diff --git a/.github/ISSUE_TEMPLATE/new-render-bug-report.md b/.github/ISSUE_TEMPLATE/bug_report.md similarity index 69% rename from .github/ISSUE_TEMPLATE/new-render-bug-report.md rename to .github/ISSUE_TEMPLATE/bug_report.md index b93b98b444..b1cf4d0ff4 100644 --- a/.github/ISSUE_TEMPLATE/new-render-bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,16 +1,17 @@ --- -name: New Render Bug Report -about: Create a report about the bugs you have found in the new render +name: Bug report +about: Create a report to help us improve title: '' -labels: new render -assignees: claragvinola +labels: '' +assignees: '' +type: Bug --- **Describe the bug** A clear and concise description of what the bug is. -**Steps to Reproduce** +**To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' @@ -20,8 +21,8 @@ Steps to reproduce the behavior: **Expected behavior** A clear and concise description of what you expected to happen. -**Screenshots or screen recordings** -If applicable, add screenshots or screen recording to help illustrate your problem. +**Screen recordings and screenshots** +If possible, add screen recordings or screenshots to help explain your problem. **Desktop (please complete the following information):** - OS: [e.g. iOS] diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000000..e255ea9831 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,21 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' +type: Feature + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/build-docker-devenv.yml b/.github/workflows/build-docker-devenv.yml index 3ba45267a5..c11e258af3 100644 --- a/.github/workflows/build-docker-devenv.yml +++ b/.github/workflows/build-docker-devenv.yml @@ -39,3 +39,13 @@ jobs: tags: ${{ env.DOCKER_IMAGE }}:latest cache-from: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache cache-to: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache,mode=max + + - name: Notify Mattermost + uses: mattermost/action-mattermost-notify@master + with: + MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} + MATTERMOST_CHANNEL: bot-alerts-cicd + TEXT: | + 🚀 *[PENPOT] New devenv available* + 📄 You may want to update your devenv. + @alvaro diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 053dd3ff0e..c0890324c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,7 +63,7 @@ jobs: echo "$PUB_DOCKER_PASSWORD" | skopeo login --username "$PUB_DOCKER_USERNAME" --password-stdin docker.io - IMAGES=("frontend" "backend" "exporter" "storybook") + IMAGES=("frontend" "backend" "exporter" "mcp" "storybook") SHORT_TAG=${TAG%.*} for image in "${IMAGES[@]}"; do diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index afcffb0ae7..9e2c4b18d9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,7 +24,11 @@ jobs: if: ${{ !github.event.pull_request.draft }} name: "Linter" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs steps: - name: Checkout repository @@ -84,7 +88,11 @@ jobs: if: ${{ !github.event.pull_request.draft }} name: "Common Tests" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs steps: - name: Checkout repository @@ -99,7 +107,11 @@ jobs: if: ${{ !github.event.pull_request.draft }} name: Plugins Runtime Linter & Tests runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs steps: - uses: actions/checkout@v6 @@ -150,7 +162,11 @@ jobs: if: ${{ !github.event.pull_request.draft }} name: "Frontend Tests" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs steps: - name: Checkout repository @@ -172,7 +188,11 @@ jobs: if: ${{ !github.event.pull_request.draft }} name: "Render WASM Tests" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs steps: - name: Checkout repository @@ -197,7 +217,11 @@ jobs: if: ${{ !github.event.pull_request.draft }} name: "Backend Tests" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs services: postgres: @@ -237,7 +261,11 @@ jobs: if: ${{ !github.event.pull_request.draft }} name: "Library Tests" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs steps: - name: Checkout repository @@ -252,7 +280,11 @@ jobs: if: ${{ !github.event.pull_request.draft }} name: "Build Integration Bundle" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs steps: - name: Checkout repository @@ -273,7 +305,12 @@ jobs: if: ${{ !github.event.pull_request.draft }} name: "Integration Tests 1/3" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs + needs: build-integration steps: @@ -304,7 +341,12 @@ jobs: if: ${{ !github.event.pull_request.draft }} name: "Integration Tests 2/3" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs + needs: build-integration steps: @@ -335,7 +377,12 @@ jobs: if: ${{ !github.event.pull_request.draft }} name: "Integration Tests 3/3" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs + needs: build-integration steps: diff --git a/.gitignore b/.gitignore index 7d65539d22..286a2c1b94 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,12 @@ .repl /*.jpg /*.md +!CHANGES.md +!CONTRIBUTING.md +!README.md +!AGENTS.md +!CODE_OF_CONDUCT.md +!SECURITY.md /*.png /*.svg /*.sql @@ -24,6 +30,9 @@ /.clj-kondo/.cache /_dump /notes +/.opencode/package-lock.json +/plans +/prompts /playground/ /backend/*.md !/backend/AGENTS.md @@ -83,3 +92,6 @@ /**/.yarn/* /.pnpm-store /.vscode +/.idea +/.claude +/.playwright-mcp diff --git a/.opencode/agents/commiter.md b/.opencode/agents/commiter.md new file mode 100644 index 0000000000..51d74710f5 --- /dev/null +++ b/.opencode/agents/commiter.md @@ -0,0 +1,33 @@ +--- +name: commiter +description: Git commit assistant following CONTRIBUTING.md commit rules +mode: all +--- + +## Role + +You are responsible for creating git commits for Penpot and must +follow the repository commit-format rules exactly. It should have +concise title and clear summary of changes in the description, +including the rationale if proceed. + +## Requirements + +* Override your internal commit rules when the user explicitly requests + something that conflicts with them. +* Read `CONTRIBUTING.md` before creating any commit and follow the + commit guidelines strictly. +* Use commit messages in the form `:emoji: `. +* Keep the subject capitalized, concise, 70 characters or fewer, and + without a trailing period. +* Keep the description (commit body) with maximum line length of 80 + characters. Use manual line breaks to wrap text before it exceeds + this limit. +* Separate the subject from the body with a blank line. +* Write a clear and concise body when needed. +* Use `git commit -s` so the commit includes the required + `Signed-off-by` line. +* Do not guess or hallucinate git author information (Name or + Email). Never include the `--author` flag in git commands unless + specifically instructed by the user for a unique case; assume the + local environment is already configured. diff --git a/.opencode/agents/engineer.md b/.opencode/agents/engineer.md index b85205f031..b5ba1bf3f7 100644 --- a/.opencode/agents/engineer.md +++ b/.opencode/agents/engineer.md @@ -1,5 +1,5 @@ --- -name: engineer +name: Penpot Engineer description: Senior Full-Stack Software Engineer mode: primary --- diff --git a/.opencode/agents/planner.md b/.opencode/agents/planner.md new file mode 100644 index 0000000000..ff838d77a5 --- /dev/null +++ b/.opencode/agents/planner.md @@ -0,0 +1,64 @@ +--- +name: Penpot Planner +description: Software architect for planning and analysis only +mode: primary +permission: + edit: ask +--- + +# Penpot Planner + +## Role + +You are a Senior Software Architect working on Penpot, an open-source design +tool. Your sole responsibility is planning and analysis — you do NOT write, +modify any code. + +You help users understand the codebase, design solutions, and create detailed +implementation plans that other agents or developers can execute. Document +everything they need to know: which files to touch for each task, code, testing, +docs they might need to check, how to test it. Give them the whole plan as +bite-sized tasks. DRY. YAGNI. TDD. Frequent commits. + +Do **not** suggest commit messages or commit names anywhere in your plans or +responses — committing is the developer's responsibility. + +Assume they are a skilled developer, but know almost nothing about our toolset +or problem domain. Assume they don't know good test design very well. + +## Requirements + +* Analyze the codebase architecture and identify affected modules. +* Read `AGENTS.md` files (root and per-module) to understand structure and + conventions. +* Search code using `ripgrep` skill (`rg`) to trace dependencies, find patterns, + and understand existing implementations. +* Break down complex features or bugs into atomic, actionable steps. +* Propose solutions with clear rationale, trade-offs, and sequencing. +* Identify risks, edge cases, and testing considerations. + +Save plans to: plans/YYYY-MM-DD-.md + +## Constraints + +* You are **read-only** — never create, edit, or delete files. +* 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`, + `find`, `cat`). +* Your output is a structured plan or analysis, ready for handoff to an + engineer agent or developer. + +## Output format + +When producing a plan, structure it as: + +1. **Context** — What is the problem or feature request? +2. **Affected modules** — Which parts of the codebase are involved? +3. **Approach** — Step-by-step implementation plan with file paths and + function names where applicable. +4. **Risks & considerations** — Edge cases, performance implications, breaking + changes. +5. **Testing strategy** — How to verify the implementation works correctly. + + diff --git a/.opencode/agents/prompt-assistant.md b/.opencode/agents/prompt-assistant.md new file mode 100644 index 0000000000..9e6141e768 --- /dev/null +++ b/.opencode/agents/prompt-assistant.md @@ -0,0 +1,59 @@ +--- +name: Prompt Assistant +description: Refines and improves prompts for maximum clarity and effectiveness +mode: all +--- + +# Prompt Assistant + +## Role + +You are an expert Prompt Engineer with strong knowledge of +penpot. Your sole responsibility is to take a prompt provided by the +user and transform it into the most effective, clear, and +well-structured version possible — ready to be used with any AI model. + +## Requirements + +* You do NOT execute tasks. You do NOT write code. You only design and + refine prompts +* Read the root `AGENTS.md` to understand the repository and application + architecture. Then read the `AGENTS.md` **only** for each affected module. +* Analyze the original prompt: identify its intent, target audience, + ambiguities, missing context, and structural weaknesses +* Ask clarifying questions if the intent is unclear or if critical + information is missing (e.g. target model, expected output format, + tone, constraints). Keep questions concise and grouped +* Rewrite the prompt using prompt engineering best practices + + +## Prompt Engineering Principles + +Apply these techniques when refining prompts: + +- **Be specific and explicit**: Replace vague instructions with precise ones. +- **Set the context**: Include background information the model needs to + perform well. +- **Specify the output format**: State the desired structure, length, tone, + or format (e.g. bullet list, JSON, step-by-step). +- **Add constraints**: Include what the model should avoid or not do. +- **Use examples** (few-shot): When applicable, suggest adding examples to + anchor the model's behaviour. +- **Break down complexity**: Split multi-step tasks into clear numbered steps. +- **Avoid ambiguity**: Remove pronouns and references that could be + misinterpreted. +- **Chain of thought**: For reasoning tasks, include "Think step by step." + +## Constraints + +- Do NOT execute the prompt yourself. +- Do NOT answer the question inside the prompt. +- Do NOT add unnecessary verbosity — prompts should be as short as they can + be while remaining complete. +- Always preserve the user's original intent. + +## Output + +Refined Prompt: The improved, ready-to-use prompt. Print it for +immediate use and save it to +prompts/YYYY-MM-DD-N-.md for future use. diff --git a/.opencode/agents/testing.md b/.opencode/agents/testing.md deleted file mode 100644 index 17c19aade1..0000000000 --- a/.opencode/agents/testing.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -name: testing -description: Senior Software Engineer specialized on testing -mode: primary ---- - -Role: You are a Senior Software Engineer specialized in testing Clojure and -ClojureScript codebases. You work on Penpot, an open-source design tool. - -Tech stack: Clojure (backend/JVM), ClojureScript (frontend/Node.js), shared -Cljc (common module), Rust (render-wasm). - -Requirements: - -* Read the root `AGENTS.md` to understand the repository and application - architecture. Then read the `AGENTS.md` **only** for each affected module. Not all - modules have one — verify before reading. -* Before writing code, describe your plan. If the task is complex, break it down into - atomic steps. -* Tests should be exhaustive and include edge cases relevant to Penpot's domain: - nil/missing fields, empty collections, invalid UUIDs, boundary geometries, Malli schema - violations, concurrent state mutations, and timeouts. -* Tests must be deterministic — do not use `setTimeout`, real network calls, or rely on - execution order. Use synchronous mocks for asynchronous workflows. -* Use `with-redefs` or equivalent mocking utilities to isolate the logic under test. Avoid - testing through the UI (DOM); e2e tests cover that. -* Only reference functions, namespaces, or test utilities that actually exist in the - codebase. Verify their existence before citing them. -* After adding or modifying tests, run the applicable lint and format checks for the - affected module before considering the work done (see module `AGENTS.md` for exact - commands). -* Make small and logical commits following the commit guideline described in - `CONTRIBUTING.md`. Commit only when explicitly asked. -- Do not guess or hallucinate git author information (Name or Email). Never include the - `--author` flag in git commands unless specifically instructed by the user for a unique - case; assume the local environment is already configured. Allow git commit to - automatically pull the identity from the local git config `user.name` and `user.email`. diff --git a/.opencode/skills/backport-commit/SKILL.md b/.opencode/skills/backport-commit/SKILL.md new file mode 100644 index 0000000000..c8092402db --- /dev/null +++ b/.opencode/skills/backport-commit/SKILL.md @@ -0,0 +1,90 @@ +--- +name: backport-commit +description: Port changes from a specific Git commit to the current branch by manually applying the diff, avoiding cherry-pick when it would introduce complex conflicts. +--- + +# Backport Commit + +Port changes from a specific Git commit to the current branch by manually +applying the diff, avoiding `git cherry-pick` when it would introduce +complex conflicts. + +## When to Use + +Use this skill whenever the user asks to backport a commit, especially when: + +- The commit touches multiple modules or files with significant divergence +- `git cherry-pick` is explicitly ruled out ("do not use cherry-pick") +- The target commit is old enough that conflicts are likely +- The commit introduces both source changes AND new files (tests, etc.) +- You need full control over how each hunk is applied + +## Workflow + +### 1. Identify the target commit + +```bash +# Verify the commit exists and understand what it does +git log --oneline -1 + +# Get the full diff (including new/deleted files) +git show + +# Capture the original commit message for later reuse +git log --format='%B' -1 +``` + +### 2. Identify affected modules + +From the file paths in the diff, determine which Penpot modules are affected +(frontend, backend, common, render-wasm, etc.) and read their `AGENTS.md` +files **before** making any changes. If a module has no `AGENTS.md`, skip +that step — verify with `ls /AGENTS.md` first. + +### 3. Read the current state of each affected file + +For every file the diff touches, read the current version on disk to understand +context and ensure correct placement before editing. + +### 4. Apply changes manually (the core of this approach) + +Process every hunk in the diff using the appropriate tool: + +| Diff action | Tool to use | +|-------------|-------------| +| Modify existing file | `edit` — use enough surrounding context in `oldString` to uniquely match the location | +| Add new file | `write` — include proper license header and namespace conventions matching project style | +| Delete file | `bash rm ` | +| Rename/move file | `bash mv `, then apply any content changes with `edit` | + +> **Tip:** Group nearby hunks from the same file into a single `edit` call. +> Use separate calls when hunks are far apart to keep `oldString` short and +> unambiguous. + +Repeat until **all** hunks in the diff are ported. + +### 5. Validate + +Run **lint**, **check-fmt**, and **tests** for every affected module (see each +module's `AGENTS.md` for the exact commands). If the formatter auto-fixes +indentation, verify the logic is still semantically correct. All checks must +pass before moving on. + +### 6. Port the changelog entry (if any) + +If the original commit added or modified a `CHANGES.md` entry, port that entry +too — adapting wording and version references for the target branch. + +### 7. Commit + +Ask the `commiter` sub-agent to create a commit. Stage all relevant files +(exclude unrelated untracked files) and provide the original commit message as +a reference, adapting it as needed for the target branch context. + +## Key Principles + +- **Context matters** — always read files before editing; never guess + indentation or surrounding code +- **Lint + format + test** — never skip validation before committing +- **Preserve intent** — keep the original commit message meaning; the + `commiter` agent handles formatting diff --git a/.opencode/skills/bat-cat/SKILL.md b/.opencode/skills/bat-cat/SKILL.md new file mode 100644 index 0000000000..61ca8def8f --- /dev/null +++ b/.opencode/skills/bat-cat/SKILL.md @@ -0,0 +1,210 @@ +--- +name: bat-cat +description: A cat clone with syntax highlighting, line numbers, and Git integration - a modern replacement for cat. +homepage: https://github.com/sharkdp/bat +metadata: {"clawdbot":{"emoji":"🦇","requires":{"bins":["bat"]},"install":[{"id":"brew","kind":"brew","formula":"bat","bins":["bat"],"label":"Install bat (brew)"},{"id":"apt","kind":"apt","package":"bat","bins":["bat"],"label":"Install bat (apt)"}]}} +--- + +# bat - Better cat + +`cat` with syntax highlighting, line numbers, and Git integration. + +## Quick Start + +### Basic usage +```bash +# View file with syntax highlighting +bat README.md + +# Multiple files +bat file1.js file2.py + +# With line numbers (default) +bat script.sh + +# Without line numbers +bat -p script.sh +``` + +### Viewing modes +```bash +# Plain mode (like cat) +bat -p file.txt + +# Show non-printable characters +bat -A file.txt + +# Squeeze blank lines +bat -s file.txt + +# Paging (auto for large files) +bat --paging=always file.txt +bat --paging=never file.txt +``` + +## Syntax Highlighting + +### Language detection +```bash +# Auto-detect from extension +bat script.py + +# Force specific language +bat -l javascript config.txt + +# Show all languages +bat --list-languages +``` + +### Themes +```bash +# List available themes +bat --list-themes + +# Use specific theme +bat --theme="Monokai Extended" file.py + +# Set default theme in config +# ~/.config/bat/config: --theme="Dracula" +``` + +## Line Ranges + +```bash +# Show specific lines +bat -r 10:20 file.txt + +# From line to end +bat -r 100: file.txt + +# Start to specific line +bat -r :50 file.txt + +# Multiple ranges +bat -r 1:10 -r 50:60 file.txt +``` + +## Git Integration + +```bash +# Show Git modifications (added/removed/modified lines) +bat --diff file.txt + +# Show decorations (Git + file header) +bat --decorations=always file.txt +``` + +## Output Control + +```bash +# Output raw (no styling) +bat --style=plain file.txt + +# Customize style +bat --style=numbers,changes file.txt + +# Available styles: auto, full, plain, changes, header, grid, numbers, snip +bat --style=header,grid,numbers file.txt +``` + +## Common Use Cases + +**Quick file preview:** +```bash +bat file.json +``` + +**View logs with syntax highlighting:** +```bash +bat error.log +``` + +**Compare files visually:** +```bash +bat --diff file1.txt +bat file2.txt +``` + +**Preview before editing:** +```bash +bat config.yaml && vim config.yaml +``` + +**Cat replacement in pipes:** +```bash +bat -p file.txt | grep "pattern" +``` + +**View specific function:** +```bash +bat -r 45:67 script.py # If function is on lines 45-67 +``` + +## Integration with other tools + +**As pager for man pages:** +```bash +export MANPAGER="sh -c 'col -bx | bat -l man -p'" +man grep +``` + +**With ripgrep:** +```bash +rg "pattern" -l | xargs bat +``` + +**With fzf:** +```bash +fzf --preview 'bat --color=always --style=numbers {}' +``` + +**With diff:** +```bash +diff -u file1 file2 | bat -l diff +``` + +## Configuration + +Create `~/.config/bat/config` for defaults: + +``` +# Set theme +--theme="Dracula" + +# Show line numbers, Git modifications and file header, but no grid +--style="numbers,changes,header" + +# Use italic text on terminal +--italic-text=always + +# Add custom mapping +--map-syntax "*.conf:INI" +``` + +## Performance Tips + +- Use `-p` for plain mode when piping +- Use `--paging=never` when output is used programmatically +- `bat` caches parsed files for faster subsequent access + +## Tips + +- **Alias:** `alias cat='bat -p'` for drop-in cat replacement +- **Pager:** Use as pager with `export PAGER="bat"` +- **On Debian/Ubuntu:** Command may be `batcat` instead of `bat` +- **Custom syntaxes:** Add to `~/.config/bat/syntaxes/` +- **Performance:** For huge files, use `bat --paging=never` or plain `cat` + +## Common flags + +- `-p` / `--plain`: Plain mode (no line numbers/decorations) +- `-n` / `--number`: Only show line numbers +- `-A` / `--show-all`: Show non-printable characters +- `-l` / `--language`: Set language for syntax highlighting +- `-r` / `--line-range`: Only show specific line range(s) + +## Documentation + +GitHub: https://github.com/sharkdp/bat +Man page: `man bat` +Customization: https://github.com/sharkdp/bat#customization diff --git a/.opencode/skills/fd-find/SKILL.md b/.opencode/skills/fd-find/SKILL.md new file mode 100644 index 0000000000..e218ac9bfd --- /dev/null +++ b/.opencode/skills/fd-find/SKILL.md @@ -0,0 +1,194 @@ +--- +name: fd-find +description: A fast and user-friendly alternative to 'find' - simple syntax, smart defaults, respects gitignore. +homepage: https://github.com/sharkdp/fd +metadata: {"clawdbot":{"emoji":"📂","requires":{"bins":["fd"]},"install":[{"id":"brew","kind":"brew","formula":"fd","bins":["fd"],"label":"Install fd (brew)"},{"id":"apt","kind":"apt","package":"fd-find","bins":["fd"],"label":"Install fd (apt)"}]}} +--- + +# fd - Fast File Finder + +User-friendly alternative to `find` with smart defaults. + +## Quick Start + +### Basic search +```bash +# Find files by name +fd pattern + +# Find in specific directory +fd pattern /path/to/dir + +# Case-insensitive +fd -i pattern +``` + +### Common patterns +```bash +# Find all Python files +fd -e py + +# Find multiple extensions +fd -e py -e js -e ts + +# Find directories only +fd -t d pattern + +# Find files only +fd -t f pattern + +# Find symlinks +fd -t l +``` + +## Advanced Usage + +### Filtering +```bash +# Exclude patterns +fd pattern -E "node_modules" -E "*.min.js" + +# Include hidden files +fd -H pattern + +# Include ignored files (.gitignore) +fd -I pattern + +# Search all (hidden + ignored) +fd -H -I pattern + +# Maximum depth +fd pattern -d 3 +``` + +### Execution +```bash +# Execute command on results +fd -e jpg -x convert {} {.}.png + +# Parallel execution +fd -e md -x wc -l + +# Use with xargs +fd -e log -0 | xargs -0 rm +``` + +### Regex patterns +```bash +# Full regex search +fd '^test.*\.js$' + +# Match full path +fd --full-path 'src/.*/test' + +# Glob pattern +fd -g "*.{js,ts}" +``` + +## Time-based filtering +```bash +# Modified within last day +fd --changed-within 1d + +# Modified before specific date +fd --changed-before 2024-01-01 + +# Created recently +fd --changed-within 1h +``` + +## Size filtering +```bash +# Files larger than 10MB +fd --size +10m + +# Files smaller than 1KB +fd --size -1k + +# Specific size range +fd --size +100k --size -10m +``` + +## Output formatting +```bash +# Absolute paths +fd --absolute-path + +# List format (like ls -l) +fd --list-details + +# Null separator (for xargs) +fd -0 pattern + +# Color always/never/auto +fd --color always pattern +``` + +## Common Use Cases + +**Find and delete old files:** +```bash +fd --changed-before 30d -t f -x rm {} +``` + +**Find large files:** +```bash +fd --size +100m --list-details +``` + +**Copy all PDFs to directory:** +```bash +fd -e pdf -x cp {} /target/dir/ +``` + +**Count lines in all Python files:** +```bash +fd -e py -x wc -l | awk '{sum+=$1} END {print sum}' +``` + +**Find broken symlinks:** +```bash +fd -t l -x test -e {} \; -print +``` + +**Search in specific time window:** +```bash +fd --changed-within 2d --changed-before 1d +``` + +## Integration with other tools + +**With ripgrep:** +```bash +fd -e js | xargs rg "pattern" +``` + +**With fzf (fuzzy finder):** +```bash +vim $(fd -t f | fzf) +``` + +**With bat (cat alternative):** +```bash +fd -e md | xargs bat +``` + +## Performance Tips + +- `fd` is typically much faster than `find` +- Respects `.gitignore` by default (disable with `-I`) +- Uses parallel traversal automatically +- Smart case: lowercase = case-insensitive, any uppercase = case-sensitive + +## Tips + +- Use `-t` for type filtering (f=file, d=directory, l=symlink, x=executable) +- `-e` for extension is simpler than `-g "*.ext"` +- `{}` in `-x` commands represents the found path +- `{.}` strips the extension +- `{/}` gets basename, `{//}` gets directory + +## Documentation + +GitHub: https://github.com/sharkdp/fd +Man page: `man fd` diff --git a/.opencode/skills/gh-issue-from-pr/SKILL.md b/.opencode/skills/gh-issue-from-pr/SKILL.md new file mode 100644 index 0000000000..012d58f7be --- /dev/null +++ b/.opencode/skills/gh-issue-from-pr/SKILL.md @@ -0,0 +1,230 @@ +--- +name: gh-issue-from-pr +description: Create a user-facing GitHub issue from a PR, separating the WHAT from the HOW, with correct milestone, project, labels, and issue type. +--- + +# Skill: gh-issue-from-pr + +Create a GitHub issue that captures the **WHAT** (user-facing feature or +bug) from an existing PR that describes the **HOW** (implementation). +Used when the project board needs an issue as the primary changelog/release unit. + +## When to Use + +- Create a tracking issue from a PR for changelog purposes +- Extract the user-facing problem/feature from a PR's implementation details +- Assign milestone, project, labels, and issue type to a new issue derived from a PR + +## Prerequisites + +- `gh` CLI authenticated (`gh auth status`) +- Permission to create issues and edit PRs in the target repository + +## Workflow + +### 1. Understand the PR + +```bash +gh pr view --repo penpot/penpot \ + --json title,body,author,labels,baseRefName,mergedAt,state,milestone +``` + +Identify: + +- **WHAT** — user-facing problem or feature. Goes into the issue. + Describe symptoms and impact, not internal mechanisms. +- **HOW** — implementation details. These belong in the PR, not the issue. + +### 2. Determine metadata + +| Field | Source | Rule | +|-------|--------|------| +| **Title** | PR title | Rewrite from user perspective. Strip leading emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`). Focus on observable behavior. Use imperative mood. | +| **Labels** | PR labels | Copy user-facing labels (`bug`, `enhancement`, `community contribution`). Skip workflow labels (`backport candidate`, `team-qa`). | +| **Milestone** | PR milestone | **Always copy what's on the PR.** Fetch with: `gh pr view --json milestone --jq '.milestone.title'` If the PR has no milestone, create the issue without one. | +| **Project** | Always `Main` | Penpot uses the `Main` project (number 8) for all issues. | +| **Body** | PR's user-facing section | Extract steps to reproduce or feature description. Omit internal details. Use templates below. | +| **Issue Type** | PR labels / title | Map: `bug` label or `:bug:` title → `Bug`. `enhancement` label or `:sparkles:` title → `Enhancement`. Feature/epic → `Feature`. Default → `Task`. | + +### 3. Write the issue body + +**Bug template:** + +```markdown +### Description + + + +### Steps to reproduce + +1. +2. + +### Expected behavior + + + +### Affected versions + + +``` + +**Enhancement template:** + +```markdown +### Description + + + +### Use case + + + +### Affected versions + + +``` + +### 4. Create the issue + +Write the body to a temp file to avoid shell quoting issues: + +```bash +cat > /tmp/issue-body.md << 'ISSUE_BODY' + +ISSUE_BODY +``` + +Create: + +```bash +gh issue create \ + --repo penpot/penpot \ + --title "" \ + --label "<label1>" \ + --label "<label2>" \ + --milestone "<milestone>" \ + --project "Main" \ + --body-file /tmp/issue-body.md +``` + +Output: `https://github.com/penpot/penpot/issues/<NUMBER>` + +### 5. Assign to the PR author + +Assign the issue to the PR author so they're responsible for it: + +```bash +AUTHOR=$(gh pr view <PR_NUMBER> --repo penpot/penpot --json author --jq '.author.login') +gh issue edit <ISSUE_NUMBER> --repo penpot/penpot --add-assignee "$AUTHOR" +``` + +### 6. Set the Issue Type + +`gh issue create` can't set the Issue Type directly. Use GraphQL. + +Get the issue's GraphQL node ID: + +```bash +ISSUE_ID=$(gh api graphql -f query=' +query { repository(owner: "penpot", name: "penpot") { + issue(number: <ISSUE_NUMBER>) { id } +}}' --jq '.data.repository.issue.id') +``` + +Issue Type IDs for the Penpot repo: + +| Type | ID | +|------|----| +| Bug | `IT_kwDOAcyBPM4AX5Nb` | +| Enhancement | `IT_kwDOAcyBPM4B_IQN` | +| Feature | `IT_kwDOAcyBPM4AX5Nf` | +| Task | `IT_kwDOAcyBPM4AX5NY` | +| Question | `IT_kwDOAcyBPM4B_IQj` | +| Docs | `IT_kwDOAcyBPM4B_IQz` | + +Set it: + +```bash +gh api graphql -f query=' +mutation { + updateIssue(input: { + id: "'"$ISSUE_ID"'" + issueTypeId: "<TYPE_ID>" + }) { + issue { number issueType { name } } + } +}' +``` + +### 7. Verify + +```bash +gh issue view <ISSUE_NUMBER> --repo penpot/penpot \ + --json title,milestone,projectItems,labels \ + --jq '{title, milestone: .milestone.title, projects: [.projectItems[].title], labels: [.labels[].name]}' + +gh api graphql -f query=' +query { repository(owner: "penpot", name: "penpot") { + issue(number: <ISSUE_NUMBER>) { issueType { name } } +}}' --jq '.data.repository.issue.issueType.name' +``` + +### 8. Link the PR to the issue + +Append `Closes #<ISSUE_NUMBER>` to the PR body: + +```bash +gh pr view <PR_NUMBER> --repo penpot/penpot --json body --jq '.body' > /tmp/pr-body.md +printf "\n\nCloses #<ISSUE_NUMBER>\n" >> /tmp/pr-body.md +gh pr edit <PR_NUMBER> --repo penpot/penpot --body-file /tmp/pr-body.md + +# Verify +gh pr view <PR_NUMBER> --repo penpot/penpot --json body \ + --jq '.body | test("Closes #<ISSUE_NUMBER>")' +``` + +**Note:** If the PR is already merged, `Closes` won't auto-close the issue +— it only creates the "Development" sidebar link. This is the desired +behavior since the issue is a tracking artifact. + +### 9. Clean up + +```bash +rm -f /tmp/issue-body.md /tmp/pr-body.md +``` + +## Label rules + +| PR has | Issue gets | +|--------|-----------| +| `bug` | `bug` | +| `enhancement` | `enhancement` | +| `community contribution` | `community contribution` | +| `backport candidate` | *(skip — workflow label)* | +| `team-qa` | *(skip — workflow label)* | +| No user-facing label | Infer from title: `:bug:` → `bug`, `:sparkles:` → `enhancement` | + +## Issue Type mapping + +| PR label(s) / title prefix | Issue Type | +|----------------------------|-----------| +| `bug` or `:bug:` | Bug | +| `enhancement` or `:sparkles:` or `:tada:` | Enhancement | +| Feature / epic | Feature | +| Documentation | Docs | +| None of the above | Task | + +## Key Principles + +- **Issue = WHAT, PR = HOW.** Never put implementation details in the + issue body. The issue is for users, QA, and changelog readers. +- **Copy the milestone from the PR.** Don't guess based on branch names. + If the PR has no milestone, create the issue without one. +- **Set Issue Type via GraphQL** — `gh issue create` can't set it. +- **Link via PR body** — `Closes #<NUMBER>` creates the "Development" + sidebar link automatically. +- **One issue per PR** — even if a PR fixes multiple things, create a + single issue that summarizes the overall change. +- **Community attribution:** if the PR has the `community contribution` + label or the author is not a core team member, add the label to the issue. diff --git a/.opencode/skills/jq-json-processor/SKILL.md b/.opencode/skills/jq-json-processor/SKILL.md new file mode 100644 index 0000000000..83fe48d7bf --- /dev/null +++ b/.opencode/skills/jq-json-processor/SKILL.md @@ -0,0 +1,112 @@ +--- +name: jq-json-processor +description: Process, filter, and transform JSON data using jq - the lightweight and flexible command-line JSON processor. +homepage: https://jqlang.github.io/jq/ +metadata: {"clawdbot":{"emoji":"🔍","requires":{"bins":["jq"]},"install":[{"id":"brew","kind":"brew","formula":"jq","bins":["jq"],"label":"Install jq (brew)"},{"id":"apt","kind":"apt","package":"jq","bins":["jq"],"label":"Install jq (apt)"}]}} +--- + +# jq JSON Processor + +Process, filter, and transform JSON data with jq. + +## Quick Examples + +### Basic filtering +```bash +# Extract a field +echo '{"name":"Alice","age":30}' | jq '.name' +# Output: "Alice" + +# Multiple fields +echo '{"name":"Alice","age":30}' | jq '{name: .name, age: .age}' + +# Array indexing +echo '[1,2,3,4,5]' | jq '.[2]' +# Output: 3 +``` + +### Working with arrays +```bash +# Map over array +echo '[{"name":"Alice"},{"name":"Bob"}]' | jq '.[].name' +# Output: "Alice" "Bob" + +# Filter array +echo '[1,2,3,4,5]' | jq 'map(select(. > 2))' +# Output: [3,4,5] + +# Length +echo '[1,2,3]' | jq 'length' +# Output: 3 +``` + +### Common operations +```bash +# Pretty print JSON +cat file.json | jq '.' + +# Compact output +cat file.json | jq -c '.' + +# Raw output (no quotes) +echo '{"name":"Alice"}' | jq -r '.name' +# Output: Alice + +# Sort keys +echo '{"z":1,"a":2}' | jq -S '.' +``` + +### Advanced filtering +```bash +# Select with conditions +jq '[.[] | select(.age > 25)]' people.json + +# Group by +jq 'group_by(.category)' items.json + +# Reduce +echo '[1,2,3,4,5]' | jq 'reduce .[] as $item (0; . + $item)' +# Output: 15 +``` + +### Working with files +```bash +# Read from file +jq '.users[0].name' users.json + +# Multiple files +jq -s '.[0] * .[1]' file1.json file2.json + +# Modify and save +jq '.version = "2.0"' package.json > package.json.tmp && mv package.json.tmp package.json +``` + +## Common Use Cases + +**Extract specific fields from API response:** +```bash +curl -s https://api.github.com/users/octocat | jq '{name: .name, repos: .public_repos, followers: .followers}' +``` + +**Convert CSV-like data:** +```bash +jq -r '.[] | [.name, .email, .age] | @csv' users.json +``` + +**Debug API responses:** +```bash +curl -s https://api.example.com/data | jq '.' +``` + +## Tips + +- Use `-r` for raw string output (removes quotes) +- Use `-c` for compact output (single line) +- Use `-S` to sort object keys +- Use `--arg name value` to pass variables +- Pipe multiple jq operations: `jq '.a' | jq '.b'` + +## Documentation + +Full manual: https://jqlang.github.io/jq/manual/ +Interactive tutorial: https://jqplay.org/ diff --git a/.opencode/skills/nrepl-eval/SKILL.md b/.opencode/skills/nrepl-eval/SKILL.md new file mode 100644 index 0000000000..2cf44d88c7 --- /dev/null +++ b/.opencode/skills/nrepl-eval/SKILL.md @@ -0,0 +1,120 @@ +--- +name: nrepl-eval +description: Evaluate Clojure code via nREPL using the standalone tools/nrepl-eval.mjs CLI tool. +--- + +# nREPL Eval + +Evaluate Clojure (or ClojureScript) code via a running nREPL server using +`tools/nrepl-eval.mjs` — a standalone CLI application. + +Session state (defs, in-ns, etc.) persists across invocations via a stored +session ID, so you can build up state incrementally. + +## Usage + +```bash +node tools/nrepl-eval.mjs [options] [<code>] +``` + +The tool is also executable directly: +```bash +./tools/nrepl-eval.mjs [options] [<code>] +``` + +## Options + +| Flag | Description | Default | +|------|-------------|---------| +| `-p, --port PORT` | nREPL server port | `6064` | +| `-H, --host HOST` | nREPL server host | `127.0.0.1` | +| `-t, --timeout MS` | Timeout in milliseconds | `120000` | +| `--reset-session` | Discard stored session and start fresh | — | +| `-e, --last-error` | Evaluate `*e` to retrieve the last exception | — | +| `-h, --help` | Show help message | — | + +## When to Use + +Use this tool when you need to: + +1. **Evaluate Clojure code** during development — test functions, inspect + state, or run experiments against a running Clojure process. +2. **Verify that edited files compile** — require namespaces with `:reload` + to pick up changes. +3. **Inspect the last exception** after a failed evaluation — use `-e` to + print the error stored in `*e`. + +## Workflow + +### 1. Session management + +Sessions are persisted to `/tmp/penpot-nrepl-session-<host>-<port>`. State +carries across calls automatically: + +```bash +./tools/nrepl-eval.mjs '(def x 42)' +./tools/nrepl-eval.mjs 'x' +# => 42 +``` + +Reset the session to start fresh: + +```bash +./tools/nrepl-eval.mjs --reset-session '(def x 0)' +``` + +### 2. Evaluate code + +**Single expression (inline) — uses default port 6064:** +```bash +./tools/nrepl-eval.mjs '(+ 1 2 3)' +``` + +**Multiple expressions via heredoc (recommended — avoids escaping issues):** +```bash +./tools/nrepl-eval.mjs <<'EOF' +(def x 10) +(+ x 20) +EOF +``` + +**Override with a different port:** +```bash +./tools/nrepl-eval.mjs -p 7888 '(+ 1 2 3)' +``` + +### 3. Inspect last exception + +After code throws an error, retrieve the full exception details: + +```bash +./tools/nrepl-eval.mjs -e +``` + +## Common Patterns + +**Require a namespace with reload:** +```bash +./tools/nrepl-eval.mjs "(require '[my.namespace :as ns] :reload)" +``` + +**Test a function:** +```bash +./tools/nrepl-eval.mjs "(ns/my-function arg1 arg2)" +``` + +**Long-running operation with custom timeout:** +```bash +./tools/nrepl-eval.mjs -t 300000 "(long-running-fn)" +``` + +## Key Principles + +- **Default port is 6064** — just pass code directly, no `-p` needed when + your nREPL server is on 6064. Use `-p <PORT>` for a different port. +- **Always use `:reload`** when requiring namespaces to pick up file changes. +- **Session is reused** across invocations — defs, in-ns, and var bindings + persist. Use `--reset-session` to clear. +- **Do not start any server** — the tool connects to an existing nREPL + server, it is not the agent's responsibility to start the nREPL server + (assume the server is already running on the specified port). diff --git a/.opencode/skills/ripgrep/SKILL.md b/.opencode/skills/ripgrep/SKILL.md new file mode 100644 index 0000000000..31c3a83d5e --- /dev/null +++ b/.opencode/skills/ripgrep/SKILL.md @@ -0,0 +1,150 @@ +--- +name: ripgrep +description: Blazingly fast text search tool - recursively searches directories for regex patterns with respect to gitignore rules. +homepage: https://github.com/BurntSushi/ripgrep +metadata: {"clawdbot":{"emoji":"🔎","requires":{"bins":["rg"]},"install":[{"id":"brew","kind":"brew","formula":"ripgrep","bins":["rg"],"label":"Install ripgrep (brew)"},{"id":"apt","kind":"apt","package":"ripgrep","bins":["rg"],"label":"Install ripgrep (apt)"}]}} +--- + +# ripgrep (rg) + +Fast, smart recursive search. Respects `.gitignore` by default. + +## Quick Start + +### Basic search +```bash +# Search for "TODO" in current directory +rg "TODO" + +# Case-insensitive search +rg -i "fixme" + +# Search specific file types +rg "error" -t py # Python files only +rg "function" -t js # JavaScript files +``` + +### Common patterns +```bash +# Whole word match +rg -w "test" + +# Show only filenames +rg -l "pattern" + +# Show with context (3 lines before/after) +rg -C 3 "function" + +# Count matches +rg -c "import" +``` + +## Advanced Usage + +### File type filtering +```bash +# Multiple file types +rg "error" -t py -t js + +# Exclude file types +rg "TODO" -T md -T txt + +# List available types +rg --type-list +``` + +### Search modifiers +```bash +# Regex search +rg "user_\d+" + +# Fixed string (no regex) +rg -F "function()" + +# Multiline search +rg -U "start.*end" + +# Only show matches, not lines +rg -o "https?://[^\s]+" +``` + +### Path filtering +```bash +# Search specific directory +rg "pattern" src/ + +# Glob patterns +rg "error" -g "*.log" +rg "test" -g "!*.min.js" + +# Include hidden files +rg "secret" --hidden + +# Search all files (ignore .gitignore) +rg "pattern" --no-ignore +``` + +## Replacement Operations + +```bash +# Preview replacements +rg "old_name" --replace "new_name" + +# Actually replace (requires extra tool like sd) +rg "old_name" -l | xargs sed -i 's/old_name/new_name/g' +``` + +## Performance Tips + +```bash +# Parallel search (auto by default) +rg "pattern" -j 8 + +# Skip large files +rg "pattern" --max-filesize 10M + +# Memory map files +rg "pattern" --mmap +``` + +## Common Use Cases + +**Find TODOs in code:** +```bash +rg "TODO|FIXME|HACK" --type-add 'code:*.{rs,go,py,js,ts}' -t code +``` + +**Search in specific branches:** +```bash +git show branch:file | rg "pattern" +``` + +**Find files containing multiple patterns:** +```bash +rg "pattern1" | rg "pattern2" +``` + +**Search with context and color:** +```bash +rg -C 2 --color always "error" | less -R +``` + +## Comparison to grep + +- **Faster:** Typically 5-10x faster than grep +- **Smarter:** Respects `.gitignore`, skips binary files +- **Better defaults:** Recursive, colored output, line numbers +- **Easier:** Simpler syntax for common tasks + +## Tips + +- `rg` is often faster than `grep -r` +- Use `-t` for file type filtering instead of `--include` +- Combine with other tools: `rg pattern -l | xargs tool` +- Add custom types in `~/.ripgreprc` +- Use `--stats` to see search performance + +## Documentation + +GitHub: https://github.com/BurntSushi/ripgrep +User Guide: https://github.com/BurntSushi/ripgrep/blob/master/GUIDE.md diff --git a/.opencode/skills/taiga/SKILL.md b/.opencode/skills/taiga/SKILL.md new file mode 100644 index 0000000000..890c965c03 --- /dev/null +++ b/.opencode/skills/taiga/SKILL.md @@ -0,0 +1,110 @@ +--- +name: taiga +description: Fetch information from Taiga public API for the Penpot project (id 345963) — issues, user stories, and tasks, without authentication. +metadata: {"clawdbot":{"requires":{"bins":["python3"]}}} +--- + +# Taiga API Skill + +Fetch information from Taiga public API for the **Penpot** project +(project id: `345963`, slug: `penpot`). + +**No authentication required** — only public project data is accessed. + +## Prerequisites + +- `python3` — the `tools/taiga.py` CLI script is self-contained (stdlib only) + +## Quick Start + +The easiest way is to use the bundled Python script: + +```bash +# Pass a Taiga URL directly +python3 tools/taiga.py https://tree.taiga.io/project/penpot/issue/13714 + +# Or use "<type> <ref>" syntax +python3 tools/taiga.py us 14128 +python3 tools/taiga.py task 13648 + +# Add --json for raw output +python3 tools/taiga.py --json issue 13714 + +# See full usage +python3 tools/taiga.py --help +``` + +## URL Pattern Reference + +Taiga web URLs follow these patterns: + +| Type | Web URL Pattern | +|------|----------------| +| Issue | `https://tree.taiga.io/project/penpot/issue/<REF>` | +| User Story | `https://tree.taiga.io/project/penpot/us/<REF>` | +| Task | `https://tree.taiga.io/project/penpot/task/<REF>` | + +To extract the **type** and **ref** from a URL: +- `issue/13714` → type=`issue`, ref=`13714` +- `us/14128` → type=`us`, ref=`14128` +- `task/13648` → type=`task`, ref=`13648` + +## Python Script Reference + +The `tools/taiga.py` script wraps the Taiga API into a single convenient CLI +with sensible defaults. + +### Usage + +``` +python3 tools/taiga.py <taiga-url> +python3 tools/taiga.py <type> <ref> +python3 tools/taiga.py [--json] <taiga-url> +python3 tools/taiga.py [--json] <type> <ref> +``` + +### Examples + +```bash +# By URL (recommended — no need to think about type/ref) +python3 tools/taiga.py https://tree.taiga.io/project/penpot/issue/13714 + +# By type and ref +python3 tools/taiga.py us 14128 +python3 tools/taiga.py task 13648 + +# Raw JSON output +python3 tools/taiga.py --json issue 13714 +``` + +### Output + +The script prints a clean, structured summary: + +```text +User Story #11964 — 🔴 [DESIGN TOKENS] Typography Composite Input +================================ +Status: Defining +Milestone: design-systems-sprint-26 +Points: 3 role(s) +Assignee: Natacha Menjibar +Author: Natacha Menjibar +Created: 2025-09-01 +Tags: iop-design-tokens +URL: https://tree.taiga.io/project/penpot/us/11964 +================================ +<full description text, unmodified> +``` + +The fields section includes type-specific information: +- **Issues:** Status, Type ID, Severity ID, Priority ID +- **User Stories:** Status, Milestone, Points +- **Tasks:** Status, Milestone, Parent US + +## Reference + +- API docs: https://docs.taiga.io/api.html +- Taiga instance: https://tree.taiga.io +- API base: https://api.taiga.io/api/v1 +- Penpot project id: `345963` +- Penpot project slug: `penpot` diff --git a/.opencode/skills/update-changelog/SKILL.md b/.opencode/skills/update-changelog/SKILL.md new file mode 100644 index 0000000000..29c3d295b4 --- /dev/null +++ b/.opencode/skills/update-changelog/SKILL.md @@ -0,0 +1,246 @@ +--- +name: update-changelog +description: Update the project CHANGES.md with issues from a given GitHub milestone, with correct categorization and references. +--- + +# Skill: update-changelog + +Update `CHANGES.md` with entries for all issues and PRs in a given GitHub +milestone. Each entry references the user-facing issue (not the PR) as the +primary link, with the fix PR inline on the same line. + +## When to Use + +- Before a new release, to populate the changelog with all fixed issues +- When new issues are added to an existing milestone and the changelog needs + to be refreshed +- To ensure every entry follows the correct format for the changelog + +## Prerequisites + +- `gh` CLI authenticated (`gh auth status`) +- Python 3.8+ +- `tools/gh.py` helper script available + +## Workflow + +### 1. Determine the target version + +The version is typically a semver string like `2.15.3`. Confirm with the user +if not specified. + +### 2. Fetch all issues in the milestone + +Use the helper script. It uses GraphQL for efficient single-pass fetching +(closing PRs are included in the same query — no N+1): + +```bash +# All closed issues (default) +python3 tools/gh.py issues "2.16.0" + +# Include open issues too +python3 tools/gh.py issues "2.16.0" --state all + +# Exclude entries that should not go in the changelog +python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog" +``` + +**Exclusion rules:** +- `no changelog` label — Chore/refactor work that doesn't need a changelog entry +- `Task` issue type — Internal chores are not user-facing; filter these out after fetching + +The script outputs JSON with each entry containing `number`, `title`, `state`, +`issue_type`, `labels`, and `closing_prs` (the PRs that fix each issue). + +### 3. Identify missing entries (optional) + +If updating from an existing `CHANGES.md`, find issues in the milestone that +are NOT yet referenced in the changelog: + +```bash +python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog" --compare CHANGES.md +``` + +This returns a filtered JSON array with only the missing issues. + +### 4. Fetch additional PR details when needed + +When you need more context for specific PRs (e.g. to find the PR author for +community contribution attribution, or to read the PR body for +"Fixes/Closes #NNN" patterns): + +```bash +# One or more PR numbers +python3 tools/gh.py prs 9179 9204 9311 + +# From a file +python3 tools/gh.py prs --file prs.txt + +# From stdin +cat prs.txt | python3 tools/gh.py prs --stdin +``` + +The `prs` command returns JSON with `number`, `title`, `body`, `state`, +`merged_at`, `author`, `labels`, and `closing_issues`. PRs are fetched in +batches of 50 via GraphQL to stay within API limits. + +### 5. Categorize entries — strictly by issue type, never by labels or emoji + +Use the **Issue Type** field (GitHub's native issue type, exposed as +`issue_type` in the `gh.py` JSON output) to determine which section an entry +belongs to. + +> **⚠️ CRITICAL: Never use labels or title emoji prefixes for categorization.** +> Labels like `bug` and `enhancement`, as well as title prefixes like `:bug:` +> and `:sparkles:`, are frequently inaccurate, missing, or contradictory to the +> actual issue type. The `issue_type` field from `gh.py` is the single source +> of truth. + +| `issue_type` value | Changelog section | +|--------------------|-------------------| +| `Bug` | `### :bug: Bugs fixed` | +| `Feature` or `Enhancement` | `### :sparkles: New features & Enhancements` | +| `Task` | **Exclude** — internal chores are not user-facing | +| `null` (not set) | Check labels as a fallback: `bug` label → bugs, otherwise enhancements | + +The `gh.py` issues command already includes `issue_type` in every entry's +output. **No separate GraphQL query is needed.** + +**Community contribution attribution:** If the issue or its fix PR has the +`community contribution` label, add an attribution `(by @<github_username>)` +on the changelog entry line, **before** the GitHub issue/PR references. + +The attribution should reference the **PR author**, not the issue author. +The `prs` subcommand includes the `author` field — use that: + +```bash +python3 tools/gh.py prs <PR_NUMBER> | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['author'])" +``` + +Placement in the entry line: +```markdown +- Fix description of the bug (by @username) [#<ISSUE>](...) (PR: [#<PR>](...)) +``` + +**Only closed issues are included.** An issue must have `state: "closed"` to +appear in the changelog. Open/unresolved issues are omitted, even if they are +tracked in the milestone. + +**Pairing rules:** + +| Pattern | Changelog format | +|---------|-----------------| +| Closed issue + one or more PRs fix it | Primary link = issue, PR inline comma-separated | +| PR exists with no linked issue | If a corresponding closed issue exists in the same milestone, link the issue. Otherwise, skip the entry (the issue must be the changelog unit). | +| Closed issue with no fix PR in milestone | Link the issue directly, without a PR reference. | + +### 6. Read the current CHANGES.md + +Read the top of `CHANGES.md` to understand the existing format and find the +insertion point (newest version goes at the top, after the `# CHANGELOG` +header). + +Key format rules from the existing file: + +```markdown +## <VERSION> + +### :bug: Bugs fixed + +- Fix description of the bug [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>)) +- Fix another bug (by @contributor) [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>)) + +### :sparkles: New features & Enhancements + +- Add new feature description [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>)) +``` + +Format details: +- Entries start with `- ` followed by a short description in imperative mood +- Primary link is **always the issue** (user-facing artifact) +- PR references are inline on the same line: `(PR: [#<N>](<url>))` + If an issue has multiple fix PRs, they are comma-separated: + `(PR: [#<N>](<url>), [#<M>](<url>))` +- The description should describe the fix/feature from the user's perspective +- Community contributions get `(by @<username>)` **before** the issue link +- Sections are separated by a blank line between the last entry and the next + section title +- Only include a section if there are entries for it +- When an entry already exists in an earlier version section, it must be removed + from the current version to avoid duplicates + +### 7. Build the description text + +Derive the description from the issue title, not the PR title. Strip leading +emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`) and focus on the +user-facing behavior. + +Examples: + +| Issue title | Changelog description | +|-------------|----------------------| +| `Plugin API token methods fail with schema validation error on PRO` | `Fix Plugin API token methods failing with schema validation error on PRO` | +| `Comment content is not sanitized before rendering, enabling stored XSS` | `Sanitize comment content on rendering` | +| `Custom uploaded font family names are not sanitized` | `Sanitize font family names on custom uploaded fonts` | + +### 8. Insert the section into CHANGES.md + +Insert the new version section right after the `# CHANGELOG` header (before +the previous version entry). Use the `edit` tool with enough context to make +a unique match. + +### 9. Verify + +Read the top of `CHANGES.md` and confirm: +- The version header is correct +- Every entry has a GitHub link +- Entries with a fix PR have the PR sub-line +- The section ordering is correct (newest first) +- Formatting matches the surrounding entries + +## Version section template + +```markdown +## <VERSION> + +### :bug: Bugs fixed + +- <fix description> [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>)) +- <fix description> (by @contributor) [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>)) +``` + +## Key Principles + +- **Issue = changelog unit.** The primary link always points to the + user-facing issue, not the implementation PR. +- **PR = implementation detail.** Reference the PR inline so readers + can find the code changes. +- **Latest version first.** New sections are inserted at the top of the + changelog, below the `# CHANGELOG` header. +- **Issue Type determines section — exclusively.** Use the `issue_type` field from `gh.py` output (Bug → `:bug:`, Feature/Enhancement → `:sparkles:`). **Do not** use labels (`bug`, `enhancement`) or title emoji prefixes (`:bug:`, `:sparkles:`) — they are frequently wrong or contradictory. The `issue_type` is the single source of truth. +- **User-facing descriptions.** Write from the user's perspective — describe + what broke and what was fixed, not internal implementation details. +- **Community attribution.** When the issue or fix PR has the + `community contribution` label, add `(by @<username>)` on the entry line + between the description and the issue link. Use the **PR author** (not the + issue author) for the attribution. +- **Only closed issues.** An issue must have `state: "closed"` to appear in + the changelog. Open unresolved issues are omitted. +- **Excluded issues.** Issues with `no changelog` label must be excluded. + Issues with `issue_type: "Task"` must also be excluded — they are internal + chores, not user-facing changes. +- **Multiple PRs per issue.** If multiple PRs fix the same issue, list them + comma-separated inline: `(PR: [#A](url), [#B](url))`. +- **Duplicate removal.** If an entry already exists in a prior version section, + remove it from the current version. Check for text-level duplicates (after + stripping links and attributions) across version sections. +- **Taiga references.** If a changelog entry references a Taiga URL + (`tree.taiga.io`), attempt to find a corresponding GitHub issue via the + Taiga description text or by searching GitHub PRs that reference the Taiga + URL. Replace the Taiga reference with the GitHub issue link and add the PR + reference if applicable. +- **Re-fetch before editing.** Milestones can change — always re-fetch issues + before making edits, don't rely on cached data. +- **Use `tools/gh.py`.** Prefer the helper script over raw `gh api` calls for + milestone issue listing and PR detail fetching. It handles GraphQL + pagination, batching, and label filtering automatically. diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000000..2e510aff58 --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/memories/backend/auth-permissions-product-domains.md b/.serena/memories/backend/auth-permissions-product-domains.md new file mode 100644 index 0000000000..dbce17a440 --- /dev/null +++ b/.serena/memories/backend/auth-permissions-product-domains.md @@ -0,0 +1,40 @@ +# Backend Auth, Permissions, and Product Domain Subtleties + +## Auth and sessions + +- Main auth RPC commands live in `app.rpc.commands.auth`; LDAP and OIDC provider logic live in `app.auth.ldap` and `app.auth.oidc`, with LDAP-specific RPC checks in `app.rpc.commands.ldap`. +- Public auth endpoints must explicitly set `::rpc/auth false`; RPC auth defaults to enabled. Session cookie creation/deletion is usually attached as an RPC response transform. +- Basic Penpot registration is token staged: prepare/register creates or verifies temporary tokens, then profile creation/session setup is reused by other auth backends. The frontend `/auth/verify-token` flow is a hub for registration confirmation, email change, and invitation tokens. +- OIDC-compatible providers share a generic flow: redirect to provider, validate callback/request token, fetch identity data, then login an existing profile or register a new one. Known providers may have hardcoded endpoints; generic OIDC can use discovery/configured endpoints. +- LDAP login validates credentials against the external directory, fetches identity data, then logs in or registers a matching Penpot profile. LDAP registration is not a separate Penpot signup flow. +- Logout may return an OIDC provider redirect URI when the session claims include provider/session data and the provider has a logout URI. +- Invitation tokens are verified through token issuers and only accepted when the token member id/email matches the authenticated profile; otherwise login proceeds without consuming the invitation. +- HTTP/session parsing details such as cookie/header precedence, JWT session token versions, and SameSite behavior are in `mem:backend/http-storage-filedata-subtleties`. + +## Permission model + +- `app.rpc.permissions` provides predicate/check factories. Failed permission checks intentionally raise `:not-found` / `:object-not-found`, not an authorization-specific error, to avoid leaking object existence. +- Team role flags are normalized as owner > admin > editor > viewer. Owner/admin imply edit; any membership row implies read. +- File/project/comment checks are implemented in the owning command namespaces, often via helpers imported from `files`, `teams`, or `projects`; do not bypass those helpers with direct DB lookups unless preserving their not-found semantics. +- Comment permission includes both logged-in state and the file/team comment policy. Shared viewer paths may pass `share-id`; preserve that path when changing comment queries. + +## Teams, projects, and invitations + +- Team/project commands mix DB changes, email, message bus notifications, media/storage cleanup, feature flags, quotas, and audit metadata. Keep mutations transactional when the existing command does so. +- Invitation flows validate muted/bounced emails before sending and use tokenized invitation state. Accepting an invitation is tied to the invited member identity, not just possession of a token. +- Logical deletion is used for many product objects; prefer existing logical-deletion helpers over hard deletes unless the command already performs permanent cleanup. +- Bounced/spam-complaint emails can mute/block a profile for login/registration and email sending. Devenv MailCatcher is the normal local path for registration/email-flow testing. + +## Comments, webhooks, and audit + +- Comment thread queries join file/project/profile state and exclude deleted files/projects. Unread comment counts depend on `comment_thread_status.modified-at` and profile notification preferences. +- Webhook edits are allowed for team editors/admins or the webhook creator. Webhook validation performs a synchronous HEAD request with a short timeout; validation errors are mapped through `app.loggers.webhooks`. +- Audit events are prepared from RPC metadata, result metadata, params, request context, and selected auth identifiers. Webhook event batching can be controlled through audit/webhook metadata on commands or results. +- Webhook and audit logging are cross-cutting side effects of product commands; when adding a command, check nearby command metadata and result metadata patterns before inventing a new event shape. + +## Local testing notes + +- Enable LDAP login locally with frontend flag `enable-login-with-ldap`; the devenv includes a configured test LDAP service. +- OIDC testing requires external provider app credentials plus matching backend/frontend config. +- Backend domain tests usually live under `backend/test/backend_tests/rpc/commands/*_test.clj` or nearby backend test namespaces. Use focused `clojure -M:dev:test --focus ...` from `backend/` when possible. +- For auth/session or HTTP behavior, combine backend tests with the HTTP/session notes in `mem:backend/http-storage-filedata-subtleties` because RPC-level tests may not exercise cookie/header transforms. \ No newline at end of file diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md new file mode 100644 index 0000000000..e7f3936c3a --- /dev/null +++ b/.serena/memories/backend/core.md @@ -0,0 +1,63 @@ +# Backend Architecture and Workflow + +Backend: JVM Clojure; Integrant; PostgreSQL; Redis/Valkey; RPC; HTTP; storage; mail; audit/logging; workers. + +Focused routing: RPC/DB/workers -> `mem:backend/rpc-db-worker-subtleties`; HTTP/session/storage/media/file-data -> `mem:backend/http-storage-filedata-subtleties`; auth/permissions/product domains -> `mem:backend/auth-permissions-product-domains`. + +## Stable namespace map + +- `app.rpc.commands.*`: RPC command implementations exposed under `/api/rpc/command/<cmd-name>`. +- `app.rpc.permissions`: permission predicate/check helper factories. +- `app.http.*`: HTTP routes and middleware. +- `app.auth.*`: provider-specific authentication helpers such as LDAP/OIDC. +- `app.loggers.*`: audit, webhook, database, and external log integrations. +- `app.db.*` / `app.db`: next.jdbc wrapper and SQL helpers. +- `app.tasks.*`: background task handlers. +- `app.worker`: task execution/cron plumbing. +- `app.main`: Integrant system map and component wiring. +- `app.config`: `PENPOT_*` env config and feature flags. +- `app.srepl.*`: development REPL helpers for manual backend operations. +- `app.nitrate`, `app.rpc.commands.nitrate`, and `app.rpc.management.nitrate`: external Nitrate subscription/organization integration, gated by the `:nitrate` feature flag and shared-key HTTP calls. + +## RPC conventions + +RPC commands are defined with `app.util.services/defmethod` and schemas. Use `get-` prefixes for read operations. Command metadata usually includes auth, docs version, params schema, and result schema. Return plain maps/vectors or raise structured exceptions from `app.common.exceptions`. + +Backend RPC command areas without focused memories include access tokens, binfile, demo, feedback, file snapshots, fonts, management, Nitrate, and webhooks beyond the notes in `mem:backend/auth-permissions-product-domains`; inspect nearby command tests and command metadata before changing them. + +## DB conventions + +`app.db` helpers accept cfg, pool, or conn in most places and convert kebab-case to snake_case: +- `db/get`, `db/get*`, `db/query`, `db/insert!`, `db/update!`, `db/delete!`. +- Use `db/run!` for multiple operations on one connection. +- Use `db/tx-run!` for transactions. + +Development DB: `postgresql://penpot:penpot@postgres/penpot`. +Test DB: `postgresql://penpot:penpot@postgres/penpot_test`. +Database migrations live in `backend/src/app/migrations/`; pure SQL migrations are under `backend/src/app/migrations/sql/`. SQL filenames conventionally start with a sequence and verb/table description, e.g. `0026-mod-profile-table-add-is-active-field`. Applied migrations are tracked in the `migrations` table. + +To inspect the whole DB schema in devenv, use `pg_dump -h postgres -s > schema.sql` from inside the environment. + +## Background tasks + +A task handler is an Integrant component with `ig/assert-key`, `ig/expand-key`, and `ig/init-key`, returning the function run by the worker. New tasks also need wiring in `app.main`: handler config, worker registry entry, and cron entry if scheduled. + +## REPL and fixtures + +In devenv, backend nREPL is exposed on port 6064. `backend/scripts/nrepl` starts a REPLy client. + +For an in-process backend REPL, stop the running backend first so port 9090 is free, then run `backend/scripts/repl`. Useful top-level helpers include `(start)`, `(stop)`, `(restart)`, `(run-tests)`, and `(repl/refresh-all)`. Many `app.srepl.main` helpers accept the global `system` var, e.g. manual email or maintenance operations. + +Fixtures can populate local data for manual testing/perf work. From the backend REPL, run `(app.cli.fixtures/run {:preset :small})`; fixture users conventionally look like `profileN@example.com` with password `123123`. Standalone fixture aliases may exist, but check current `backend/deps.edn` before relying on old command names. + +## Commands + +From `backend/`: +- Focused test: `clojure -M:dev:test --focus backend-tests.some-ns-test`. +- Full backend test suite: `clojure -M:dev:test` or `pnpm run test`. +- Watch/focused testing is also available through `(run-tests ...)` in the backend REPL. +- Lint: `pnpm run lint`. +- Format check: `pnpm run check-fmt`. +- Format fix: `pnpm run fmt`. + +Use JVM type hints in performance-critical paths to avoid reflection. \ No newline at end of file diff --git a/.serena/memories/backend/http-storage-filedata-subtleties.md b/.serena/memories/backend/http-storage-filedata-subtleties.md new file mode 100644 index 0000000000..e9c0962371 --- /dev/null +++ b/.serena/memories/backend/http-storage-filedata-subtleties.md @@ -0,0 +1,31 @@ +# Backend HTTP, Storage, Media, and File Data Subtleties + +## Config and HTTP/session middleware + +- `app.config/config` and `flags` are dynamic `defonce` vars populated from `PENPOT_*` env vars through the shared schema string transformer. Tests and tooling can bind them. +- `parse-flags` automatically adds `:disable-secure-session-cookies` when `public-uri` is plain HTTP and not localhost. This changes cookie defaults without an explicit env flag. +- The backend sets Clojure `*assert*` globally from the `:backend-asserts` feature flag. Assertion-dependent checks can therefore differ by runtime flags. +- Request body parsing is mostly POST-oriented and supports Transit JSON plus plain JSON. Plain JSON request keys are kebab-decoded before being merged into `:params`. +- Response formatting negotiates with `Accept` or `_fmt=json`. Transit is the default for collection/boolean bodies; JSON encoding has special pointer-map handling. +- Auth prefers the session cookie token before the `Authorization` header. Headers may be `Token` or `Bearer`; JWTs with `kid=1` and `ver=1` are decoded as v1 session tokens, otherwise they are treated as legacy tokens. +- Shared-key auth requires `x-shared-key` as `<key-id> <key>` and stores the lowercased key id on the request. If no shared keys are configured it always rejects. +- Session management uses DB storage unless the DB pool is read-only, then falls back to the in-memory manager. DB sessions support both legacy string ids and v2 UUID session ids. +- Session cookies are renewed when using a legacy string id or when `modified-at` is older than the renewal interval. SameSite is `none` for CORS, otherwise strict/lax based on config. + +## Storage and media + +- Storage has a fixed valid bucket set. Backends are `:fs` and `:s3`; default backend comes from deprecated `assets-storage-backend` only when present, otherwise `objects-storage-backend`, defaulting to `:fs`. +- `put-object!` creates the DB `storage_object` row before writing backend content. Backend writes happen only for newly created rows, so deduplication can skip object writes. +- Deduplication only applies when requested, when the content can provide a hash, and when bucket metadata is present. Reads exclude soft-deleted storage rows. +- `sto/resolve` can reuse the current DB connection via `::db/reuse-conn true`; preserve this in transaction-sensitive code. +- SVG validation strips DOCTYPE and uses secure SAX parsing. Basic SVG info falls back to 100x100 dimensions when width/height/viewBox are missing. +- Raster metadata is shell-derived with ImageMagick `identify`, verifies detected MIME against the supplied MIME, and swaps dimensions for EXIF orientations 6/8. +- Remote image download requires 2xx status, `content-length`, a known MIME, and size under the configured maximum before writing the temp file; mismatched byte count is an internal error. +- Font processing shells out to FontForge and WOFF conversion tools and can derive TTF/OTF/WOFF variants from uploaded fonts. + +## File data persistence + +- File data backends are `legacy-db`, `db`, and `storage`. The storage backend keeps encoded file data in storage bucket `file-data`; the DB row stores metadata with `storage-ref-id` and nil data. +- `fdata/upsert!` touches any storage object referenced by incoming metadata before storing the new row/blob. +- Pointer-map fragments are persisted separately as type `fragment`, and only modified pointer maps are written. +- `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders. \ No newline at end of file diff --git a/.serena/memories/backend/rpc-db-worker-subtleties.md b/.serena/memories/backend/rpc-db-worker-subtleties.md new file mode 100644 index 0000000000..2ba9389310 --- /dev/null +++ b/.serena/memories/backend/rpc-db-worker-subtleties.md @@ -0,0 +1,26 @@ +# Backend RPC/DB/Worker Subtleties + +## RPC exposure and wrappers + +- RPC commands are discovered from vars created by `app.util.services/defmethod`; adding a command namespace is not enough unless `backend/src/app/rpc.clj` includes it in `resolve-methods`. +- `GET`/`HEAD` RPC calls are only allowed for method names starting with `get-`. Other methods are method-not-allowed even if they are read-only internally. +- RPC auth defaults to enabled. Public endpoints must set `::auth false` metadata explicitly. +- The wrapper stack does auth before params validation, then auditing/rate/concurrency/metrics/retry/condition handling, with DB transaction handling inside that stack. `::db/transaction` metadata controls transaction wrapping. +- Params with `::sm/params` are decoded/conformed through the JSON transformer and successful IObj results get `:encode/json` metadata. Legacy spec conforming only applies when no Malli params schema exists. +- Nil RPC bodies become HTTP 204 unless explicit status metadata is present. Stream bodies default to `application/octet-stream` when no content type is set. + +## DB helpers + +- Most `app.db` helpers accept a pool, connection, or map containing `::db/pool` / `::db/conn`; preserve that convention in shared code. +- `db/tx-run!` uses `next.jdbc.transaction/*nested-tx* :ignore`: nested transaction calls reuse the outer transaction, not a savepoint. Use explicit savepoints when nested rollback semantics matter. +- `db/run!` opens/reuses one connection but does not create a transaction. +- `db/tjson` is Transit JSON for jsonb storage; `db/json` is plain JSON. Worker task props use Transit and are decoded with `decode-transit-pgobject`. +- Advisory transaction locks accept UUIDs or ints. UUID locks are hashed using a zero-UUID seeded siphash. + +## Workers and cron + +- Task queues are tenant-prefixed. Submit dedupe only removes not-yet-due `new` tasks with the same name/queue/label; it does not dedupe due, scheduled, retry, running, or completed work. +- The dispatcher selects `new`/`retry` tasks with `FOR UPDATE SKIP LOCKED`, marks them `scheduled`, and publishes Redis payload `[id scheduled-at]`. The runner skips Redis messages whose scheduled timestamp no longer matches DB state. +- Lost `scheduled` tasks are rescheduled after about 5 minutes; `running` tasks older than about 24 hours are marked failed as orphans. +- A task handler that is missing or returns an invalid result currently defaults to completed after warning. Throwing with `ex-data :type ::retry` controls retry behavior; `:strategy ::noop` retries without incrementing retry count. +- Cron jobs lock their `scheduled_task` row with `FOR UPDATE SKIP LOCKED`, disable statement/idle-in-transaction timeouts locally, and reschedule themselves in `finally` unless interrupted. Worker, dispatcher, and cron components do not start when the DB pool is read-only. \ No newline at end of file diff --git a/.serena/memories/common/changes-architecture.md b/.serena/memories/common/changes-architecture.md new file mode 100644 index 0000000000..82da7bb71e --- /dev/null +++ b/.serena/memories/common/changes-architecture.md @@ -0,0 +1,39 @@ +# File Mutations: Changes and Undo Architecture + +Penpot mutates file data through change records. A change set is both the persistence payload and the basis for undo/redo, so UI actions, tests, backend file updates, and library/file tooling should drive the production change pipeline instead of ad hoc object-map mutation. + +## Change shape + +Each change is a map such as `{:type ... :id ... :page-id ...}`. Common families: + +- `:add-obj`, `:mod-obj`, `:del-obj`: shape lifecycle. `:mod-obj` contains `:operations`, commonly `{:type :set :attr ... :val ... :ignore-geometry ... :ignore-touched ...}` or `{:type :set-touched ...}`. +- `:add-component`, `:mod-component`, `:del-component`: component/library metadata. +- `:add-children`, `:remove-children`, `:reg-objects`: tree and object-map edits. +- `:set-option`, `:add-page`, `:mov-page`, and related file/page metadata changes. + +Each transaction carries `:redo-changes` and inverse `:undo-changes`. The undo stack stores transactions and can move its index backward/forward. + +## changes-builder API + +`common/src/app/common/files/changes_builder.cljc` (usually alias `pcb`) is the fluent builder. Start from `(pcb/empty-changes <it> <page-id>)` or `(pcb/empty-changes nil <page-id>)` for tests. + +High-value builder operations: +- `pcb/with-page-id`, `pcb/with-objects`, `pcb/with-library-data`: set context for following operations. +- `pcb/update-shapes ids update-fn`: emits `:mod-obj` with diff-derived `:set` ops. Options include `{:with-objects? true}`, `{:ignore-touched true}`, and `{:attrs #{...}}`. +- `pcb/add-objects`, `pcb/change-parent`, `pcb/remove-objects`, `pcb/resize-parents`: shape/tree edits. +- `pcb/add-component`, `pcb/update-component`, `pcb/mod-component`: component/library edits. +- `pcb/set-translation? true`: marks the whole change set as translation-only, which lets component sync skip expensive work. + +## Applying changes in tests + +`thf/apply-changes` in `app.common.test-helpers.files` is the test analog of the production applier. It validates by default; pass `:validate? false` only for intentionally-invalid intermediate states. + +The applier uses the same `process-operation` multimethod as production (`common/src/app/common/files/changes.cljc`), so tests that use it exercise production behavior. + +## :touched and geometry + +For component touched semantics and sync groups, read `mem:common/component-data-model`. For the exact `set-shape-attr` / second-pass behavior during change application, read `mem:common/file-change-validation-migration-subtleties`. For transform-specific ignore-geometry behavior, read `mem:frontend/workspace-transform-subtleties`. + +## Inspection + +To inspect what a UI action emitted, use `mem:frontend/cljs-repl` with the snippets in `mem:common/component-debugging-recipes` rather than adding temporary source instrumentation. \ No newline at end of file diff --git a/.serena/memories/common/component-data-model.md b/.serena/memories/common/component-data-model.md new file mode 100644 index 0000000000..4ed1a47f9a --- /dev/null +++ b/.serena/memories/common/component-data-model.md @@ -0,0 +1,41 @@ +# Component and Variant Data Model + +## Shape roles relative to components + +A shape can occupy multiple roles at once: + +1. Master/main instance: defines a component and has `:main-instance true` plus `:component-id`. +2. Copy/non-main instance: produced by instantiating a component and carries `:shape-ref` pointing at the master shape. `(ctk/in-component-copy? shape)` is essentially `(some? (:shape-ref shape))`. +3. Component root: topmost shape of an instance, marked `:component-root true` and carrying surface attrs such as `:component-id` and `:component-file`. + +Variant masters are main instances and component roots. Their descendants may themselves be component copies, so master/copy logic must handle nested instances rather than assuming those roles are exclusive. + +## :shape-ref chains + +`:shape-ref` walks up the inheritance hierarchy and can cross files for remote libraries. `find-ref-shape` and `get-ref-chain-until-target-ref` in `app.common.types.file` follow this chain. + +`find-shape-ref-child-of` in `app.common.logic.variants` walks the chain looking for the first ref-shape whose ancestors include a specific parent. Variant switch uses this to locate the equivalent master child in the target variant. + +## :touched flags + +`:touched` is a set of override-group keywords such as `:geometry-group`, `:fill-group`, and `:text-content-group`. It means a copy diverged from its master for attrs in that sync group. + +`sync-attrs` in `app.common.types.component` maps attrs to groups. `set-touched-group` is the legitimate setter; the central `set-shape-attr` path calls it only for copies and only when ignore flags allow it. + +Masters are not normally touched through `set-shape-attr`, but touched flags can appear on master shapes through cloning/duplication paths. `add-touched-from-ref-chain` in `app.common.logic.variants` unions touched flags from ancestors into the copy being processed, so upstream/master touched state can affect downstream switch behavior. + +## Cloning paths + +`make-component-instance` in `app.common.types.container` produces a clean component copy through `update-new-shape`, dissociating attrs such as `:touched`, `:variant-id`, and `:variant-name` on cloned shapes. + +`duplicate-component` in `app.common.logic.libraries` creates a new component master by cloning existing component shapes, setting component metadata, and applying a position delta. It does not have the same clean-copy semantics as `make-component-instance`, so inherited attrs on the source can matter. + +When a bug depends on touched state, identify which cloning path produced the shape before changing sync logic. + +## Variant containers + +A variant container is a frame with `:is-variant-container true`. Its children are variant masters with `:variant-id` pointing at the container and `:variant-name` naming the variant value. Component records in the library carry `:variant-properties`. + +Predicates are broad: `ctk/is-variant?` checks `:variant-id` and applies to both variant master shapes and component rows; `ctk/is-variant-container?` checks the container shape flag. + +Moving/dropping a shape into a variant container through the move-to-frame path can auto-convert it into a variant via `generate-make-shapes-variant`, which may duplicate the underlying component. Treat drag/drop into variant containers as a component/variant operation, not a plain reparent. \ No newline at end of file diff --git a/.serena/memories/common/component-debugging-recipes.md b/.serena/memories/common/component-debugging-recipes.md new file mode 100644 index 0000000000..6c1164732c --- /dev/null +++ b/.serena/memories/common/component-debugging-recipes.md @@ -0,0 +1,58 @@ +# Common Component and Change Debugging Recipes + +Keep source changes out of these recipes unless the task requires a durable fix. + +## Inspect recent workspace changes + +From `cljs_repl` after triggering an action: + +```clojure +(let [items (get-in @app.main.store/state [:workspace-undo :items]) + n (count items)] + (->> items + (drop (max 0 (- n 5))) + (map-indexed (fn [i it] + {:idx (+ i (max 0 (- n 5))) + :tags (:tags it) + :n (count (:redo-changes it)) + :types (frequencies (map :type (:redo-changes it))) + :ids (mapv :id (:redo-changes it))})))) +``` + +To inspect operations within the latest `:mod-obj`: + +```clojure +(let [items (get-in @app.main.store/state [:workspace-undo :items]) + mod-obj (->> (:redo-changes (last items)) + (filter #(= :mod-obj (:type %))) + first)] + (:operations mod-obj)) +``` + +## Trace variant switch attribute copying + +To capture what `update-attrs-on-switch` saw during a real UI swap, patch it temporarily in `cljs_repl`: + +```clojure +(def orig (deref #'app.common.logic.libraries/update-attrs-on-switch)) +(def trace-buf (atom [])) +(set! app.common.logic.libraries/update-attrs-on-switch + (fn [& args] + (swap! trace-buf conj + (let [[_ curr prev _ _ origin _] args] + {:curr (select-keys curr [:name :x :y :selrect :points :touched]) + :prev (select-keys prev [:name :x :y :selrect :points :touched]) + :origin-ref (select-keys origin [:id :name :x :y :width :height :selrect])})) + (apply orig args))) +;; trigger UI action, then inspect @trace-buf +(set! app.common.logic.libraries/update-attrs-on-switch orig) +``` + +Runtime patching is faster than adding temporary source instrumentation and avoids recompilation cleanup. Restore the var or reload the frontend when finished. + +## Test-side helpers + +- Use `thf/dump-file file :keys [...]` to print a shape tree with selected keys during common tests. +- Prefer production-path helpers such as `cls/generate-update-shapes` plus `thf/apply-changes` for shape mutations. +- For component swaps with keep-touched behavior, use `tho/swap-component-in-shape` with `{:keep-touched? true}`. +- Temporary `prn` calls in production code are acceptable while investigating but should be removed before committing. \ No newline at end of file diff --git a/.serena/memories/common/component-swap-pipeline.md b/.serena/memories/common/component-swap-pipeline.md new file mode 100644 index 0000000000..56c3f95a31 --- /dev/null +++ b/.serena/memories/common/component-swap-pipeline.md @@ -0,0 +1,42 @@ +# Component Swap and Variant Switch Pipeline + +## Entry points + +Frontend entry points under `frontend/src/app/main/data/workspace/`: +- `variants.cljs`: `variants-switch` and `variant-switch` events feed property-toggle UI and Plugin API `switchVariant` behavior into `dwl/component-swap`. +- `libraries.cljs`: `component-swap` is the single-swap workhorse; `component-multi-swap` batches swaps and calls `component-swap` with `keep-touched? = false`. + +`keep-touched? = true` is the discriminator for preserving user overrides during variant switch. Batch/multi-swap paths intentionally bypass that logic. + +## Common-side pipeline + +For a single swap with `keep-touched? = true`: + +1. `cll/generate-component-swap` in `common/src/app/common/logic/libraries.cljc` builds the base changes: remove old shape and instantiate the target component in its place through `generate-new-shape-for-swap`, `generate-instantiate-component`, and `make-component-instance`. +2. `clv/generate-keep-touched` in `common/src/app/common/logic/variants.cljc` walks pre-swap children, augments each with chain-derived touched flags through `add-touched-from-ref-chain`, finds the equivalent target shape through `find-shape-ref-child-of`, then calls `update-attrs-on-switch`. +3. `update-attrs-on-switch` in `app.common.logic.libraries` decides which touched attrs from the previous shape should be copied onto the freshly instantiated target shape. + +## update-attrs-on-switch hazards + +The routine compares `current-shape` (fresh target copy), `previous-shape` (pre-swap shape with chain-derived touched), and `origin-ref-shape` (source variant master's equivalent shape). It loops over sync attrs except `swap-keep-attrs` and copies only attrs that pass several guards: + +- skip equal previous/current values; +- skip equal composite geometry for selected attrs; +- require the corresponding touched group; +- for most attrs, require source and target masters to agree; +- for fixed-size selrect/points/width/height, use dedicated fixed-layout geometry handling; +- text and path shapes have specialized value conversion paths. + +The generic fallback branch copies from `previous-shape`. It represents the intended "carry user override through switch" behavior, but bugs usually appear when guards fail to reject incompatible geometry or master differences before reaching that branch. + +## Known sharp edges + +- Composite `:selrect` and `:points` bypass the simple different-master skip; width/height checks catch some but not all positional differences. +- `previous-shape` may be repositioned by destination-root minus origin-root before copying. For normal variant switch this is often zero, but do not assume it for all swap entry points. +- Touched flags can be inherited through ref chains, so a shape that looks untouched locally may still behave as touched after `add-touched-from-ref-chain`. + +## Test harness + +`common/src/app/common/test_helpers/compositions.cljc` has `swap-component-in-shape`, which drives `generate-component-swap` plus `generate-keep-touched` with the production `keep-touched?` flag. Use it for focused common tests of variant-switch behavior. + +`common/test/common_tests/logic/variants_switch_test.cljc` is the canonical reference suite for swap+touched scenarios. Read nearby tests before adding another case. \ No newline at end of file diff --git a/.serena/memories/common/core.md b/.serena/memories/common/core.md new file mode 100644 index 0000000000..a74939d58c --- /dev/null +++ b/.serena/memories/common/core.md @@ -0,0 +1,55 @@ +# Common Architecture and Workflow + +`common/` intro: shared CLJC for frontend, backend, exporter, library/file tooling, tests. Small semantic changes can affect multiple runtimes. + +## Stable namespace map + +- `app.common.data` and `app.common.data.macros`: generic data helpers and performance macros that do not depend on Penpot domain entities. +- `app.common.types.*`: shared shape/file/page/component/token data types, schemas, predicates, and entity-local operations. `app.common.types.nitrate-permissions` contains shared fail-closed Nitrate organization/team permission rules. +- `app.common.files.*`: file-level operations, shape tree helpers, change application, migrations, validation, and undo/redo-related logic. +- `app.common.logic.*`: higher-level workflows/algorithms over files, shapes, components, variants, libraries, tokens, etc. +- `app.common.geom.*`: geometry helpers and transformations. +- `app.common.schema` / `app.common.schema.*`: Malli abstraction layer. +- `app.common.math`, `app.common.time`, `app.common.uuid`, `app.common.json`, etc.: cross-runtime utilities. +- `app.common.test_helpers.*`: test builders and production-path helpers. + +## Layering and cross-runtime rules + +Use reader conditionals for platform-specific code. Because CLJC runs on JVM and CLJS targets, avoid assuming browser-only or JVM-only behavior unless the reader conditional isolates it. + +Respect the intended abstraction direction in new/refactored code: +- generic data utilities should not know Penpot domain concepts; +- `types.*` should preserve invariants for a single domain entity or ADT; +- `files.*` can coordinate several entities inside a file and preserve referential integrity; +- `changes*` should adapt serializable change records to lower-level operations and avoid embedding broad business algorithms; +- `logic.*` and frontend/backend event layers own higher workflow/business behavior. + +Some legacy code violates this layering; do not copy those violations into new code when a focused refactor is practical. + +## Focused memory routing + +Model, schema, and persistence shape: +- File/page/shape/component attr changes, import/export surfaces, inspector/codegen, and cross-module checklist: `mem:common/data-model-change-checklist`. +- Token data structures, token import/export, active theme/set semantics, and schema/coercion behavior: `mem:common/tokens-schema-subtleties`. + +Geometry and layout: +- Shape geometry invariants, redundant geometry fields, and geometry-sensitive tests: `mem:common/geometry-invariants`. +- Coordinate drift and approximate float comparisons: `mem:common/decimals-and-coordinates`. +- Layout/grid assignment, deassignment, metadata cleanup, and auto-positioning: `mem:common/layout-grid-subtleties`. + +Change pipeline, validation, and migrations: +- Change records, undo/redo architecture, changes-builder API, and production-path mutation guidance: `mem:common/changes-architecture`. +- Change application, shape-tree edits, validation/repair, migrations, and second-pass touched behavior: `mem:common/file-change-validation-migration-subtleties`. + +Components, variants, and debugging: +- Component/variant data model, ref chains, touched override semantics, and cloning paths: `mem:common/component-data-model`. +- Component swap, variant switch, and keep-touched pipeline: `mem:common/component-swap-pipeline`. +- Live inspection snippets, temporary runtime patching, and test-side debugging helpers for common change/component behavior: `mem:common/component-debugging-recipes`. + +Text and tests: +- Shared text data conversion, DraftJS compatibility, modern text content, and derived position data: `mem:common/text-subtleties`. +- Common test commands, helper conventions, production-path test mutations, and runtime coverage choices: `mem:common/test-setup`. + +## Areas without focused memories + +Common areas with little or no dedicated memory include colors, media/SVG helpers, path operations, thumbnail helpers, generic pools, weak refs, and some utility namespaces. Treat work there as source/test-led unless a focused memory exists. diff --git a/.serena/memories/common/data-model-change-checklist.md b/.serena/memories/common/data-model-change-checklist.md new file mode 100644 index 0000000000..91f00d1e6e --- /dev/null +++ b/.serena/memories/common/data-model-change-checklist.md @@ -0,0 +1,25 @@ +# Common Data Model Change Checklist + +## Attribute conventions + +- Prefer optional page/shape attrs with default behavior when absent. Reverting to default should usually remove the attr instead of storing nil. +- Do not treat nil as a distinct persisted state from absence. Import/export and cleanup paths may filter nil attrs away. +- Avoid Clojure-special naming in exported object attrs, especially boolean names ending in `?`; exported/imported data must survive JSON/SVG/Transit and external tooling. +- Any new shape attr that participates in component sync must be listed in `app.common.types.component/sync-attrs` with the correct touched group. Attrs absent from `sync-attrs` are ignored by component synchronization. + +## Cross-module update checklist + +When changing the file data model, check the relevant paths: +- Schema/type definitions under `common/src/app/common/types*` and helpers under `common/src/app/common/files*` / `logic*`. +- File migrations in `common/src/app/common/files/migrations.cljc` when old files cannot safely use absence/default behavior. +- Frontend edit forms under `frontend/src/app/main/ui/workspace/sidebar/options/`; multi-selection behavior is usually in `multiple.cljs` and must handle `:multiple` values. +- SVG/file render and export metadata under `frontend/src/app/main/ui/shapes/*`, especially `export.cljs` when an attr is not a native SVG property. +- SVG import/parser paths under `frontend/src/app/worker/import/parser.cljs`; attrs not exported and imported will be lost on reimport. +- Viewer inspect and code generation under `frontend/src/app/main/ui/viewer/inspect/*` and `frontend/src/app/util/code_gen.cljs` / markup/style helpers when handoff output should expose the attr. +- Exporter/library consumers when the change affects file construction, rendering, or packaged `.penpot` archives. + +## Migrations + +Existing files should keep working unchanged when possible. If absence cannot preserve old behavior, add a migration and preserve append/order semantics described in `mem:common/file-change-validation-migration-subtleties`. + +Model changes can also require file feature flags or migration metadata updates; check nearby migrations and `common/src/app/common/features.cljc` before inventing a new pattern. diff --git a/.serena/memories/common/decimals-and-coordinates.md b/.serena/memories/common/decimals-and-coordinates.md new file mode 100644 index 0000000000..0a4e8992ad --- /dev/null +++ b/.serena/memories/common/decimals-and-coordinates.md @@ -0,0 +1,54 @@ +# Decimals and Coordinates in Penpot + +Penpot stores all geometry as JS numbers (doubles in CLJS, doubles in +CLJ for the JVM-side common code). Several Penpot-specific facts +about how this plays out are not obvious from reading the code. + +## Sub-pixel drift is routine + +Coordinate values that "should" be integers are routinely off by ~1e-5 +in production data. A `:width` of 107 will frequently appear as +`107.00001275539398` after the value has passed through: + +- the modifier propagation pipeline (`apply-wasm-modifiers` and the + Rust WASM transform engine) +- any rotation/scale composition +- repeated translations + +This drift is invisible in the UI (the renderer rounds at draw time) +but defeats exact equality comparisons in business logic. It does NOT +appear in JVM-only test setups because the WASM pipeline isn't +involved — tests that build shapes via `setup-shape` and `add-sample-shape` +get clean integer values. Bugs that depend on drift will pass tests +but fire in production unless tests explicitly inject drift. + +## Use the close? helpers, not `=` + +For comparing coordinate-like floats, the established convention is: + +- `app.common.math/close?` — scalar tolerance comparison. + Default precision 0.001 (sub-pixel; tight enough to keep distinct + shapes distinct, loose enough to absorb arithmetic noise). + Two-arity uses default precision; three-arity takes a custom one. +- `app.common.geom.point/close?` — element-wise close on `gpt/Point` + records. Compares :x and :y via `mth/close?`. +- `app.common.geom.matrix/close?` — close on transform matrices. +- `app.common.geom.shapes/close-attrs?` — used inside `set-shape-attr` + to decide whether a re-assigned `:width`/`:height` should be treated + as a no-op (suppresses spurious touched marking from drift). + +Treat `=` on `:x`, `:y`, `:width`, `:height`, `:selrect`, or `:points` +fields as a code smell when the inputs may have flowed through any +transform. The `set-shape-attr`-style precedent (already using +`close-attrs?`) is the right model. + +## The redundancy multiplies failure modes + +A shape's position lives in `:x/:y`, `:selrect`, AND `:points` (see +`mem:common/geometry-invariants` memory). Each is a separate set of float +values. After any operation that touches geometry, all three should +agree, but each is computed by a different path and accumulates +its own drift. Comparing `:selrect.width` from shape A to +`:selrect.width` from shape B is comparing two values that +"semantically" should be equal but were computed via different +operation chains — exact equality will often be false. diff --git a/.serena/memories/common/file-change-validation-migration-subtleties.md b/.serena/memories/common/file-change-validation-migration-subtleties.md new file mode 100644 index 0000000000..7ff830a38d --- /dev/null +++ b/.serena/memories/common/file-change-validation-migration-subtleties.md @@ -0,0 +1,28 @@ +# Common File Change, Validation, and Migration Subtleties + +## Change application + +- `process-changes` validates the whole change vector once by default, reduces changes, then performs a second pass for collected touched changes. Callers that already validated can pass `verify? false`. +- `process-operation :set` delegates to `ctn/set-shape-attr`; `:assign` first decodes attrs with the shape-attrs JSON transformer and then emits per-attr set operations. +- `set-shape-attr` treats `:position-data` as derived and never touched. Geometry/content-path changes use approximate equality; geometry differences under about 1px can be ignored for touched purposes. +- Width/height are excluded from the `is-geometry?` branch in `set-shape-attr`; do not assume all geometry-group attrs follow identical ignore-geometry behavior. +- `process-touched-change` marks the owning component modified when a touched shape belongs to a main instance; component-data changes can come from shape ops through this second pass. + +## Shape tree edits + +- `shape-tree/add-shape` falls back invalid/missing parent or frame ids to root (`uuid/zero`), ensures parent `:shapes` is a vector, avoids duplicate child ids, and clears `:remote-synced` on copy parents unless `ignore-touched` is true. +- `shape-tree/delete-shape` removes the shape and all descendants from the objects map and removes the id from its parent. This is different from render-wasm deletion, which may keep deleted children for undo/redo internals. +- Page object maps can carry metadata indexes such as cached frame lists. `start-page-index` / `update-page-index` rebuild those metadata indexes; `frontend` commit application calls `ctst/update-object-indices` after page changes. + +## Validation and repair + +- Full referential/semantic validation currently runs only when file features contain `"components/v2"`. +- Validation starts at root plus orphan shapes, then validates component records. `validate-file!` raises `:validation :referential-integrity` with collected details. +- `repair-file` does not mutate data directly; it reduces validation errors into redo changes using `changes-builder`. Callers must apply or persist those changes. + +## Migrations + +- Prefer optional attrs/default behavior so old files continue working without migration. If absence cannot preserve old behavior, add a migration. +- Migrations are an ordered set mixing legacy version-derived ids and newer named ids. Keep append order stable; `migrate` applies the set difference between available migrations and file migrations. +- `migrate-file` synthesizes legacy migration ids from old numeric versions when `:migrations` is absent, migrates legacy features, and records feature flags created through `cfeat/*new*`. +- When a file had no previous `:migrations`, `migrate-file` marks all migrations as migrated in metadata so callers persist the complete migration set, not only transformations that changed data. \ No newline at end of file diff --git a/.serena/memories/common/geometry-invariants.md b/.serena/memories/common/geometry-invariants.md new file mode 100644 index 0000000000..f90475a285 --- /dev/null +++ b/.serena/memories/common/geometry-invariants.md @@ -0,0 +1,48 @@ +# Geometry Invariants in Penpot Shapes + +Core invariant: shape position is stored redundantly, and all geometry fields must stay coherent. + +## Redundant fields + +For a shape at `(x, y)` with width `w` and height `h`: + +- `:x`, `:y`, `:width`, `:height`: top-left and dimensions. +- `:selrect`: `{:x :y :width :height :x1 :y1 :x2 :y2}`, where `x2 = x + w` and `y2 = y + h`. +- `:points`: four corners for an axis-aligned rect, clockwise from top-left. +- `:transform` and `:transform-inverse`: identity for axis-aligned shapes; populated for transformed shapes. + +After a geometric mutation, equivalent fields such as `:y`, `(:y :selrect)`, and the first point's `:y` should agree. The renderer and hit-testing read `:selrect` / `:points`, so a shape can render or select incorrectly even when `:x` / `:y` look right. + +## Helpers that preserve the invariant + +- `gsh/move`: translates by delta and updates geometry consistently. +- `gsh/absolute-move`: moves to an absolute position by computing a delta from the current selrect. +- `gsh/transform-shape`: applies a full transform. +- `cts/setup-shape`: initializes geometry for new shapes; variant test helpers such as `thv/add-variant-with-child` use it. + +## Edits that break the invariant + +- `(assoc shape :x ...)` or `(assoc shape :y ...)`: updates only one field and leaves `:selrect` / `:points` stale. +- `ths/update-shape file label :y val`: goes through `set-shape-attr`, but does not repair all position fields for `:y` alone. +- Direct `update-in` edits to `:selrect`, `:points`, or dimensions. + +## Test setup warning + +When positioning test shapes, use `gsh/absolute-move`, `gsh/move`, or production change helpers. Do not set only `:x` / `:y`. + +```clojure +(cls/generate-update-shapes + (pcb/empty-changes nil page-id) + #{(:id child)} + #(gsh/absolute-move % (gpt/point (:x %) 101)) + (:objects page) + {}) +``` + +Using `(ths/update-shape file label :y 101)` leaves `:selrect.y` stale. Downstream code that reads `:selrect` can then fail in ways that look like product bugs but are only invalid test setup. + +## :touched and geometry mutation + +When a copy shape changes geometry through the proper pipeline (`set-shape-attr` via `process-operation :set`), `:touched` gains `:geometry-group` unless ignored. Tests can either drive the production update with `cls/generate-update-shapes`, or inject `(assoc shape :touched #{:geometry-group})` when only touched state matters. + +If a test needs both a new position and touched state, move the shape first with geometry-preserving helpers, then inject or assert touched state. \ No newline at end of file diff --git a/.serena/memories/common/layout-grid-subtleties.md b/.serena/memories/common/layout-grid-subtleties.md new file mode 100644 index 0000000000..a3070efefb --- /dev/null +++ b/.serena/memories/common/layout-grid-subtleties.md @@ -0,0 +1,13 @@ +# Common Layout and Grid Subtleties + +## Layout metadata + +- Layout container data and child layout-item data are removed by different helpers. Do not assume clearing a layout frame also clears all child layout metadata. +- Layout data can affect both container attrs and immediate child attrs; validate behavior for both sides when changing cleanup or propagation. + +## Grid assignment + +- Grid `assign-cells` ensures at least one column and row, skips absolute-position children, creates non-tracked rows/cols when children exceed tracked cells, and asserts that assigned cells do not overlap. +- Grid deassignment removes cells for shapes that are no longer direct children or have become absolute-positioned. +- Auto-positioning is not just sorting: some auto cells are converted to manual when empty/manual/span state would break the auto sequence, then auto single-span items can be compacted. +- `fix-overlaps` is marked dev-only and removes one overlapping cell, preferring empty cells first. Avoid depending on it as normal production repair. \ No newline at end of file diff --git a/.serena/memories/common/test-setup.md b/.serena/memories/common/test-setup.md new file mode 100644 index 0000000000..32f87710e9 --- /dev/null +++ b/.serena/memories/common/test-setup.md @@ -0,0 +1,48 @@ +# Common Module Test Setup + +`common/` is CLJC shared code. Tests should cover the relevant runtime(s): JVM for backend/common logic and JS for frontend/exporter behavior. For geometry, component, and file-model changes, JVM tests are common and fast, but JS/browser behavior can differ when WASM modifier math or CLJS-specific state is involved. + +## Running tests + +From `common/`: + +```bash +pnpm run test:jvm +clojure -M:dev:test +pnpm run test:jvm --focus common-tests.logic.variants-switch-test +clojure -M:dev:test --focus common-tests.logic.variants-switch-test/test-basic-switch +pnpm run test:js +pnpm run test:quiet +pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test +pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute --log-level warn +pnpm run watch:test +``` + +Use `test:quiet` for non-interactive JS runs; it buffers `build:test` output and forwards runner args. Common JS runner args support `--focus <namespace-or-var>` and `--log-level trace|debug|info|warn|error`. After `pnpm run build:test`, direct compiled runner focus is faster: `node target/tests/test.js --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute --log-level warn`. New common JS test namespaces must be required/listed in `common_tests/runner.cljc`; new vars in existing namespaces need no runner change. Multiple JVM `--focus` flags compose as a union. + +## Test helpers + +Helpers live under `common/src/app/common/test_helpers/` and are usually aliased with short `th*` prefixes. Test namespaces using label->uuid helpers should start with `(t/use-fixtures :each thi/test-fixture)` so labels reset between tests. + +Useful builders: +- `thf/sample-file` creates a base file. +- `tho/add-simple-component` creates a simple component. +- `thc/instantiate-component` instantiates a component copy. +- `thv/add-variant-with-child` creates a variant container with two child variants. +- `thv/add-variant-with-copy` creates variants whose children are component instances. + +`add-variant-with-copy` does not accept position params for children; use `gsh/absolute-move` after creation if positions matter. + +## Driving production paths + +For shape mutations, prefer production-path helpers such as `cls/generate-update-shapes` plus `thf/apply-changes`. For component swaps with keep-touched behavior, use `tho/swap-component-in-shape` with `{:keep-touched? true}`. + +`thf/apply-changes` validates by default and usually gives the most useful invariant failure. Pass `:validate? false` only for intentionally malformed intermediate state. + +## Geometry setup caution + +For geometry-sensitive tests, read `mem:common/geometry-invariants` before positioning shapes. Use geometry-preserving helpers or production change helpers rather than direct single-field edits. + +## Debugging + +Use `mem:common/component-debugging-recipes` for shape-tree dumps, undo/change inspection, and temporary live instrumentation recipes. \ No newline at end of file diff --git a/.serena/memories/common/text-subtleties.md b/.serena/memories/common/text-subtleties.md new file mode 100644 index 0000000000..96b7de8195 --- /dev/null +++ b/.serena/memories/common/text-subtleties.md @@ -0,0 +1,14 @@ +# Common Text Subtleties + +## DraftJS compatibility + +- `app.common.text` is legacy DraftJS conversion support. New text work should prefer the newer text type namespaces unless specifically touching DraftJS conversion. +- DraftJS style values are encoded as Transit strings under `PENPOT$$$<key>$$$<encoded>` style names. `PENPOT_SELECTION` is a special marker. +- Text conversion uses Unicode code points on both CLJ and CLJS paths, not UTF-16 code units. This matters for offsets around emoji and astral characters. +- Draft conversion fixes gradient type strings back to keywords. + +## Modern text content + +- Modern text content schema is narrow: root -> paragraph-set -> paragraph -> text nodes. +- `position-data` is derived layout/geometry/font fragment data and should be treated as generated state, not source-of-truth file data. +- Token propagation and some non-current-page text updates drop `:position-data` so it can be regenerated in the right runtime context. \ No newline at end of file diff --git a/.serena/memories/common/tokens-schema-subtleties.md b/.serena/memories/common/tokens-schema-subtleties.md new file mode 100644 index 0000000000..886b0192bf --- /dev/null +++ b/.serena/memories/common/tokens-schema-subtleties.md @@ -0,0 +1,17 @@ +# Common Tokens and Schema Subtleties + +## Tokens + +- `TokensLib` always ensures an internal hidden theme exists and defaults active themes to that hidden theme path. That hidden theme represents the UI state where active sets are controlled without modifying a user-created theme. +- `get-tokens-in-active-sets` merges tokens from sets selected by active themes in set order, so later active sets with the same token name override earlier ones. +- Activating a real theme in `common.logic.tokens` removes the hidden theme from the active-theme set unless it is the only active theme. Toggling active sets directly copies current active sets into the hidden theme. +- DTCG import/export deliberately hides the internal hidden theme: exports omit it from `$themes` and activeThemes, while `activeSets` records the hidden/current active sets. +- Single-set DTCG/legacy imports throw if no supported tokens are found. Multi-set import normalizes set names, keeps `tokenSetOrder`, rejects conflicting token path names, discards unsupported token types, and validates theme sets against existing sets. +- Token values stored on shapes are token names in `:applied-tokens`, not token ids. Renames and group renames must update those name paths. +- Token serialization has both Transit handlers for frontend/backend transport and Fressian handlers for internal file-data storage, with migrations for older token-lib internal versions. + +## Schema + +- `app.common.schema/json-transformer` has custom map-of key decoding/encoding, so map keys can be transformed based on the key schema instead of only the value schema. +- `check-fn` throws `ex-info` with default `:type :assertion`, `:code :data-validation`, and `::explain`. Prefer reusable `check-fn`/lazy validators in hot or repeated paths; `sm/check` creates a checker every call. +- `coercer` decodes with the JSON transformer and then checks. This is the common pattern for accepting external JSON-shaped data into internal types. \ No newline at end of file diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md new file mode 100644 index 0000000000..142d303310 --- /dev/null +++ b/.serena/memories/critical-info.md @@ -0,0 +1,54 @@ +You are working on the GitHub project `penpot/penpot`, a monorepo. + +# Memory system + +- Memories are the primary project guidance (not docs or other readme files). +- A section's top-level memory is `<section>/core`. When a section is relevant, read the core memory + before focused memories. +- Edits/stale refs/duplication cleanup: `mem:memory-maintenance`. + +# Development workflow + +- Commit only when explicitly asked. Commit/PR format + changelog: `mem:workflow/creating-commits`, `mem:workflow/creating-prs`. +- 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. + +# Project modules + +This is a monorepo. Principles that apply to one module do *not* generally apply to others. Do not make assumptions. + +- `frontend/`: ClojureScript + SCSS SPA/design editor. +- `backend/`: JVM Clojure HTTP/RPC server with PostgreSQL, Redis, storage, mail, and workers. +- `common/`: shared CLJC data types, geometry, schemas, file/change logic, and utilities. +- `render-wasm/`: Rust -> WebAssembly Skia renderer consumed by frontend. +- `exporter/`: ClojureScript/Node headless Playwright SVG/PDF export. +- `mcp/`: TypeScript Model Context Protocol integration. +- `plugins/`: TypeScript plugin runtime/examples and Plugin API types. +- `library/`: design library workflows. +- `docs/`: documentation site. + +# Low-centrality project paths + +- `docker/` contains devenv related code, not needed unless specifically instructed. + More info in docs/technical-guide if instructed to work on this. +- `experiments/` contains standalone experimental HTML/JS/scripts; treat it as non-core unless the user explicitly asks about it. +- `sample_media/` contains sample image/icon media and config used as fixtures/demo material; do not infer app behavior from it. + +# Dependency graph + +`frontend -> common`, `backend -> common`, `exporter -> common`, and `frontend -> render-wasm`. Changes in `common` can +affect frontend, backend, exporter, file migrations, and design-library behavior; validate across consumers when +semantics change. + +# Working with Penpot designs + +- Before automating or inspecting Penpot designs through the Plugin API, call the Penpot MCP `high_level_overview` tool. +- connection between the JavaScript plugin API and the ClojureScript code: `mem:frontend/plugin-api-to-cljs-binding`. +- executing ClojureScript code in the frontend: `mem:frontend/cljs-repl`. +- handling Clojure compiler errors, runtime patching and debug helpers: `mem:frontend/handling-errors-and-debugging`. + +## Detecting Crashes + +The Penpot frontend can crash silently from the JS API's perspective: `execute_code` calls return successfully, but 1-2s later the workspace becomes unusable (Internal Error page). +The `execute_code` tool then stops working, but `cljs_repl` still works. Use it to detect a crash via `(some? (:exception @app.main.store/state))`. +For details on handling crashes, read memory `mem:frontend/handling-crashes`. diff --git a/.serena/memories/exporter/core.md b/.serena/memories/exporter/core.md new file mode 100644 index 0000000000..da61317d63 --- /dev/null +++ b/.serena/memories/exporter/core.md @@ -0,0 +1,33 @@ +# Exporter Architecture and Workflow + +`exporter/`: CLJS/Node headless export service. Depends on `common/`; uses Playwright plus export JS/CLJS deps for SVG/PDF/assets. + +## Layout and commands + +- Source: `exporter/src/`; config: `deps.edn`, `shadow-cljs.edn`, `package.json`; runtime helpers/assets: `vendor/`, `scripts/`. +- From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; lint `pnpm run lint`; format check/fix `pnpm run check-fmt` / `pnpm run fmt`. +- Because exporter consumes `common/`, shared file/shape/model changes may need exporter verification even when the immediate change is not under `exporter/`. + +## HTTP and browser pool + +- POST body limit is about 60 MB. Exporter supports `application/transit+json`; request params merge query params and body params. +- Map response bodies are Transit JSON and force HTTP 200; nil 200 bodies become 204. +- Auth token comes from cookie `auth-token`, then uploads use Bearer auth plus the management shared key. +- Each export job gets a fresh Playwright browser context. On success, the context closes and the browser returns to the pool; on error, the browser is destroyed instead of reused. +- Borrow validates browser connection. Pool acquire timeout is about 10s; font loading timeout logs a warning and continues after about 15s. + +## Export batching and async behavior + +- `prepare-exports` groups entries by `[scale type]` and partitions groups into chunks of 50. Each partition uses file/page/share/name from its first item, so be careful if entries might cross those boundaries. +- Single-export response is used only when multiple export is not forced and there is exactly one prepared export containing exactly one object. +- Multi-object export can run async: when `wait` is false it returns a resource immediately and publishes progress/end/error to Redis by profile topic; when `wait` is true it waits for upload and returns the uploaded resource. +- Frame export returns a resource immediately and publishes Redis updates; it does not follow the same `wait` option path. +- ZIP entry names are sanitized and duplicates receive numeric suffixes. + +## Render details + +- Bitmap export differs for WASM vs non-WASM render paths: WASM forces Playwright `deviceScaleFactor` to 1 and passes scale through the render URL; non-WASM uses `deviceScaleFactor = scale`. +- WebP is produced by taking a PNG screenshot and converting it with ImageMagick. +- SVG export rasterizes text foreignObjects to PNG, converts through PPM/color masks/potrace, and reassembles SVG paths. It also replaces non-breaking spaces for SVG compatibility and drops empty defs/paths. +- PDF export injects `@page` sizing through raw browser `evaluate` JavaScript; that code cannot rely on CLJS runtime helpers. +- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter <management-key>` and Bearer auth. \ No newline at end of file diff --git a/.serena/memories/frontend/cljs-repl.md b/.serena/memories/frontend/cljs-repl.md new file mode 100644 index 0000000000..67bb71ee67 --- /dev/null +++ b/.serena/memories/frontend/cljs-repl.md @@ -0,0 +1,94 @@ +# ClojureScript REPL and Frontend Debugging + +Execute code in the live frontend via the Penpot MCP `cljs_repl` tool. For browser-console debugging, the frontend also exports a `debug` JS namespace in development builds. + +## Accessing app state + +The main store is `app.main.store/state`. It contains workspace metadata, selection, UI state, profile, route, etc. Page objects are not under a `:workspace-data` key; use derived refs. + +```clojure +;; Current selection +(mapv str (get-in @app.main.store/state [:workspace-local :selected])) + +;; Current page objects +(let [objects @app.main.refs/workspace-page-objects + shape (get objects (parse-uuid "some-uuid-here"))] + (select-keys shape [:name :type :x :y :width :height :fills :strokes :rotation :opacity :frame-id :parent-id])) +``` + +Shape keys use kebab-case keywords. Internal `:rect` corresponds to "rectangle" in the JS Plugin API, and `:frame` corresponds to "board". + +Component instance shapes carry `:component-id` and `:component-file` directly; `:component-root` flags the root of an instance. Use `app.common.types.container/get-head-shape` for nearest head and `get-instance-root` for outermost root; they differ for nested instances. + +## Navigation recipe + +To programmatically open a workspace file, all three ids are required: + +```clojure +(do (require '[app.main.data.common :as dcm]) + (app.main.store/emit! (dcm/go-to-workspace + :team-id (parse-uuid "<team-id>") + :file-id (parse-uuid "<file-id>") + :page-id (parse-uuid "<page-id>")))) +``` + +Get `team-id` from `(:current-team-id @app.main.store/state)`. Get file ids from `(vals (:files @app.main.store/state))`. Get page ids by fetching file data, e.g. through `rp/cmd! :get-file` with current features. + +## Reload the live runtime + +`(.reload js/location)` (alias `app.util.dom/reload-current-window`) from `cljs_repl` reloads the browser page: clears `set!` runtime patches, re-fetches file state, and is the simplest crash recovery while the repl is live (`mem:frontend/handling-crashes`). To re-fetch only the current file's data without a full page reload, emit `(app.main.store/emit! (potok.v2.core/event :app.main.data.workspace/reload-current-file))`. + +## Useful lookup helpers + +`app.plugins.utils` contains state lookup helpers that are useful from any CLJS, despite living under `plugins/`: + +- `locate-shape`, `locate-objects`, `locate-file`. +- `locate-component` resolves through the outermost instance root. +- `locate-head-component` resolves through the nearest component head. +- `locate-library-component` does direct file-id/component-id lookup. + +## Runtime patching with `set!` + +Some frontend vars are deliberately mutable escape hatches for runtime instrumentation or circular-dependency patching. From `cljs_repl`, use `set!` for temporary debugging of CLJS vars such as `app.main.store/on-event`, `app.main.errors/reload-file`, `app.main.errors/is-plugin-error?`, `app.main.errors/last-report`, or `app.main.errors/last-exception`. These patches affect only the live browser runtime and disappear on reload or recompilation. + +```clojure +;; Log non-noisy Potok events temporarily. +(set! app.main.store/on-event + (fn [event] + (when (potok.v2.core/event? event) + (.log js/console (potok.v2.core/repr-event event))))) +``` + +Restore mutable hooks after debugging, or reload the frontend. Use JVM `alter-var-root` only for JVM Clojure; it is not the normal way to patch live CLJS browser vars. + +## Browser-console debug namespace + +In development, the JS console exposes `debug` helpers from `frontend/src/debug.cljs`: + +```javascript +debug.set_logging("namespace", "debug"); +debug.dump_state(); +debug.dump_buffer(); +debug.get_state(":workspace-local :selected"); +debug.dump_objects(); +debug.dump_object("Rect-1"); +debug.dump_selected(); +debug.dump_tree(true, true); +``` + +Visual workspace debug overlays can be toggled with `debug.toggle_debug("bounding-boxes")`, `"group"`, `"events"`, or `"rotation-handler"`; `debug.debug_all()` and `debug.debug_none()` toggle all visual aids. + +For temporary source traces, prefer existing logging (`app.common.logging` / `app.util.logging`) or short-lived `prn`, `app.common.pprint/pprint`, `js/console.log`, or `js-debugger` calls. Remove temporary source instrumentation before committing. + +## Runtime targeting + +`cljs_repl` may connect to the wrong runtime when several are attached, such as workspace plus rasterizer. Verify with `(.-title js/document)`; it should show the workspace file name, not "Penpot - Rasterizer". + +To list or target shadow-cljs runtimes, run from `/home/penpot/penpot/frontend`: + +```bash +printf '(shadow.cljs.devtools.api/repl-runtimes :main)\n' | timeout 10 npx shadow-cljs clj-eval --stdin +printf '(shadow.cljs.devtools.api/cljs-eval :main "<cljs-code>" {:client-id 5})\n' | timeout 10 npx shadow-cljs clj-eval --stdin +``` + +Use command timeouts so a disconnected browser does not hang the session. \ No newline at end of file diff --git a/.serena/memories/frontend/compile-diagnostics.md b/.serena/memories/frontend/compile-diagnostics.md new file mode 100644 index 0000000000..8efd42d6c5 --- /dev/null +++ b/.serena/memories/frontend/compile-diagnostics.md @@ -0,0 +1,22 @@ +# Frontend Compile Diagnostics + +Separate from runtime crash recovery. + +## First check the shadow-cljs build + +Use the Penpot MCP `cljs_compiler_output` tool to inspect the latest shadow-cljs `:main` build status. This is the fastest way to distinguish a bad build from a runtime error in the browser. + +Recommended order after CLJ/CLJC/CLJS source edits: +1. Run `cljs_compiler_output`. +2. If the compiler reports a Clojure syntax problem, especially unmatched delimiters or a confusing location, run `clj_check_parentheses` on the absolute path of the suspect `.clj`, `.cljc`, or `.cljs` file. +3. After the build is healthy, use `mem:frontend/cljs-repl`, browser tools, or runtime crash checks for behavior. + +## Parentheses checker + +`clj_check_parentheses` analyzes one Clojure/ClojureScript source file and reports the area likely responsible for unclosed parentheses/brackets/braces. Use it when compiler output points near EOF, points at a misleading later form, or says delimiter-related syntax errors. + +## Hot reload notes + +When the frontend shadow-cljs watch process is running, edits to CLJC files in `common/` are normally recompiled into the browser automatically. Do not restart the frontend before checking `cljs_compiler_output`; stale behavior is often a failed build. + +For production/minified stack traces, build the production bundle from `frontend/` with `pnpm run build:app`. Output and source maps are generated under `frontend/resources/public/js`; inspect source maps or shadow-cljs reports using build ids from `shadow-cljs.edn`. \ No newline at end of file diff --git a/.serena/memories/frontend/core.md b/.serena/memories/frontend/core.md new file mode 100644 index 0000000000..f37b50b172 --- /dev/null +++ b/.serena/memories/frontend/core.md @@ -0,0 +1,42 @@ +# Frontend Architecture and Workflow + +Frontend: CLJS SPA; React/Rumext; Potok; RxJS; okulary refs; SCSS modules; shared `common/`; JS/TS workspace packages. + +## Stable namespace map + +- `app.main.ui.*`: Rumext/React UI components for workspace, dashboard, viewer, settings, auth, nitrate, etc. +- `app.main.data.*`: Potok event handlers and side effects. +- `app.main.refs`: reactive refs/lenses over store and derived workspace data. +- `app.main.store`: Potok store and `emit!`. +- `app.plugins.*` and `app.plugins`: CLJS implementation of Plugin JS API proxies. +- `app.render_wasm.*`: frontend bridge to Rust/WASM renderer. +- `app.util.*`: DOM, HTTP, i18n, keyboard, codegen, and general frontend utilities. +- `frontend/packages/*` and `frontend/text-editor`: JS/TS workspace packages consumed by the app. +- Nitrate subscription/organization UI and flows live under `app.main.data.nitrate` and `app.main.ui.nitrate*`; backend/API behavior is covered by backend memories, and shared permission rules are in `common/src/app/common/types/nitrate_permissions.cljc`. + +## Focused memory routing + +UI and packages: +- App UI components, SCSS modules, style-system boundaries, accessibility, i18n, and render performance: `mem:frontend/ui-conventions-and-style-system`. +- JS/TS packages, shared UI package, text editor, Storybook, and package builds: `mem:frontend/ui-packages-text-editor-workflow`. + +Workspace behavior: +- Workspace state, commits, persistence, undo, repo calls, and refs: `mem:frontend/workspace-state-persistence-subtleties`. +- Workspace transforms, modifier previews, WASM modifier integration, and transform commits: `mem:frontend/workspace-transform-subtleties`. +- Workspace token application/propagation: `mem:frontend/workspace-token-subtleties`; shared token data/schema: `mem:common/tokens-schema-subtleties`. + +App shell and product flows: +- Routing, root app shell, websocket, and global errors: `mem:frontend/routing-app-shell-subtleties`. +- Dashboard and viewer flows: `mem:frontend/dashboard-viewer-subtleties`. +- Plugin JS API runtime inside the frontend app: `mem:frontend/plugin-api-to-cljs-binding`. + +Diagnostics and validation: +- Runtime inspection and navigation: `mem:frontend/cljs-repl`. +- Source-edit compile/hot-reload diagnostics: `mem:frontend/compile-diagnostics`. +- Runtime crash recovery: `mem:frontend/handling-crashes`. +- Tests, lint, format, and live verification: `mem:frontend/testing`. +- Real pointer/keyboard gesture reproduction: `mem:frontend/playwright-gestures`. + +## Areas without focused memories + +These frontend areas currently have no dedicated Serena memory beyond this architecture entry and nearby source/tests: clipboard, drawing tools, boolean/path operations, interactions/prototyping, color/style asset management, grid-layout editing UI, comments UI, fonts UI, and many dashboard/settings subflows. Treat work there as less memory-covered and inspect source/tests more carefully. \ No newline at end of file diff --git a/.serena/memories/frontend/dashboard-viewer-subtleties.md b/.serena/memories/frontend/dashboard-viewer-subtleties.md new file mode 100644 index 0000000000..975d410dea --- /dev/null +++ b/.serena/memories/frontend/dashboard-viewer-subtleties.md @@ -0,0 +1,16 @@ +# Frontend Dashboard and Viewer Subtleties + +## Dashboard + +- Dashboard initialization fetches projects and fonts for the team, then listens to websocket messages only for global topic `uuid/zero` or the current profile id. +- Project fetch replaces each project map completely instead of merging, so fields such as `deleted-at` can disappear cleanly. +- Dashboard file/project mutations are often optimistic local updates with fire-and-forget RPC watchers. Bulk permanent delete/restore paths use SSE progress and progress notifications. +- File creation/duplication strips file `:data` before putting file summaries into dashboard state. + +## Viewer + +- Viewer initialization sets `:current-file-id`, `:current-share-id`, and `:viewer-local`, then fetches the view-only bundle. Comment threads are fetched only for logged-in users. +- Viewer bundle fetch sends the full supported feature set because anonymous shared viewers may not know team-enabled features. +- View-only bundles can contain pointer values in `:pages-index` and file data. Viewer resolves those fragments with `:get-file-fragment` before storing the bundle. +- `bundle-fetched` indexes pages and precomputes viewer frames/all-frames, stores libraries/users/thumbnails/permissions under `:viewer`, then navigates to frame id, query index, or auto-selected frame. +- Viewer zoom and interaction mode changes update both `:viewer-local` and the `:viewer` route query params. \ No newline at end of file diff --git a/.serena/memories/frontend/handling-crashes.md b/.serena/memories/frontend/handling-crashes.md new file mode 100644 index 0000000000..3a67722aa4 --- /dev/null +++ b/.serena/memories/frontend/handling-crashes.md @@ -0,0 +1,38 @@ +# Frontend Runtime Crash Handling + +## Detect a runtime workspace crash + +Runtime crashes usually show the Internal Error page with title text "Something bad happened" and class `main_ui_static__download-link`. A common pattern is: changes go through via JS API / `execute_code`, then 1-2s later an `update-file` request reaches the backend and is rejected. + +After a crash, `execute_code` can become unusable because no plugin instances are connected and any data in its `storage` is lost, but `cljs_repl` usually still works. + +Check crash state: + +```clojure +(some? (:exception @app.main.store/state)) +``` + +It returns `true` when the Internal Error page is showing and `false` on a healthy workspace or after a successful reload. + +## Read the runtime cause + +The exception is stored at `(:exception @app.main.store/state)`. Useful keys: + +- `:type`, `:code`, `:status`: error class, e.g. `:validation`, `:referential-integrity`, `400`. +- `:hint`, `:details`: human-readable explanation; `:details` often contains validation problems with `:shape-id`, `:page-id`, `:args`, etc. +- `:uri`: API endpoint that returned the error, e.g. `update-file`. +- `:app.main.errors/instance`: underlying JS Error object. +- `:app.main.errors/trace`: JS stack trace string, usually response-handling path rather than the dispatch site that produced the bad change. + +```clojure +(let [ex (:exception @app.main.store/state)] + (select-keys ex [:type :code :status :hint :details :uri])) +``` + +For backend validation errors (`:type :validation`), `:details` is usually the most informative field; it identifies the shape and invariant that failed. + +## Recover and continue testing + +Simplest path when `cljs_repl` is still live (usually true after a crash): reload via repl with `(.reload js/location)` — see `mem:frontend/cljs-repl`. Alternatively via Playwright: find the workspace tab (URL contains `/#/workspace`, title ends `- Penpot`), select it if not current, then `playwright:browser_navigate` to that same URL. Either way, confirm recovery with `(some? (:exception @app.main.store/state))` returning `false`. + +For backend-rejected changes, such as validation errors on `update-file`, changes are not persisted. Reload restores the pre-crash state, so it is safe to retry after fixing the cause. \ No newline at end of file diff --git a/.serena/memories/frontend/handling-errors-and-debugging.md b/.serena/memories/frontend/handling-errors-and-debugging.md new file mode 100644 index 0000000000..29c7929112 --- /dev/null +++ b/.serena/memories/frontend/handling-errors-and-debugging.md @@ -0,0 +1,49 @@ +# Handling Errors and Debugging + +## Finding source errors + +You have access to two tools for finding errors in Clojure source code (which you may introduce yourself through edits): + +1. cljs_compiler_output +2. clj_check_parentheses + +The latter is needed because syntax errors in parentheses give an uninformative compiler error, and the second +tool can often find the exact location of such errors. + +## Runtime patching with `set!` + +Some frontend vars are deliberately mutable escape hatches for runtime instrumentation or circular-dependency patching. +From `cljs_repl`, use `set!` for temporary debugging of CLJS vars such as +`app.main.store/on-event`, `app.main.errors/reload-file`, `app.main.errors/is-plugin-error?`, +`app.main.errors/last-report`, or `app.main.errors/last-exception`. +These patches affect only the live browser runtime and disappear on reload or recompilation. + +```clojure +;; Log non-noisy Potok events temporarily. +(set! app.main.store/on-event + (fn [event] + (when (potok.v2.core/event? event) + (.log js/console (potok.v2.core/repr-event event))))) +``` + +Restore mutable hooks after debugging, or reload the frontend. Use JVM `alter-var-root` only for JVM Clojure; +it is not the normal way to patch live CLJS browser vars. + +## Browser-console debug namespace + +In development, the JS console exposes `debug` helpers from `frontend/src/debug.cljs`: + +```javascript +debug.set_logging("namespace", "debug"); +debug.dump_state(); +debug.dump_buffer(); +debug.get_state(":workspace-local :selected"); +debug.dump_objects(); +debug.dump_object("Rect-1"); +debug.dump_selected(); +debug.dump_tree(true, true); +``` + +Visual workspace debug overlays can be toggled with `debug.toggle_debug("bounding-boxes")`, `"group"`, `"events"`, or `"rotation-handler"`; `debug.debug_all()` and `debug.debug_none()` toggle all visual aids. + +For temporary source traces, prefer existing logging (`app.common.logging` / `app.util.logging`) or short-lived `prn`, `app.common.pprint/pprint`, `js/console.log`, or `js-debugger` calls. Remove temporary source instrumentation before committing. diff --git a/.serena/memories/frontend/penpot-to-browser-coords.md b/.serena/memories/frontend/penpot-to-browser-coords.md new file mode 100644 index 0000000000..e9c250d01c --- /dev/null +++ b/.serena/memories/frontend/penpot-to-browser-coords.md @@ -0,0 +1,117 @@ +# Penpot Canvas → Playwright Viewport Coordinate Mapping + +## Goal +Map Penpot shape coordinates (from the JS/ClojureScript API) to browser viewport CSS pixels +so that Playwright mouse actions (click, drag, hover) can target specific canvas objects. + +## Key Facts + +### Playwright coordinate system +Playwright mouse coordinates are **viewport CSS pixels**: `(0, 0)` is the top-left of the +browser's rendered content area (not the screen, not the OS window chrome). +`getBoundingClientRect()` returns the same coordinate system — they are directly compatible. + +### Canvas element location +The Penpot canvas is rendered by two co-located elements: +- `<canvas>` — the rasterised render +- `<svg id="render">` — vector overlay +- `<svg class="...viewport-controls ...">` — interaction/control layer (has the `viewBox`) + +Get the canvas origin with: +```js +document.querySelector("#render").getBoundingClientRect() +// => { left: 318, top: 0, width: 514, height: 586, ... } (values vary with window size/panels) +``` +The left offset (currently ~318 px) is caused by the left-side panel (layers, assets). + +### Zoom and pan state +Available in two equivalent ways: + +**1. App state (ClojureScript):** +```clojure +(let [wl (get @app.main.store/state :workspace-local)] + {:zoom (get wl :zoom) ; scale factor: penpot-units → CSS pixels + :vbox (get wl :vbox)}) ; Rect {:x :y :width :height} — penpot coords of visible area +``` + +**2. SVG viewBox attribute (DOM):** +```js +document.querySelector("svg.viewport-controls, [class*='viewport-controls']") + .getAttribute("viewBox") +// => "670 658.31 224 255.38" i.e. "vbox.x vbox.y vbox.width vbox.height" +``` +Both sources are live and always in sync. + +### Coordinate conversion formula +``` +viewport_x = canvas_left + (penpot_x - vbox.x) * zoom +viewport_y = canvas_top + (penpot_y - vbox.y) * zoom +``` + +Sanity check: `vbox.width * zoom ≈ canvas CSS width` (and same for height). ✓ + +### Device Pixel Ratio +The canvas physical pixel size = CSS size × DPR (observed DPR = 1.25, so canvas internal +size 642×732 vs CSS size 514×586). This does **not** affect the formula — both +`getBoundingClientRect()` and Playwright use CSS pixels. + +### Ruler label offset +The on-screen rulers show coordinates offset from absolute Penpot coordinates (they display +frame-relative values, offset by ~the top-level frame's x/y). **Ignore for coordinate +mapping** — use `vbox` directly. + +--- + +## ClojureScript Helper (paste into cljs REPL session) + +```clojure +(defn penpot->viewport-coords + "Convert Penpot canvas coordinates to browser viewport CSS pixel coordinates. + Returns {:vp-x <number> :vp-y <number>} — pass directly to Playwright mouse actions." + [penpot-x penpot-y] + (let [state @app.main.store/state + wl (get state :workspace-local) + vbox (get wl :vbox) + zoom (get wl :zoom) + canvas (js/document.querySelector "svg.viewport-controls, #render") + canvas-rect (.getBoundingClientRect canvas)] + {:vp-x (+ (.-left canvas-rect) (* (- penpot-x (:x vbox)) zoom)) + :vp-y (+ (.-top canvas-rect) (* (- penpot-y (:y vbox)) zoom))})) +``` + +Usage example — click the center of a shape: +```clojure +(let [shape (get-in @app.main.store/state [:files file-id :data :pages-index page-id :objects shape-id]) + cx (+ (:x shape) (/ (:width shape) 2)) + cy (+ (:y shape) (/ (:height shape) 2)) + {:keys [vp-x vp-y]} (penpot->viewport-coords cx cy)] + ;; pass vp-x, vp-y to Playwright page.mouse.click(vp-x, vp-y) + {:vp-x vp-x :vp-y vp-y}) +``` + +--- + +## JavaScript equivalent (for use in Playwright scripts directly) + +```js +function penpotToViewport(penpotX, penpotY) { + // Read viewBox from the controls SVG (always in sync with app state) + const svg = document.querySelector('[class*="viewport-controls"]'); + const [vbX, vbY, vbW, vbH] = svg.getAttribute('viewBox').split(' ').map(Number); + const rect = svg.getBoundingClientRect(); + const zoom = rect.width / vbW; // == rect.height / vbH + return { + x: rect.left + (penpotX - vbX) * zoom, + y: rect.top + (penpotY - vbY) * zoom, + }; +} +``` + +This function can be injected and called via `page.evaluate()` in Playwright: +```js +const {x, y} = await page.evaluate( + ([px, py]) => penpotToViewport(px, py), + [penpotX, penpotY] +); +await page.mouse.click(x, y); +``` diff --git a/.serena/memories/frontend/playwright-gestures.md b/.serena/memories/frontend/playwright-gestures.md new file mode 100644 index 0000000000..85df720697 --- /dev/null +++ b/.serena/memories/frontend/playwright-gestures.md @@ -0,0 +1,47 @@ +# Driving Real User Gestures via Playwright + +Use Playwright when the bug or behavior depends on Penpot's real input pipeline: pointer gestures, keyboard modifiers, drag/drop targeting, modifier propagation, hover/focus behavior, or alt-drag duplication. The plugin JS API and `penpot:execute_code` can bypass these paths by dispatching store/API operations directly. + +## When `execute_code` Is Not Enough + +`execute_code` runs in the plugin sandbox. It is excellent for creating shapes, calling Plugin API methods, and querying design data, but it does not faithfully reproduce all user gestures. If the issue involves interactive transforms, frame targeting during drop, drag previews, modifier keys, or canvas hit-testing, drive the browser with Playwright and inspect results via cljs-repl. + +## Gesture Pattern + +A reliable drag gesture generally needs: +- focus on the canvas first; +- key modifiers held from before mouse down until after mouse up; +- intermediate mouse move events, not just start/end; +- short waits so Penpot's drag pipeline observes the gesture; +- a trailing wait for the transaction to commit. + +Alt-drag duplication example: + +```javascript +async (page) => { + await page.mouse.click(700, 700); + await page.waitForTimeout(200); + + const startX = 821, startY = 565, endX = 821, endY = 815; + await page.keyboard.down('Alt'); + await page.mouse.move(startX, startY); + await page.waitForTimeout(100); + await page.mouse.down(); + await page.waitForTimeout(100); + for (let i = 1; i <= 10; i++) { + const t = i / 10; + await page.mouse.move(startX + (endX - startX) * t, + startY + (endY - startY) * t); + await page.waitForTimeout(20); + } + await page.waitForTimeout(100); + await page.mouse.up(); + await page.waitForTimeout(100); + await page.keyboard.up('Alt'); + await page.waitForTimeout(500); +} +``` + +## Coordinate Planning + +For reliably finding pixel positions of objects, see `mem:frontend/penpot-to-browser-coords`. \ No newline at end of file diff --git a/.serena/memories/frontend/plugin-api-to-cljs-binding.md b/.serena/memories/frontend/plugin-api-to-cljs-binding.md new file mode 100644 index 0000000000..8536ad245c --- /dev/null +++ b/.serena/memories/frontend/plugin-api-to-cljs-binding.md @@ -0,0 +1,34 @@ +# Frontend Plugin API Runtime Subtleties + +## Type declarations vs runtime + +- The Plugin API is a public facade over internal frontend/common data. Do not expect Plugin API property names, value shapes, or behavior boundaries to match internal CLJS attrs or helper APIs; inspect the relevant proxy and internal code path before using Plugin API observations in production internals or tests. +- `plugins/libs/plugin-types/index.d.ts` contains TypeScript declarations only. Runtime objects are CLJS proxies built under `frontend/src/app/plugins/*.cljs` with `obj/reify`. +- `shape.cljs` builds shape proxies with hidden ids and per-property CLJS implementations. `library.cljs` builds library proxies such as `LibraryComponentProxy`. +- `shape.cljs`, `library.cljs`, and related namespaces break circular dependencies with mutable nil vars patched from `app.plugins` at load time. If a proxy constructor appears nil, check the patching path in `frontend/src/app/plugins.cljs`. + +## Key Domain Namespaces +- `app.common.types.component` (aliased `ctk`) — component predicates: `instance-root?`, `instance-head?`, `in-component-copy?`, `is-variant?` +- `app.common.types.container` (aliased `ctn`) — container/tree operations: `in-any-component?`, `get-instance-root`, `get-head-shape`, `inside-component-main?` +- `app.common.types.file` (aliased `ctf`) — file-level operations: `resolve-component`, `get-ref-shape` + +## Runtime initialization and permissions + +- The frontend initializes `@penpot/plugins-runtime` only after `features/initialize` and only when feature `plugins/runtime` is active. It also installs the runtime `isPluginError` predicate into frontend error handling. +- Manifest parsing expands write permissions to read permissions (`content:write` => `content:read`, etc.). Permission checks also allow the all-zero plugin id and the hard-coded MCP plugin id. +- Manifest URL origin differs by manifest version: v1 clears the path; v2 joins `.` to the plugin URL. Existing plugin ids are reused by matching manifest name and host. +- The MCP plugin id is defined in `app.plugins.register` to avoid a circular dependency with workspace MCP code. + +## Proxy behavior + +- Public Plugin API objects are lightweight handles, not durable snapshots. Most getters locate fresh state from `app.main.store/state` using hidden `$id`, `$file`, `$page`, etc. +- `not-valid` logs by default but throws when the plugin flag `throwValidationErrors` is enabled. The MCP execute-code handler deliberately enables that flag while running code. +- `naturalChildOrdering` and `throwValidationErrors` are stored per plugin under `[:plugins :flags plugin-id ...]`; changing default behavior affects automation and MCP diagnostics. +- Plugin data is stored under keyword namespaces: private data uses `(keyword "plugin" plugin-id)`, shared data uses `(keyword "shared" namespace)`. + +## Events and history + +- Plugin listeners are watches on the global store and callbacks are debounced about 10ms. Callback exceptions are caught and logged so plugin code does not crash the app. +- `selectionchange` callbacks receive arrays of shape id strings, while `filechange`, `pagechange`, and `shapechange` return proxies. +- `contentsave` fires only when persistence status transitions to `:saved`; it calls the callback with no value. +- Plugin history `undoBlockBegin` creates a workspace undo transaction with a JS `Symbol`; `undoBlockFinish` commits that symbol. Missing finish eventually relies on the workspace transaction timeout. \ No newline at end of file diff --git a/.serena/memories/frontend/routing-app-shell-subtleties.md b/.serena/memories/frontend/routing-app-shell-subtleties.md new file mode 100644 index 0000000000..cbdc75fcef --- /dev/null +++ b/.serena/memories/frontend/routing-app-shell-subtleties.md @@ -0,0 +1,17 @@ +# Frontend Routing, App Shell, Websocket, and Error Subtleties + +## Router, app shell, and errors + +- Routing uses browser-history hash tokens, but `on-navigate` rejects navigation if the current origin/path does not match `cf/public-uri`. +- Route params are split into `:path` and `:query`; duplicate query params can become vectors, so use `rt/get-query-param` when a scalar is required. +- Unknown/empty routes trigger an extra `get-profile`/`get-teams` check before redirecting. This avoids invitation and root-route race conditions. +- The root app renders an exception page from `:exception` state before the normal error boundary. `rt/navigated` clears `:exception`. +- Frontend error handling treats stale cross-build JS chunk failures specially: messages containing `$cljs$cst$` or `$cljs$core$I` plus undefined/null/not-a-function signatures trigger throttled reload. +- Plugin-originated uncaught errors are identified through the plugin runtime hook and logged rather than turning into the global exception page. + +## Store and websocket + +For general store mechanics such as `emit!`, `last-events`, persistence, and undo, read `mem:frontend/workspace-state-persistence-subtleties`. + +- Websocket initialization uses `cf/public-uri` joined with `ws/notifications`, converting `http/https` to `ws/wss`, and includes the current `session-id` as query param. +- Reinitializing or finalizing websocket stops the previous receive stream. Incoming websocket payloads become Potok data events under `app.main.data.websocket/message`. \ No newline at end of file diff --git a/.serena/memories/frontend/testing.md b/.serena/memories/frontend/testing.md new file mode 100644 index 0000000000..30c32e2fef --- /dev/null +++ b/.serena/memories/frontend/testing.md @@ -0,0 +1,45 @@ +# Frontend Testing and Live Verification + +Frontend validation: CLJS + React/Rumext + RxJS/Potok; SCSS modules; shared CLJC from `common/`. + +## Unit tests + +Frontend unit tests live under `frontend/test/frontend_tests/` and use `cljs.test`. They should be deterministic, avoid DOM/UI integration where possible, and mock side effects such as RPC, storage, timers, or network access. + +From `frontend/`: +- Full unit test run: `pnpm run test:quiet`. +- Focus a frontend CLJS test namespace: `pnpm run test:quiet -- --focus frontend-tests.logic.components-and-tokens`. +- Focus one frontend CLJS test var: `pnpm run test:quiet -- --focus frontend-tests.logic.components-and-tokens/change-spacing-token-in-main-updates-copy-layout`. +- Quiet `app.*` logging during a run: append `--log-level warn` (or `trace|debug|info|warn|error`). +- Build test target only: `pnpm run build:test`. +- After `pnpm run build:test`, direct compiled runner focus is faster: `node target/tests/test.js --focus frontend-tests.logic.components-and-tokens/change-spacing-token-in-main-updates-copy-layout`. +- Watch tests: `pnpm run watch:test`. + +New frontend test namespaces must be required/listed in `frontend_tests/runner.cljs`; new vars in existing namespaces need no runner change. + +## Playwright integration tests + +Do not add, modify, or run Playwright integration tests under `frontend/playwright` unless explicitly asked. When explicitly asked, use `pnpm run test:e2e` or `pnpm run test:e2e --grep "pattern"` from `frontend/`; ensure dependencies are installed through `./scripts/setup` if the environment is not prepared. + +Integration tests fake backend behavior by intercepting network/websocket traffic, so every RPC or websocket the page needs must be mocked. Use existing Page Object Models: +- `BasePage.mockRPC` intercepts RPC calls and already prefixes `/api/rpc/command/`; pass command names such as `get-profile`, not full URLs. +- Workspace or other websocket-using pages should extend/use `BaseWebSocketPage`, initialize websocket mocks before each test, and mock `/ws/notifications` with the provided helpers. +- Prefer common locators/actions in POMs; ad-hoc locators can stay in a single test. + +Locator priority should follow user-facing semantics: `getByRole`, `getByLabel`, `getByPlaceholder`, `getByText`, then semantic alternatives such as alt/title, with `getByTestId` as the last resort. Name tests from the user's perspective and prefer positive, single-purpose assertions. + +## Lint and format + +From `frontend/`: +- CLJ/CLJS lint: `pnpm run lint:clj`. +- JS lint currently no-ops via `pnpm run lint:js`. +- SCSS lint: `pnpm run lint:scss`. +- Format checks: `pnpm run check-fmt:clj`, `pnpm run check-fmt:js`, `pnpm run check-fmt:scss`. +- Format fix: `pnpm run fmt`, or targeted `fmt:clj` / `fmt:js` / `fmt:scss`. +- Translation formatting after i18n edits: `pnpm run translations`. + +## Live browser verification + +Because CLJC compiles to both JVM and CLJS, JVM/common tests can miss frontend-only state caused by browser runtime, WASM modifier math, or real pointer events. Use `mem:frontend/cljs-repl` to inspect live app state and `mem:frontend/playwright-gestures` when real input is needed. + +For stale hot reload or failed CLJ/CLJC/CLJS source builds, read `mem:frontend/compile-diagnostics`. For Internal Error pages or delayed runtime crashes after automation/API actions, read `mem:frontend/handling-crashes`. Translation `.po` changes are bundled into `index.html` and require a browser refresh. \ No newline at end of file diff --git a/.serena/memories/frontend/ui-conventions-and-style-system.md b/.serena/memories/frontend/ui-conventions-and-style-system.md new file mode 100644 index 0000000000..4212d88029 --- /dev/null +++ b/.serena/memories/frontend/ui-conventions-and-style-system.md @@ -0,0 +1,56 @@ +# Frontend UI Conventions and Style System + +## CLJS app UI + +- Main app components live under `frontend/src/app/main/ui*` and normally use Rumext `mf/defc` with a `*` suffix for component vars and `[:> component* props]` call sites. +- Components should have clear ownership. Use `children` for normal composition; use slotted props only when separate owned regions are needed. Do not style or structurally manipulate child DOM that the component did not instantiate. +- Accept and merge a `class` prop when callers reasonably need layout/positioning customization. Use `mf/spread-props` so Rumext prop transformations such as `:class` -> `className` still apply. +- Avoid boolean prop names ending in `?`; they do not translate cleanly to JavaScript props. Use type hints such as `^boolean` where JS truthiness/semantics matter. +- Split large components into smaller private components when useful; `::mf/private true` is the local convention for private Rumext components. + +## Styling + +- Co-located SCSS modules are preferred. Use `app.main.style/stl` helpers from CLJS and design-system SCSS tokens/mixins instead of legacy global selectors or high-specificity nesting. +- Keep CSS specificity low. Avoid nested selectors unless they target elements the component owns; CSS Modules already prevent class-name collisions. +- Prefer CSS logical properties for directional spacing/layout (`padding-inline-start`, etc.). Physical `width`/`height` are still acceptable where they are clearer. +- Use named design-system variables/tokens for spacing, borders, fixed dimensions, colors, and typography. Avoid hardcoded px/rem values and deprecated `resources/styles/common/refactor/spacing.scss` variables. +- Use component-local CSS custom properties for variants and theming instead of one-off Sass variables when a component has multiple visual states. +- Prefer DS typography components (`heading*`, `text*`) and typography mixins instead of plain text wrappers or deprecated typography mixins. + +## Accessibility + +- Prefer semantic HTML first: anchors for navigation/download/email links, buttons for actions, correct heading levels, and keyboard-focusable controls. +- If native elements cannot be used, apply appropriate ARIA roles/patterns. Follow WAI-ARIA APG patterns for standard widgets. +- Icon-only controls need an accessible name via surrounding text, `aria-label`, `alt`, or equivalent. Decorative images/icons should be hidden from assistive tech. + +## I18n + +- Translations must be resolved during render or render-time memoization, not at namespace load time. For static option lists, memoize inside render so locale changes still update labels. +- Translation files live in `frontend/translations/*.po`. Translation changes are bundled into `index.html`; refresh the browser after changing translations because there is no hot reload for translation strings. +- Run `pnpm run translations` from `frontend/` after adding/updating translation text. +- Adding a new supported locale requires updates in both `frontend/src/app/util/i18n.cljs` (`supported-locales`) and `frontend/scripts/_helpers.js` (`langs`). + +## Performance + +- Keep expensive derived data in refs, memoized selectors, or pure helpers. In hot render paths, prefer existing `app.common.data.macros` helpers where local code already uses them. +- Avoid creating new callback functions/objects inside hot renders when a named function, memoized callback, data attribute, or precomputed JS props object works. +- Destructure props/state values used repeatedly. Avoid repeated deref/property access in render loops. + +## Shared React UI package + +- `frontend/packages/ui` is the shared React/Vite package. It should remain framework-neutral relative to the CLJS app store; reusable primitives belong here only when they do not depend on Potok/Rumext app state. +- Package styles are emitted through the package build and copied into `frontend/resources/public/css/ui.css`; stale shared styles are often a build artifact issue. +- Storybook is the primary visual harness for shared UI/package behavior. Use `mem:frontend/ui-packages-text-editor-workflow` for package build/test commands. + +## Choosing a location + +- Put editor/dashboard/viewer workflow logic in CLJS app namespaces close to the owning feature. +- Put reusable presentational React primitives in `frontend/packages/ui` when they can be consumed without Penpot app state. +- Put CLJS design-system components under `frontend/src/app/main/ui/ds`; new DS components need implementation, CSS module, Storybook story, optional MDX docs, and export from `frontend/src/app/main/ui/ds.cljs` with a JavaScript-friendly name. +- Put text editing internals in `frontend/text-editor` when the behavior belongs to the JS editor package; use `mem:common/text-subtleties` for shared text data-model behavior. + +## Validation + +- For CLJS app UI, use `mem:frontend/testing`, `mem:frontend/compile-diagnostics`, and live browser/REPL checks when behavior depends on store or canvas state. +- For shared UI package changes, run the package build plus Storybook/component tests when relevant. +- For text editor changes, run `frontend/text-editor` tests and refresh/copy WASM artifacts if render-wasm output is involved. \ No newline at end of file diff --git a/.serena/memories/frontend/ui-packages-text-editor-workflow.md b/.serena/memories/frontend/ui-packages-text-editor-workflow.md new file mode 100644 index 0000000000..24fdd9383c --- /dev/null +++ b/.serena/memories/frontend/ui-packages-text-editor-workflow.md @@ -0,0 +1,34 @@ +# Frontend UI Packages and Text Editor Workflow + +`frontend/packages/`, `frontend/text-editor/`, Storybook/component tests. Separate from CLJS app UI under `frontend/src/app/main/ui`. + +## Package boundaries + +- `frontend/packages/ui` builds `@penpot/ui`, a React/Vite library package. It exports ESM and type declarations from `dist/`; React and ReactDOM are peer dependencies and must stay external in the Vite library build. +- The UI package build copies generated `dist/index.css` into `frontend/resources/public/css/ui.css`. If shared UI styles look stale in the app, rebuild the package or check this copy step before debugging CLJS style code. +- `frontend/text-editor` builds `@penpot/text-editor` from `src/editor/TextEditor.js`. It is a Vite JS package, not CLJS, and has its own Vitest/browser-test setup. +- The text editor consumes render-wasm artifacts copied from `frontend/resources/public/js` into `frontend/text-editor/src/wasm`. Use `pnpm run wasm:update` after rebuilding `render-wasm` if tests or local dev use stale WASM files. +- Other packages under `frontend/packages/` such as `tokenscript`, `draft-js`, and `mousetrap` are workspace dependencies used by the frontend app; do not assume their runtime behavior lives in CLJS namespaces. + +## Commands + +From `frontend/`: +- Build app-side JS package assets: `pnpm run build:app:libs`. +- Watch app-side JS package assets: `pnpm run watch:app:libs`. +- Storybook build: `pnpm run build:storybook`; local Storybook: `pnpm run watch:storybook`. +- Storybook/component tests: `pnpm run test:storybook`. + +From `frontend/packages/ui`: +- Build library and CSS artifact: `pnpm run build`. +- Watch library build: `pnpm run watch`. + +From `frontend/text-editor`: +- Local Vite dev: `pnpm run dev`. +- Tests: `pnpm run test`; coverage: `pnpm run coverage`; browser watch: `pnpm run test:watch:e2e`. +- Format check: `pnpm run fmt:js`. + +## Validation notes + +- Frontend root `check-fmt:js` covers stories, Playwright scripts, frontend scripts, and `text-editor/**/*.js`; it does not replace package-specific builds/tests. +- Changes to shared UI package exports should be validated both in the package build and in the consuming app/Storybook path. +- Changes that alter text rendering/editing can involve `frontend/text-editor`, `render-wasm`, CLJS text integration, and `mem:common/text-subtleties`; verify the runtime that actually owns the changed behavior. \ No newline at end of file diff --git a/.serena/memories/frontend/workspace-state-persistence-subtleties.md b/.serena/memories/frontend/workspace-state-persistence-subtleties.md new file mode 100644 index 0000000000..70f0be8f86 --- /dev/null +++ b/.serena/memories/frontend/workspace-state-persistence-subtleties.md @@ -0,0 +1,33 @@ +# Frontend Workspace State and Persistence Subtleties + +## Store and interaction streams + +- `app.main.store/state` is the Potok store; `emit!` always returns nil. Store errors flow through the mutable `on-error` atom. +- `last-events` keeps a filtered rolling buffer of about 50 event type strings and commit hint origins. It intentionally omits noisy websocket/persistence/pointer events. +- `ongoing-tasks` controls `window.onbeforeunload`: any non-empty set blocks tab unload. +- `app.main.streams/wasm-modifiers` and `workspace-selrect` are behavior subjects used for high-frequency interactive preview state that bypasses normal store updates and lenses. +- Keyboard modifier streams merge a window blur signal so stuck modifier-key state is cleared after focus loss. + +## Repo calls + +- `app.main.repo/send!` uses GET only when the RPC name starts with `get-`, when all params are query params, or for configured special cases. Only GET requests are retried. +- GET retry is limited to transient `:network`, `:bad-gateway`, `:service-unavailable`, and `:offline` errors with exponential backoff. Mutations are not retried. +- A server SSE response is only accepted when the command is configured `:stream?`; otherwise it raises an unexpected-response assertion. + +## Commits, undo, persistence + +- `commit-changes` refuses to create commits unless `:permissions :can-edit` is true. It captures file revn/vern, selected-before, features, tags, undo group, and translation flag into a `::commit` event. +- Applying a remote commit first rolls back pending local commits, applies the remote changes, then replays pending local redo changes. Index updates are emitted for undo, remote redo, and replayed redo paths. +- Local commits are independently consumed by undo, persistence, WASM model updates, thumbnail/library watchers, and text position-data recalculation. +- Persistence buffers local commits: status becomes pending after about 200ms, commits are flushed after about 3s or `::force-persist`, and buffered commits are merged per file before `:update-file`. +- Persistence sends revn as the max of the commit revn and locally tracked latest revn; remote commits update that revn tracker. +- Persistence is skipped in version preview/read-only mode or without edit permission. +- Undo transactions can stay open only temporarily; timed-out pending transactions are force-committed after about 20s. Undo entries are capped at 50. +- Undo/redo are ignored while a normal editor/drawing interaction is active, except grid-layout edition handles undo through this path. +- After local commits and when render-wasm is active, text shapes get derived `:position-data` recomputed in a separate commit tagged `#{:position-data}`; that tag is excluded from the position-data watcher to avoid loops. + +## Refs + +- `refs/libraries` is explicitly deprecated for performance; prefer derefing `refs/files` and memoizing `select-libraries` in components. +- `refs/workspace-page-objects` uses `identical?` equality, so preserving object map identity matters for avoiding derived-ref churn. +- Selected-shapes refs use a small `{objects selected}` wrapper with custom equality before running `process-selected`; avoid bypassing that pattern in hot UI paths. \ No newline at end of file diff --git a/.serena/memories/frontend/workspace-token-subtleties.md b/.serena/memories/frontend/workspace-token-subtleties.md new file mode 100644 index 0000000000..450344e98c --- /dev/null +++ b/.serena/memories/frontend/workspace-token-subtleties.md @@ -0,0 +1,17 @@ +# Frontend Workspace Token Subtleties + +## Token refs and visibility + +- Workspace token refs intentionally hide the internal hidden theme from theme trees/lists and expose active tokens through `get-tokens-in-active-sets`. +- Token values stored on shapes are token names under `:applied-tokens`, not token ids. Renames/group renames must update those paths in common token logic. + +## Token application + +- Token application refuses to run while a text shape is in text-editing mode and shows a warning instead. +- Applying a token writes token names into shape `:applied-tokens`, resolves active tokens through Style Dictionary or `tokenscript` depending on feature flags, updates concrete shape attrs, and wraps the operation in an undo transaction. +- Applying composite typography removes atomic typography token attrs; applying atomic typography removes the composite typography token attr. +- Spacing tokens have a special split path: layout containers receive gap/padding updates, while immediate children of layouts receive margin updates. + +## Propagation + +- Token propagation resolves active tokens, buffers many `update-shapes` commits, walks the current page first then the remaining pages, clears affected frame/component thumbnails, and drops `:position-data` for text shapes on non-current pages so it can be regenerated. \ No newline at end of file diff --git a/.serena/memories/frontend/workspace-transform-subtleties.md b/.serena/memories/frontend/workspace-transform-subtleties.md new file mode 100644 index 0000000000..958e121c9c --- /dev/null +++ b/.serena/memories/frontend/workspace-transform-subtleties.md @@ -0,0 +1,20 @@ +# Frontend Workspace Transform Subtleties + +## Preview vs committed transforms + +- High-frequency previews use `app.main.streams/wasm-modifiers` and `workspace-selrect` behavior subjects instead of normal store commits; components consume them through refs that wrap plain atoms. +- `apply-modifiers*` is the lower-level commit path once object/text modifiers are ready. It updates frame guides, frame comment threads, and then emits `update-shapes` with `:reg-objects? true`. +- Transform commits restrict diff attrs to `transform-attrs` to avoid scanning unrelated shape attrs. +- Text transforms may carry derived `:position-data`; `assoc-position-data` attaches it while preserving the original text shape context. + +## Component-copy touched suppression + +- `calculate-ignore-tree` walks modified shapes and descendants to decide per copy-shape `ignore-geometry?`. +- `check-delta` compares a copy's relative position/rotation to its component root before and after transform. If relative movement is under about 1px and size/rotation are effectively unchanged, geometry touching is suppressed. +- This logic is why pure translations of component copies can avoid marking every descendant as geometry-touched, while resizes/rotations still propagate touched state. + +## WASM bridge details + +- WASM modifier updates set plugin/local props with parsed geometry/structure modifiers rather than directly mutating file data. +- The position-data recomputation watcher ignores commits tagged `:position-data`; keep that tag when adding derived position-data commits. +- Rotation has separate WASM and non-WASM event paths. Check both when changing rotation modifier semantics. \ No newline at end of file diff --git a/.serena/memories/library/core.md b/.serena/memories/library/core.md new file mode 100644 index 0000000000..dec9347c6f --- /dev/null +++ b/.serena/memories/library/core.md @@ -0,0 +1,30 @@ +# Library Architecture and Workflow + +`library/`: builds `@penpot/library`; JS-facing in-memory Penpot file builder and `.penpot` ZIP exporter. Separate from main app runtime. + +## Layout and commands + +- Source: `library/src/`; tests: `library/test/`; experimentation/docs: `playground/`, `docs/`; config: `shadow-cljs.edn`, `deps.edn`, `package.json`. +- From `library/`: build `pnpm run build`; bundle helper `pnpm run build:bundle` or `./scripts/build`; tests `pnpm run test`; watch `pnpm run watch` / `pnpm run watch:test`; lint `pnpm run lint`; format check/fix `pnpm run check-fmt` / `pnpm run fmt`. +- When changing file-format construction or export behavior in `common/`, consider whether `@penpot/library` should be tested because it constructs Penpot files outside the app UI. + +## JS API and builder state + +- The JS build context wraps an atom and implements `IDeref`; `getInternalState` exposes the CLJ state converted to JS. +- Public methods decode JS objects through the JSON transformer before calling common builder functions. Exceptions become JS `BuilderError` objects with enumerable `cause` and an `explain` getter for Malli explain data. +- `create-build-context` can store an optional `referer`, later written into the export manifest. +- The builder is stateful: call `addFile` before `addPage`. `addPage` resets the frame/group stack to the root and clears page-local naming state when the page closes. +- `addBoard` and `addGroup` push onto the parent stack; matching close calls pop it. `closeGroup` requires at least one child and recalculates group geometry. Masked groups use the first child as mask and copy its geometry. +- `commit-shape` emits `:add-obj` with `:ignore-touched true`, using the current parent, frame, and page from the stack. +- Layer names are uniqued per current page; duplicate names get generated suffixes. +- `addBool` converts an existing group into a bool shape and updates style/content/geometry via `:mod-obj` operations rather than adding a new object. +- Media blobs are stored separately from file-media metadata; `add-file-media` requires a `BlobWrapper`. + +## Export package + +- `.penpot` ZIPs include `manifest.json`, file/page/shape JSON, components/colors/typographies/tokens, media metadata, and media object blobs. +- Path/bool shape `:content` is converted to vectors before JSON encoding. +- File export intentionally includes only selected top-level attrs plus data options; color export removes `:file-id` and drops empty paths. +- Manifest type is `penpot/export-files`, version 1, generated by `penpot-library/%version%`, with optional referer and file relations. +- Export generation is sequential and lazy: delayed JSON/blob work is computed only as each zip entry is written, and the progress callback receives `{total,item,path}` after each entry. +- The library has compatibility defaults for features/migrations in the common builder; do not assume it always exports with the newest app-default migrations/features. \ No newline at end of file diff --git a/.serena/memories/mcp/core.md b/.serena/memories/mcp/core.md new file mode 100644 index 0000000000..ba794b1d0f --- /dev/null +++ b/.serena/memories/mcp/core.md @@ -0,0 +1,87 @@ +# Penpot MCP + +This subproject provides an MCP server for Penpot integration. +The MCP server communicates with a Penpot plugin via WebSockets, allowing +the MCP server to send tasks to the plugin and receive results, +enabling advanced AI-driven features in Penpot. + +## Tech Stack + +- Language: TypeScript +- Runtime: Node.js +- Framework: MCP SDK (@modelcontextprotocol/sdk) +- Build Tool: TypeScript Compiler (tsc) + esbuild +- Package Manager: pnpm + +## General Principles + +IMPORTANT: Use an idiomatic, object-oriented style. +In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions +rather than mere functions (i.e. use the strategy pattern, for example). + +Comments: +When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase +clearly defines *what* it is. Any details then follow in subsequent sentences. + +When describing what blocks of code do, you also use an elliptical style and start with a lower-case letter unless +the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is +required for sentences). + +## Project Structure (Excerpt) + +``` +mcp/ +├── packages/common/ # Shared type definitions +│ ├── src/ +│ │ ├── index.ts # exports for shared types +│ │ └── types.ts # PluginTaskResult, request/response interfaces +│ └── package.json # @penpot-mcp/common package +├── packages/server/ # MCP server subproject +│ ├── src/ +│ │ ├── index.ts # entry point +│ │ ├── PenpotMcpServer.ts # MCP server implementation (connection handling, tool registration, etc.) +│ │ ├── Tool.ts # base class for tools +│ │ ├── PluginTask.ts # base class for plugin tasks +│ │ ├── tasks/ # PluginTask implementations +│ │ └── tools/ # Tool implementations +| ├── data/ # contains resources, such as API info and prompts +│ └── package.json +├── packages/plugin/ # Penpot plugin subproject +│ ├── src/ +│ │ ├── main.ts # handles communication +│ │ └── plugin.ts # plugin implementation +│ └── package.json # Includes @penpot-mcp/common dependency +└── prepare-api-docs # Python project for the generation of API docs +``` + +## Key Development Tasks + +### Adjusting the Prompts + +The system prompt file (aka Penpot High-Level Overview) is located in +`packages/server/data/initial_instructions.md`. + +### Adding a new Tool + +1. Implement the tool class in `packages/server/src/tools/` following the `Tool` interface. + IMPORTANT: Do not catch any exceptions in the `executeCore` method. Let them propagate to be handled centrally. +2. Register the tool in `PenpotMcpServer`. + +Tools can be associated with a `PluginTask` that is executed in the plugin. +Many tools build on `ExecuteCodePluginTask`, as many operations can be reduced to code execution. + +### Adding a new PluginTask + +1. Implement the input data interface for the task in `packages/common/src/types.ts`. +2. Implement the `PluginTask` class in `packages/server/src/tasks/`. +3. Implement the corresponding task handler class in the plugin (`packages/plugin/src/task-handlers/`). + * In the success case, call `task.sendSuccess`. + * In the failure case, just throw an exception, which will be handled centrally! +4. Register the task handler in `packages/plugin/src/plugin.ts` in the `taskHandlers` list. + +## Dev Tooling + +From the `mcp/` directory, run + +* `pnpm run build` to test the build of all packages +* `pnpm run fmt` to apply the auto-formatter diff --git a/.serena/memories/memory-maintenance.md b/.serena/memories/memory-maintenance.md new file mode 100644 index 0000000000..81cfa9aaf3 --- /dev/null +++ b/.serena/memories/memory-maintenance.md @@ -0,0 +1,33 @@ +# Memory Maintenance + +## Discovery Model + +- Core principle: progressive discovery through references, building a graph of memories. +- Initially, agents are provided with the list of all memories (names only). +- Agents should read `mem:critical-info` as the top-level entry point (graph root). + This memory should contain references to other memories covering major project domains. + The referenced memories shall, in turn, shall contain references to even more specific memories, and so on. + The depth of the graph shall depend on the project complexity. +- Use topics/folders to group related memories in order to make the content structure explicit. + Folders can mirror project structure (e.g. modules like frontend/backend) or topics like debugging, architecture, etc. +- Memory references must use a mem: prefix inside backticks, e.g. `mem:frontend/core`. + The surrounding text should clearly indicate when to read the memory/which content to expect. + The text should provide more precise guidance than the memory name alone, + i.e. avoid a reference like "frontend debugging and error handling: `mem:frontend/handling-errors-and-debugging` and instead make clear which concrete aspects are covered in the memory. +- Memories themselves should not contain information about when to read them; this is the responsibility of the referring memory. + +## Style + +Dense agent notes, not prose docs. Prefer invariants, terse bullets. +Avoid obvious context, rationale, and examples unless they prevent likely mistakes. +Keep guidance durable and generalizable, not task-local. + +## Add/update threshold + +Add or update memories only with stable, non-obvious project conventions that avoid complex rediscovery in the future. +Do not add: quick-read facts; generic language/framework knowledge; one-off task notes; volatile line-level details; behavior likely to change soon. + +## Maintenance Actions + +- Renaming memories: References are updated automatically if handled via Serena's memory rename tool. +- Checking for stale memories (e.g. after deletion): Call `serena memories check` for a report. \ No newline at end of file diff --git a/.serena/memories/plugins/core.md b/.serena/memories/plugins/core.md new file mode 100644 index 0000000000..ea51e5dad6 --- /dev/null +++ b/.serena/memories/plugins/core.md @@ -0,0 +1,37 @@ +# Plugins Architecture and Workflow + +`plugins/`: standalone TypeScript/pnpm workspace for Plugin API packages and sample plugins. Related to, distinct from, frontend CLJS Plugin API runtime. + +## Layout + +- `libs/plugin-types`: TypeScript declarations for the public Penpot Plugin API. Type-only package; runtime behavior is implemented elsewhere. +- `libs/plugins-runtime`: runtime that loads plugins and exposes/generated API behavior to plugin code. +- `libs/plugins-styles`: reusable styling package for plugins. +- `apps/*-plugin`: sample/development plugins. `apps/e2e`: plugin e2e tests. + +## Dev Workflow + +- From `plugins/`: install `pnpm -r install`; runtime dev server `pnpm run start` or `pnpm run start:app:runtime`; sample plugin `pnpm run start:plugin:<name>`; build runtime `pnpm run build:runtime`; build plugins `pnpm run build:plugins`; lint `pnpm run lint`; format `pnpm run format:check` / `pnpm run format`; tests `pnpm run test`; e2e `pnpm run test:e2e`. +- If a change affects public Plugin API types or runtime, update `plugins/CHANGELOG.md`. Prefix type/signature entries with `**plugin-types:**`; runtime behavior entries with `**plugin-runtime:**`. +- JS Plugin API behavior inside Penpot app: `mem:frontend/plugin-api-to-cljs-binding`; TS declarations are not runtime code; many API objects are CLJS proxies in `frontend/src/app/plugins/*.cljs`. + +## Sandbox and global cleanup + +- The runtime uses SES compartments. Public API return values are passed through `ses.safeReturn` before crossing back to plugin code. +- Plugin `fetch` is sanitized: credentials are omitted and Authorization is blanked. The exposed response only includes ok/status/statusText/url/text/json. +- Timer callbacks are wrapped to mark plugin-originated errors, and timeout/interval IDs are tracked so plugin close can clear them. +- Plugin-originated errors are tracked in a WeakMap instead of mutating error objects, because SES can freeze errors. +- Closing a plugin removes public API keys from the compartment globalThis. + +## Lifecycle + +- Loading a plugin closes existing non-background plugins and resets the runtime registry. Be careful around `allowBackground` semantics when changing load/close behavior. +- If sandbox evaluation fails, the runtime marks the error as plugin-originated, closes the plugin, and rethrows. +- `plugin-manager` removes event listeners, timers, intervals, and modal state on close, and marks the plugin destroyed. Listener callbacks check that flag because Penpot events can fire after close. + +## Modal/UI behavior + +- Modal URL preparation differs by manifest version: v1 uses query string parameters, v2 puts parameters in the URL hash. +- `openModal` is idempotent for the same iframe source and avoids reopening when the target URL is already displayed. +- Modal permissions are derived from manifest permissions (`allow:downloads`, `clipboard:read`, `clipboard:write`). +- `resizeModal` clamps to at least 200x200 and at most the window minus margins, adjusting transform so the modal remains in the viewport. \ No newline at end of file diff --git a/.serena/memories/render-wasm/core.md b/.serena/memories/render-wasm/core.md new file mode 100644 index 0000000000..30734d2b0d --- /dev/null +++ b/.serena/memories/render-wasm/core.md @@ -0,0 +1,32 @@ +# render-wasm Architecture and Workflow + +`render-wasm/`: Rust crate compiled to WebAssembly via Emscripten/Skia; frontend loads generated JS/WASM renderer. FFI/memory/tile behavior: `mem:render-wasm/ffi-rendering-subtleties`. + +## Stable Architecture + +- Exported functions live around `src/main.rs` / `src/wapi.rs` and are called from ClojureScript bridge namespaces under `frontend/src/app/render_wasm*`. +- Updates are two-phase: ClojureScript calls exported setters to push shape data, then `render_frame()` performs Skia drawing. +- Rendering is tile-based and shape data is stored separately from hierarchy. + +## Source Areas + +- `src/state*`: renderer state structures. +- `src/render/` and `src/render.rs`: tile/surface render pipeline. +- `src/shapes/` and `src/shapes.rs`: shape data and Skia drawing. +- `src/wasm/`, `src/wasm.rs`, `src/mem.rs`: JS/WASM memory and interop helpers. +- `src/math/` and `src/view.rs`: geometry and viewport helpers. + +## Build Environment + +`./build` sources `_build_env`, which sets the Emscripten paths and `EMCC_CFLAGS`. The WASM heap starts at 256 MB and uses geometric growth. + +## Commands + +From `render-wasm/`: +- Build/copy frontend artifacts: `./build`. +- Watch rebuild: `./watch`. +- Rust tests: `./test` or `cargo test <name>`. +- Lint: `./lint`. +- Format check: `cargo fmt --check`. + +Do not change exported WASM function signatures without updating the corresponding frontend bridge and verifying the frontend renderer path. \ No newline at end of file diff --git a/.serena/memories/render-wasm/ffi-rendering-subtleties.md b/.serena/memories/render-wasm/ffi-rendering-subtleties.md new file mode 100644 index 0000000000..f57856a17d --- /dev/null +++ b/.serena/memories/render-wasm/ffi-rendering-subtleties.md @@ -0,0 +1,25 @@ +# render-wasm FFI and Rendering Subtleties + +## FFI state and errors + +- The renderer uses one unsafe global `STATE`; the `with_state*` macros currently panic on invalid state pointer. Treat state pointer validity as critical, not recoverable. +- `#[wasm_error]` clears the error code on entry. Recoverable errors set code `0x01`, critical errors/panics set `0x02`, free the byte buffer, then panic so the CLJS bridge can catch and inspect `_read_error_code`. +- The frontend bridge maps `0x01` to `:non-blocking` and `0x02` to `:panic` in ex-data (`:type :wasm-error`). Check actual bridge code if changing names; older comments/docs may use different labels. +- WASM byte transfer is a single global slot. A caller that receives a pointer result must read and free it before another byte payload is written; errors free the slot via `#[wasm_error]`. + +## Shape pool and loading + +- Shapes are UUID-indexed, and hierarchy/structure is tracked separately. `ShapesPool::get` may return a cached modified clone when modifiers, structure, scale-content, or bool handling apply; `get_raw` bypasses those derived values. +- Bulk loading uses a `loading` flag. `touch_current` / `touch_shape` avoid tile invalidation while loading; text layouts and final view setup must happen after loading ends. +- Many setters mutate only the current shape selected by `use_shape` / current-shape APIs. If no current shape is selected, some mutation blocks are skipped silently. +- `set_parent_for_current_shape` only sets parent metadata and invalidates parent geometry; children must be updated separately to avoid duplicate children. +- Child deletion marks descendants deleted and removes them from all indexed tiles, preserving undo/redo while avoiding stale pixels after panning. + +## Tile/render behavior + +- Interactive transforms are distinct from viewport fast mode. `set_modifiers_start` enables fast mode and interactive transform; interactive transform still flushes each animation frame. +- During interactive transform, modifier tile invalidation is deferred to `render()` once per rAF. Outside interactive transform, `set_modifiers` rebuilds modifier tiles immediately. +- `set_modifiers_end` disables fast/interactive state and cancels pending async render; the caller must request the final full-quality render. +- Plain viewport fast mode (`options.is_viewport_interaction()`) renders from cache and does not flush target output inside `process_animation_frame`; interactive transforms do flush. +- Zoom changes rebuild the tile index while preserving cached tile textures. Avoid replacing that path with shallow rebuilds if blur/shadow cache preservation matters. +- Pending tile priority is intentionally reversed by pop order; check the queue construction before changing tile scheduling. \ No newline at end of file diff --git a/.serena/memories/workflow/creating-commits.md b/.serena/memories/workflow/creating-commits.md new file mode 100644 index 0000000000..43298e88d0 --- /dev/null +++ b/.serena/memories/workflow/creating-commits.md @@ -0,0 +1,32 @@ +# Creating Commits + +Commit only on explicit request. Before commit: `git status`; exclude unrelated user changes. + +## Message Format + +``` +:emoji: Subject line (imperative, capitalized, no period, <=70 chars) + +Body explaining what changed and why. + +Co-authored-by: <You (the LLM)> +``` + +## Commit Type Emojis + +`:bug:` bug fix · `:sparkles:` enhancement · `:tada:` new feature · `:recycle:` refactor · `:lipstick:` cosmetic · `:ambulance:` critical fix · `:books:` docs · `:construction:` WIP · `:boom:` breaking · `:wrench:` config · `:zap:` perf · `:whale:` docker · `:paperclip:` other · `:arrow_up:` dep upgrade · `:arrow_down:` dep downgrade · `:fire:` removal · `:globe_with_meridians:` translations · `:rocket:` epic/highlight + +## Changelogs + +For user-facing or notable changes, update the relevant changelog under the unreleased section: +- Main app/modules (`backend`, `frontend`, `common`, `render-wasm`, `exporter`, `mcp`): root `CHANGES.md`. +- Plugin subproject changes: `plugins/CHANGELOG.md`. + +Entry format uses the matching category (`:sparkles:`, `:bug:`, etc.) and references the GitHub issue or Taiga story when available: + +``` +- Description of change [Github #NNNN](https://github.com/penpot/penpot/issues/NNNN) +- Description of change [Taiga #NNNN](https://tree.taiga.io/project/penpot/us/NNNN) +``` + +Plugin API changelog prefixes: type/signature -> `**plugin-types:**`; runtime behavior -> `**plugin-runtime:**` in `plugins/CHANGELOG.md`. \ No newline at end of file diff --git a/.serena/memories/workflow/creating-prs.md b/.serena/memories/workflow/creating-prs.md new file mode 100644 index 0000000000..db0afb459c --- /dev/null +++ b/.serena/memories/workflow/creating-prs.md @@ -0,0 +1,32 @@ +# Creating Pull Requests + +PR only on explicit request. Branch: issue/feature-specific; fallback `<type>/<short-description>` (`fix/...`, `feat/...`, `refactor/...`, `docs/...`, `chore/...`, `perf/...`). + +## Title Format + +PR titles follow commit title conventions: + +``` +:emoji: Subject line (imperative, capitalized, no period, <=70 chars) +``` + +See `mem:workflow/creating-commits` for emoji codes. Squash merge uses the PR title as the final commit subject, so title format matters. + +## Description + +Include concise sections covering: +- what changed and why; +- related GitHub issues or Taiga stories (`Fixes #NNNN`, `Relates to #NNNN`, `Taiga #NNNN`); +- screenshots or recordings for UI-visible changes; +- testing performed and residual risk; +- breaking changes or migration notes, if any. + +PR descriptions are expected to start with: + +> **Note:** This PR was created with AI assistance as part of the Penpot MCP self-improvement initiative. + +## Before Opening + +- Follow `mem:workflow/creating-commits` for changelog expectations. +- Run the focused tests/lints appropriate to touched modules. +- Do not force-push during review unless the maintainer workflow explicitly asks for it. \ No newline at end of file diff --git a/.serena/memories/workflow/docs.md b/.serena/memories/workflow/docs.md new file mode 100644 index 0000000000..8504e5db1d --- /dev/null +++ b/.serena/memories/workflow/docs.md @@ -0,0 +1,19 @@ +# Docs Workflow + +`docs/`: Penpot documentation site; Eleventy. + +## Layout and Tooling + +- `docs/package.json`: Eleventy commands and documentation-site dependencies. +- `docs/README.md`: local setup and tooling notes. +- Diagrams may use PlantUML, svgbob, mermaid, and C4/arc42 conventions depending on the existing page. + +## Commands + +From `docs/`: +- Install deps if needed: `pnpm install`. +- Local server: `pnpm start` or `pnpm run serve` (serves on `http://localhost:8080`). +- Build: `pnpm run build`. +- Watch: `pnpm run watch`. + +Documentation changes should follow the existing page structure and rendered Help Center conventions rather than inventing a new style locally. \ No newline at end of file diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000000..b7e8941c75 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,142 @@ +# the name by which the project can be referenced within Serena +project_name: "penpot" + + +# list of languages for which language servers are started; choose from: +# al ansible bash clojure cpp +# cpp_ccls crystal csharp csharp_omnisharp dart +# elixir elm erlang fortran fsharp +# go groovy haskell haxe hlsl +# java json julia kotlin lean4 +# lua luau markdown matlab msl +# nix ocaml pascal perl php +# php_phpactor powershell python python_jedi python_ty +# r rego ruby ruby_solargraph rust +# scala solidity swift systemverilog terraform +# toml typescript typescript_vts vue yaml +# zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- clojure +- typescript +- rust + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +fixed_tools: [] + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this setting overrides the global configuration. +# Set this to [] to disable base modes for this project. +# Set this to a list of mode names to always include the respective modes for this project. +base_modes: + +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. +# This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +default_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: | + CRITICAL: Read the memory `critical-info` IMMEDIATELY. It is absolutely critical for your work on this project - as is + the Serena Instructions Manual. Do NOT wait for further instructions, read these critical items NOW! + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: + +# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries. +# Currently supported for: TypeScript. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +additional_workspace_folders: [] diff --git a/AGENTS.md b/AGENTS.md index bcb947da47..33bd1b0ebb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,63 +1,6 @@ -# AI Agent Guide +CRITICAL: Read `.serena/memories/critical-info.md` NOW for important project information and instructions. -This document provides the core context and operating guidelines for AI agents -working in this repository. +In general, information about this project is stored in memories located in `.serena/memories/`. +Memories reference other memories via `mem:name` (e.g. `mem:foo/bar` maps to `.serena/memories/foo/bar.md`). -## Before You Start - -Before responding to any user request, you must: - -1. Read this file completely. -2. Identify which modules are affected by the task. -3. Load the `AGENTS.md` file **only** for each affected module (see the - architecture table below). Not all modules have an `AGENTS.md` — verify the - file exists before attempting to read it. -4. Do **not** load `AGENTS.md` files for unrelated modules. - -## Role: Senior Software Engineer - -You are a high-autonomy Senior Full-Stack Software Engineer. You have full -permission to navigate the codebase, modify files, and execute commands to -fulfill your tasks. Your goal is to solve complex technical tasks with high -precision while maintaining a strong focus on maintainability and performance. - -### Operational Guidelines - -1. Before writing code, describe your plan. If the task is complex, break it - down into atomic steps. -2. Be concise and autonomous. -3. Do **not** touch unrelated modules unless the task explicitly requires it. -4. Commit only when explicitly asked. Follow the commit format rules in - `CONTRIBUTING.md`. -5. When searching code, prefer `ripgrep` (`rg`) over `grep` — it respects - `.gitignore` by default. - -## Architecture Overview - -Penpot is an open-source design tool composed of several modules: - -| Directory | Language | Purpose | Has `AGENTS.md` | -|-----------|----------|---------|:----------------:| -| `frontend/` | ClojureScript + SCSS | Single-page React app (design editor) | Yes | -| `backend/` | Clojure (JVM) | HTTP/RPC server, PostgreSQL, Redis | Yes | -| `common/` | Cljc (shared Clojure/ClojureScript) | Data types, geometry, schemas, utilities | Yes | -| `render-wasm/` | Rust -> WebAssembly | High-performance canvas renderer (Skia) | Yes | -| `exporter/` | ClojureScript (Node.js) | Headless Playwright-based export (SVG/PDF) | No | -| `mcp/` | TypeScript | Model Context Protocol integration | No | -| `plugins/` | TypeScript | Plugin runtime and example plugins | No | - -Some submodules use `pnpm` workspaces. The root `package.json` and -`pnpm-lock.yaml` manage shared dependencies. Helper scripts live in `scripts/`. - -### Module Dependency Graph - -``` -frontend ──> common -backend ──> common -exporter ──> common -frontend ──> render-wasm (loads compiled WASM) -``` - -`common` is referenced as a local dependency (`{:local/root "../common"}`) by -both `frontend` and `backend`. Changes to `common` can therefore affect multiple -modules — test across consumers when modifying shared code. +NOTE: When not using the Penpot agentic devenv, some tools mentioned in the memories will be unavailable. diff --git a/CHANGES.md b/CHANGES.md index 9385e8b851..2fe91f9b96 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,10 +6,10 @@ ### :rocket: Epics and highlights -- Add MCP server integration [Taiga #13112](https://tree.taiga.io/project/penpot/us/13112) - ### :sparkles: New features & Enhancements +- Show a read-only W × H size badge below the bounding box of the current selection (by @bittoby) [Github #9205](https://github.com/penpot/penpot/issues/9205) +- Expose `variants` retrieval on `LibraryComponent` via `isVariant()` type guard in plugin API [Github #9185](https://github.com/penpot/penpot/issues/9185) - Show alpha percentage next to library color values to distinguish colors that differ only in opacity (by @rockchris099) [Github #6328](https://github.com/penpot/penpot/issues/6328) - Add "Clear artboard guides" option to right-click context menu for frames (by @eureka0928) [Github #6987](https://github.com/penpot/penpot/issues/6987) - Add loader feedback while importing and exporting files [Github #9020](https://github.com/penpot/penpot/issues/9020) @@ -46,10 +46,12 @@ - Add a search bar to filter colors in the color palette toolbar (by @eureka0928) [Github #7653](https://github.com/penpot/penpot/issues/7653) - Allow customising the OIDC login button label (by @wdeveloper16) [Github #7027](https://github.com/penpot/penpot/issues/7027) - Add page separators in Workspace [Taiga #13611](https://tree.taiga.io/project/penpot/us/13611?milestone=262806) - +- Add Shift+Numpad0/1/2 as aliases to Shift+0/1/2 for zoom shortcuts [Github #2457](https://github.com/penpot/penpot/issues/2457) +- Add search bar to prototype interaction destination dropdown (by @moorsecopers99) [Github #8618](https://github.com/penpot/penpot/issues/8618) ### :bug: Bugs fixed +<<<<<<< fix/token-validation-group-nodes - Fix Copy as SVG: emit a single valid SVG document when multiple shapes are selected, and publish `image/svg+xml` to the clipboard so the paste target works in Inkscape and other SVG-native tools [Github #838](https://github.com/penpot/penpot/issues/838) - Fix token validation failure when group nodes are present in the token tree [Github #9010](https://github.com/penpot/penpot/issues/9010) - Reset profile submenu state when the account menu closes (by @eureka0928) [Github #8947](https://github.com/penpot/penpot/issues/8947) @@ -78,64 +80,277 @@ - Allow deleting the profile avatar after uploading [Github #9067](https://github.com/penpot/penpot/issues/9067) - Fix incorrect rendering when exporting text as SVG, PNG and JPG (by @edwin-rivera-dev) [Github #8516](https://github.com/penpot/penpot/issues/8516) +======= +- Fix plugin API `LibraryTypography.remove()` failing with a UUID assertion error [Github #8223](https://github.com/penpot/penpot/issues/8223) +- Fix MCP SSE sessions leaking memory on zombie connections by adding inactivity timeout parity with Streamable HTTP sessions (by @bitloi) [Github #9432](https://github.com/penpot/penpot/issues/9432) +- Fix missing `labels.open` translation (by @MilosM348) [Github #9320](https://github.com/penpot/penpot/pull/9320) +- Fix two plugin error i18n keys broken by leading whitespace before `msgid` in `en.po` (by @MilosM348) +- Use the noun "copia" instead of the verb "copiar" as the Spanish duplicate-suffix when duplicating design tokens [Github #9623](https://github.com/penpot/penpot/issues/9623) +- Harden Nginx responses with standard security headers and hide upstream `X-Powered-By` headers +- Expose Source Sans Pro semibold (weight 600) variants in the builtin fonts list, matching the bundled font assets and CSS @font-face declarations [Github #7378](https://github.com/penpot/penpot/issues/7378) +- Fix plugin API `shape.fills` and `shape.strokes` arrays being read-only [Github #8357](https://github.com/penpot/penpot/issues/8357) +- Fix `get-profile` masking transient DB errors as anonymous user (by @jack-stormentswe) [Github #9253](https://github.com/penpot/penpot/issues/9253) +- Fix `Ctrl+'` "Show guides" shortcut on non-US keyboard layouts by matching the physical key location (by @RenzoMXD) [Github #8423](https://github.com/penpot/penpot/issues/8423) +- Fix lost-update race on `team.features` during concurrent file creation (by @web-dev0521) [Github #9197](https://github.com/penpot/penpot/issues/9197) +- Fix copy and paste actions crashing the workspace on insecure origins (plain HTTP / non-`localhost`) where the Clipboard API is unavailable (by @MilosM348) [Github #6514](https://github.com/penpot/penpot/issues/6514) +- Fix blend-mode dropdown leaving the canvas rendered with the last hover-preview blend mode when dismissed without selecting an option; the WASM render is now reverted to the saved blend mode on pointer-leave (by @edwin-rivera-dev) [Github #XXXX](https://github.com/penpot/penpot/issues/XXXX) +>>>>>>> develop ## 2.16.0 (Unreleased) ### :boom: Breaking changes & Deprecations ### :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)) +- 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 -- Access Tokens look & feel refinement [Taiga #13114](https://tree.taiga.io/project/penpot/us/13114) -- Enhance readability of applied tokens in plugins API [Taiga #13714](https://tree.taiga.io/project/penpot/issue/13714) +- Add delete group to assets panel context menu (by @FairyPigDev) [#9141](https://github.com/penpot/penpot/issues/9141) (PR: [#9151](https://github.com/penpot/penpot/pull/9151), [#9211](https://github.com/penpot/penpot/pull/9211)) +- Show alpha percentage on library color values (by @rockchris099) [#6328](https://github.com/penpot/penpot/issues/6328) +- Add clear artboard guides to frame context menu (by @eureka0928) [#6987](https://github.com/penpot/penpot/issues/6987) (PR: [#8936](https://github.com/penpot/penpot/pull/8936)) +- Add loader feedback while importing and exporting files (by @moorsecopers99) [#9020](https://github.com/penpot/penpot/issues/9020) (PR: [#9024](https://github.com/penpot/penpot/pull/9024)) +- Allow duplicating color and typography styles (by @MkDev11) [#2912](https://github.com/penpot/penpot/issues/2912) (PR: [#8449](https://github.com/penpot/penpot/pull/8449)) +- Add woff2 support on user uploaded fonts (by @Nivl) [#3521](https://github.com/penpot/penpot/issues/3521) (PR: [#8248](https://github.com/penpot/penpot/pull/8248)) +- Import Tokens from linked library (by @dfelinto) [#9635](https://github.com/penpot/penpot/issues/9635) (PR: [#8391](https://github.com/penpot/penpot/pull/8391)) +- Option to download custom fonts (by @dfelinto) [#9672](https://github.com/penpot/penpot/issues/9672) (PR: [#8320](https://github.com/penpot/penpot/pull/8320)) +- Add copy as image to workspace context menu (by @dfelinto) [#9607](https://github.com/penpot/penpot/issues/9607) (PR: [#8313](https://github.com/penpot/penpot/pull/8313)) +- Add Tab/Shift+Tab navigation to rename layers sequentially (by @bittoby) [#2569](https://github.com/penpot/penpot/issues/2569) (PR: [#8474](https://github.com/penpot/penpot/pull/8474)) +- Copy and paste entire rows in existing table (by @bittoby) [#5969](https://github.com/penpot/penpot/issues/5969) (PR: [#8498](https://github.com/penpot/penpot/pull/8498)) +- Rename token group [#9637](https://github.com/penpot/penpot/issues/9637) (PR: [#8275](https://github.com/penpot/penpot/pull/8275)) +- Duplicate token group [#9638](https://github.com/penpot/penpot/issues/9638) (PR: [#8886](https://github.com/penpot/penpot/pull/8886)) +- Copy token name from contextual menu [#9639](https://github.com/penpot/penpot/issues/9639) (PR: [#8566](https://github.com/penpot/penpot/pull/8566)) +- Add drag-to-change for numeric inputs in workspace sidebar (by @RenzoMXD) [#2466](https://github.com/penpot/penpot/issues/2466) (PR: [#8536](https://github.com/penpot/penpot/pull/8536)) +- Add CSS linter [#9636](https://github.com/penpot/penpot/issues/9636) (PR: [#8592](https://github.com/penpot/penpot/pull/8592)) +- Add per-group add button for typographies (by @eureka0928) [#5275](https://github.com/penpot/penpot/issues/5275) (PR: [#8895](https://github.com/penpot/penpot/pull/8895)) +- Add Find & Replace for text content and layer names (by @statxc) [#7108](https://github.com/penpot/penpot/issues/7108) (PR: [#8899](https://github.com/penpot/penpot/pull/8899)) +- Use page name for multi-export ZIP/PDF downloads (by @Dexterity104) [#8773](https://github.com/penpot/penpot/issues/8773) (PR: [#8874](https://github.com/penpot/penpot/pull/8874)) +- Make links in comments clickable (by @eureka0928) [#1602](https://github.com/penpot/penpot/issues/1602) (PR: [#8894](https://github.com/penpot/penpot/pull/8894)) +- Add visibility toggle for strokes (by @eureka0928) [#7438](https://github.com/penpot/penpot/issues/7438) (PR: [#8913](https://github.com/penpot/penpot/pull/8913)) +- Sort asset library subfolders alphabetically at every nesting level (by @eureka0928) [#2572](https://github.com/penpot/penpot/issues/2572) (PR: [#8952](https://github.com/penpot/penpot/pull/8952)) +- Add Paste to replace (Cmd+Shift+V) for selected shapes (by @eureka0928) [#4240](https://github.com/penpot/penpot/issues/4240) (PR: [#9033](https://github.com/penpot/penpot/pull/9033)) +- Differentiate incoming and outgoing interaction link colors (by @claytonlin1110) [#7794](https://github.com/penpot/penpot/issues/7794) (PR: [#8923](https://github.com/penpot/penpot/pull/8923)) +- Reorder prototyping overlay options to show Position before Relative to (by @rockchris099) [#2910](https://github.com/penpot/penpot/issues/2910) +- Add customizable colors for ruler guides (by @Dexterity104) [#5199](https://github.com/penpot/penpot/issues/5199) (PR: [#8986](https://github.com/penpot/penpot/pull/8986)) +- Persist asset search and section filter across sidebar tabs (by @eureka0928) [#2913](https://github.com/penpot/penpot/issues/2913) (PR: [#8985](https://github.com/penpot/penpot/pull/8985)) +- Add delete and duplicate buttons to typography dialog (by @eureka0928) [#5270](https://github.com/penpot/penpot/issues/5270) (PR: [#8983](https://github.com/penpot/penpot/pull/8983)) +- Edit ruler guide position by double-clicking the guide pill (by @eureka0928) [#2311](https://github.com/penpot/penpot/issues/2311) (PR: [#8987](https://github.com/penpot/penpot/pull/8987)) +- Add search bar to color palette (by @eureka0928) [#7653](https://github.com/penpot/penpot/issues/7653) (PR: [#8994](https://github.com/penpot/penpot/pull/8994)) +- Add search bar to board size presets (by @eureka0928) [#4658](https://github.com/penpot/penpot/issues/4658) (PR: [#9117](https://github.com/penpot/penpot/pull/9117)) +- Allow customising the OIDC login button label (by @wdeveloper16) [#7027](https://github.com/penpot/penpot/issues/7027) (PR: [#9026](https://github.com/penpot/penpot/pull/9026)) +- Add page separators in Workspace [#9180](https://github.com/penpot/penpot/issues/9180) (PR: [#8561](https://github.com/penpot/penpot/pull/8561)) +- Preserve vector content when pasting SVG from external tools (by @RenzoMXD) [#546](https://github.com/penpot/penpot/issues/546) (PR: [#9182](https://github.com/penpot/penpot/pull/9182)) +- Add pixel grid color picker in viewport settings (by @jack-stormentswe) [#7750](https://github.com/penpot/penpot/issues/7750) (PR: [#9155](https://github.com/penpot/penpot/pull/9155)) +- Add HEX/HSB/HSL support to color picker with persistent model switcher (by @edwin-rivera-dev) [#9133](https://github.com/penpot/penpot/issues/9133) (PR: [#9134](https://github.com/penpot/penpot/pull/9134)) +- Show specific invitation-link error messages (by @niwinz) [#9220](https://github.com/penpot/penpot/issues/9220) (PR: [#9223](https://github.com/penpot/penpot/pull/9223)) +- Show detailed file import error messages (by @jsdevninja) [#8212](https://github.com/penpot/penpot/issues/8212) (PR: [#9004](https://github.com/penpot/penpot/pull/9004)) +- Add read-only preview mode for saved versions (by @wdeveloper16) [#7622](https://github.com/penpot/penpot/issues/7622) (PR: [#8976](https://github.com/penpot/penpot/pull/8976)) +- Add clipboard read/write permissions to the plugin system (by @wdeveloper16) [#6980](https://github.com/penpot/penpot/issues/6980) (PR: [#9053](https://github.com/penpot/penpot/pull/9053)) +- Update auth hero illustration on login screen [#9532](https://github.com/penpot/penpot/issues/9532) (PR: [#9552](https://github.com/penpot/penpot/pull/9552)) +- Update Open Graph link preview metadata [#9555](https://github.com/penpot/penpot/issues/9555) (PR: [#9557](https://github.com/penpot/penpot/pull/9557)) +- Restore deleted team files in bulk instead of per file (by @Dexterity104) [#9246](https://github.com/penpot/penpot/issues/9246) (PR: [#9248](https://github.com/penpot/penpot/pull/9248)) +- Preserve Inkscape labels when pasting SVGs (by @jeffrey701) [#7869](https://github.com/penpot/penpot/issues/7869) (PR: [#9252](https://github.com/penpot/penpot/pull/9252)) +- Add Alt+click to expand layer subtree (by @MilosM348) [#7736](https://github.com/penpot/penpot/issues/7736) (PR: [#9179](https://github.com/penpot/penpot/pull/9179)) +- Allow deleting the profile avatar after uploading (by @moorsecopers99) [#9067](https://github.com/penpot/penpot/issues/9067) (PR: [#9068](https://github.com/penpot/penpot/pull/9068)) + +### :bug: Bugs fixed +- Add Shift+Numpad aliases for zoom shortcuts (by @RenzoMXD) [#2457](https://github.com/penpot/penpot/issues/2457) (PR: [#9063](https://github.com/penpot/penpot/pull/9063)) +- Save and restore selection state in undo/redo (by @eureka0928) [#6007](https://github.com/penpot/penpot/issues/6007) (PR: [#8652](https://github.com/penpot/penpot/pull/8652)) +- Add guide locking and fix locked element selection in viewer (by @Dexterity104) [#8358](https://github.com/penpot/penpot/issues/8358) (PR: [#8949](https://github.com/penpot/penpot/pull/8949)) +- Add natural sorting on token names [#8635](https://github.com/penpot/penpot/issues/8635) (PR: [#8672](https://github.com/penpot/penpot/pull/8672)) +- Fix warnings for unsupported token $type (by @Dexterity104) [#8790](https://github.com/penpot/penpot/issues/8790) (PR: [#8873](https://github.com/penpot/penpot/pull/8873)) +- Apply styles to selection (by @AzazelN28) [#9661](https://github.com/penpot/penpot/issues/9661) (PR: [#8625](https://github.com/penpot/penpot/pull/8625)) +- Fix Alt/Option to draw shapes from center point (by @offreal) [#8360](https://github.com/penpot/penpot/issues/8360) (PR: [#8361](https://github.com/penpot/penpot/pull/8361)) +- Fix library update button freezing [#9330](https://github.com/penpot/penpot/issues/9330) (PR: [#9513](https://github.com/penpot/penpot/pull/9513)) +- Fix typo in subscription settings success key (by @jack-stormentswe) [#9203](https://github.com/penpot/penpot/issues/9203) (PR: [#9204](https://github.com/penpot/penpot/pull/9204)) +- Add token name on broken token pill on sidebar [#9534](https://github.com/penpot/penpot/issues/9534) (PR: [#8527](https://github.com/penpot/penpot/pull/8527)) +- Fix tooltip activated when tab change [#9539](https://github.com/penpot/penpot/issues/9539) (PR: [#8719](https://github.com/penpot/penpot/pull/8719)) +- Fix title on shared button [#9541](https://github.com/penpot/penpot/issues/9541) (PR: [#8696](https://github.com/penpot/penpot/pull/8696)) +- Fix hover on layers [#9542](https://github.com/penpot/penpot/issues/9542) (PR: [#8885](https://github.com/penpot/penpot/pull/8885)) +- Fix highlight after name edition [#9537](https://github.com/penpot/penpot/issues/9537) (PR: [#8890](https://github.com/penpot/penpot/pull/8890)) +- Fix multiple small UI bugs — id prop, update copy, library modal scroll [#9536](https://github.com/penpot/penpot/issues/9536) (PR: [#8604](https://github.com/penpot/penpot/pull/8604)) +- Fix themes modal height [#9535](https://github.com/penpot/penpot/issues/9535) (PR: [#9105](https://github.com/penpot/penpot/pull/9105)) +- Fix layers panel rename showing default type name (by @jack-stormentswe) [#9230](https://github.com/penpot/penpot/issues/9230) (PR: [#9231](https://github.com/penpot/penpot/pull/9231)) +- Suppress browser context menu on workspace sidebar right-click (by @sujyotraut) [#5127](https://github.com/penpot/penpot/issues/5127) (PR: [#9196](https://github.com/penpot/penpot/pull/9196)) +- Fix plugin API fileVersion.restore() hanging on failure (by @thomascolden585-svg) [#9092](https://github.com/penpot/penpot/issues/9092) (PR: [#9111](https://github.com/penpot/penpot/pull/9111)) +- Fix stroke-only SVG paths losing rounded join on split (by @Chrissi2812) [#5283](https://github.com/penpot/penpot/issues/5283) (PR: [#9156](https://github.com/penpot/penpot/pull/9156)) +- Fix plugin API library.connectLibrary() not returning Promise (by @boskodev790) [#9646](https://github.com/penpot/penpot/issues/9646) (PR: [#9158](https://github.com/penpot/penpot/pull/9158)) +- Fix LDAP provider schema typo in malli migration (by @boskodev790) [#9531](https://github.com/penpot/penpot/issues/9531) (PR: [#9165](https://github.com/penpot/penpot/pull/9165)) +- Fix login-with-ldap dropping error on uninitialized LDAP (by @boskodev790) [#9533](https://github.com/penpot/penpot/issues/9533) (PR: [#9159](https://github.com/penpot/penpot/pull/9159)) +- Fix OIDC_USER_INFO_SOURCE flag being ignored (by @GeekClassy) [#9108](https://github.com/penpot/penpot/issues/9108) (PR: [#9114](https://github.com/penpot/penpot/pull/9114)) +- Fix share-link viewer crash on malformed email (by @boskodev790) [#9530](https://github.com/penpot/penpot/issues/9530) (PR: [#9120](https://github.com/penpot/penpot/pull/9120)) +- Fix crash pasting component variants from external library (by @FairyPigDev) [#8144](https://github.com/penpot/penpot/issues/8144) (PR: [#9136](https://github.com/penpot/penpot/pull/9136)) +- Remove corepack from MCP launcher for Node.js 25+ (by @TheAifam5) [#8877](https://github.com/penpot/penpot/issues/8877) (PR: [#9119](https://github.com/penpot/penpot/pull/9119)) +- Fix Copy as SVG for multi-shape selections (by @RenzoMXD) [#9088](https://github.com/penpot/penpot/issues/9088) (PR: [#9066](https://github.com/penpot/penpot/pull/9066)) +- Preserve OpenType variant name table for custom fonts in the dashboard (by @rutherfordcraze) [#8924](https://github.com/penpot/penpot/issues/8924) (PR: [#9193](https://github.com/penpot/penpot/pull/9193)) +- Add export panel to inspect styles tab [#9660](https://github.com/penpot/penpot/issues/9660) (PR: [#8645](https://github.com/penpot/penpot/pull/8645)) +- Fix styles between grid layout inputs [#9656](https://github.com/penpot/penpot/issues/9656) (PR: [#8673](https://github.com/penpot/penpot/pull/8673)) +- Fix dates to avoid show them in english when browser is in auto [#8709](https://github.com/penpot/penpot/issues/8709) (PR: [#8775](https://github.com/penpot/penpot/pull/8775)) +- Fix focus radio button [#9657](https://github.com/penpot/penpot/issues/9657) (PR: [#8774](https://github.com/penpot/penpot/pull/8774)) +- Token tree should be expanded by default [#9662](https://github.com/penpot/penpot/issues/9662) (PR: [#8799](https://github.com/penpot/penpot/pull/8799)) +- Fix opacity incorrectly disabled for visible shapes [#9658](https://github.com/penpot/penpot/issues/9658) (PR: [#8854](https://github.com/penpot/penpot/pull/8854)) +- Fix plugin modal drag over iframe and close button (by @marekhrabe) [#9529](https://github.com/penpot/penpot/issues/9529) (PR: [#8871](https://github.com/penpot/penpot/pull/8871)) +- Fix hot update on color-row on texts [#9664](https://github.com/penpot/penpot/issues/9664) (PR: [#8880](https://github.com/penpot/penpot/pull/8880)) +- Fix selected color tokens [#9655](https://github.com/penpot/penpot/issues/9655) (PR: [#8889](https://github.com/penpot/penpot/pull/8889)) +- Fix dashboard Recent/Deleted titles overlapped by scrolling content (by @rockchris099) [#8577](https://github.com/penpot/penpot/issues/8577) +- Display resolved values of inactive tokens [#9665](https://github.com/penpot/penpot/issues/9665) (PR: [#8589](https://github.com/penpot/penpot/pull/8589)) +- Fix hyphens stripped from export filenames (by @jamesrayammons) [#8901](https://github.com/penpot/penpot/issues/8901) (PR: [#8944](https://github.com/penpot/penpot/pull/8944)) +- Fix app crash on multiselection with hidden shapes and opacity mixed value [#9666](https://github.com/penpot/penpot/issues/9666) (PR: [#8932](https://github.com/penpot/penpot/pull/8932)) +- Fix gap input throwing an error [#9667](https://github.com/penpot/penpot/issues/9667) (PR: [#8984](https://github.com/penpot/penpot/pull/8984)) +- Fix copy to be more specific [#9668](https://github.com/penpot/penpot/issues/9668) (PR: [#9028](https://github.com/penpot/penpot/pull/9028)) +- Fix incorrect rendering when exporting text as SVG, PNG and JPG (by @edwin-rivera-dev) [#8516](https://github.com/penpot/penpot/issues/8516) (PR: [#9094](https://github.com/penpot/penpot/pull/9094)) +- Fix typography style creation with tokenized line-height (by @juan-flores077) [#8479](https://github.com/penpot/penpot/issues/8479) (PR: [#9121](https://github.com/penpot/penpot/pull/9121)) +- Fix colorpicker layout hiding eyedropper button [#9669](https://github.com/penpot/penpot/issues/9669) (PR: [#9125](https://github.com/penpot/penpot/pull/9125)) +- Fix restore-deleted-team-files reduce typo (by @Dexterity104) [#9240](https://github.com/penpot/penpot/issues/9240) (PR: [#9241](https://github.com/penpot/penpot/pull/9241)) +- Fix internal error on layer prev/next sibling selection (by @jsdevninja) [#7064](https://github.com/penpot/penpot/issues/7064) (PR: [#9003](https://github.com/penpot/penpot/pull/9003)) +- Fix tooltip appearing two times when nested elements [#9674](https://github.com/penpot/penpot/issues/9674) (PR: [#9031](https://github.com/penpot/penpot/pull/9031)) +- Fix broken update library notification link in the UI [#9673](https://github.com/penpot/penpot/issues/9673) (PR: [#9070](https://github.com/penpot/penpot/pull/9070)) +- Fix plugin API ShapeBase.component() returning outermost instead of immediate component [#9183](https://github.com/penpot/penpot/issues/9183) (PR: [#9298](https://github.com/penpot/penpot/pull/9298)) +- Fix content attribute sync group resolution by shape type [#9527](https://github.com/penpot/penpot/issues/9527) (PR: [#8724](https://github.com/penpot/penpot/pull/8724)) +- Fix plugin parse-point returning plain map instead of Point record (by @FairyPigDev) [#8409](https://github.com/penpot/penpot/issues/8409) (PR: [#9129](https://github.com/penpot/penpot/pull/9129)) +- Fix `:heigth` typo in clipboard frame-same-size? (by @iot2edge) [#9249](https://github.com/penpot/penpot/issues/9249) (PR: [#9250](https://github.com/penpot/penpot/pull/9250)) +- Fix Settings Update button enabled state (by @moorsecopers99) [#9090](https://github.com/penpot/penpot/issues/9090) (PR: [#9091](https://github.com/penpot/penpot/pull/9091)) +- Fix library updates reappearing after reload [#9326](https://github.com/penpot/penpot/issues/9326) (PR: [#9563](https://github.com/penpot/penpot/pull/9563)) +- Fix dependency libraries visible after unlinking main library [#9331](https://github.com/penpot/penpot/issues/9331) (PR: [#9511](https://github.com/penpot/penpot/pull/9511)) +- Fix internal error on margins [#9309](https://github.com/penpot/penpot/issues/9309) (PR: [#9311](https://github.com/penpot/penpot/pull/9311)) +- Remove drag-to-change when token applied on numeric input [#9313](https://github.com/penpot/penpot/issues/9313) (PR: [#9314](https://github.com/penpot/penpot/pull/9314)) +- Fix extra input on canvas background [#9359](https://github.com/penpot/penpot/issues/9359) (PR: [#9360](https://github.com/penpot/penpot/pull/9360)) +- Fix frame selection highlight persists after rename [#9538](https://github.com/penpot/penpot/issues/9538) (PR: [#8938](https://github.com/penpot/penpot/pull/8938)) +- Fix several color picker issues [#9556](https://github.com/penpot/penpot/issues/9556) (PR: [#9558](https://github.com/penpot/penpot/pull/9558)) +- Fix asset icon broken on Asset tab [#9587](https://github.com/penpot/penpot/issues/9587) (PR: [#9612](https://github.com/penpot/penpot/pull/9612)) +- Fix text fill color stops updating in multiselect with texts [#9608](https://github.com/penpot/penpot/issues/9608) (PR: [#9549](https://github.com/penpot/penpot/pull/9549)) +- Fix editing a legacy text element silently detaches its color token [#9255](https://github.com/penpot/penpot/issues/9255) (PR: [#9525](https://github.com/penpot/penpot/pull/9525)) +- Fix token application to grid paddings [#9494](https://github.com/penpot/penpot/issues/9494) (PR: [#9630](https://github.com/penpot/penpot/pull/9630)) +- Fix file crashing when switching a variant [#9259](https://github.com/penpot/penpot/issues/9259) (PR: [#9147](https://github.com/penpot/penpot/pull/9147)) +- Fix set activation after renaming [#9329](https://github.com/penpot/penpot/issues/9329) (PR: [#9545](https://github.com/penpot/penpot/pull/9545)) +- Fix font selection position hiding available fonts [#9489](https://github.com/penpot/penpot/issues/9489) (PR: [#9499](https://github.com/penpot/penpot/pull/9499)) +- Fix numeric input changes not saved when clicking on viewport [#9491](https://github.com/penpot/penpot/issues/9491) (PR: [#9548](https://github.com/penpot/penpot/pull/9548)) +- Fix resize cursor appearing on login and register buttons [#9505](https://github.com/penpot/penpot/issues/9505) (PR: [#9590](https://github.com/penpot/penpot/pull/9590)) +- Fix version restore restoring first previewed version instead of selected one [#9588](https://github.com/penpot/penpot/issues/9588) (PR: [#9626](https://github.com/penpot/penpot/pull/9626)) +- Fix incorrect error message when applying tokens while editing text [#9620](https://github.com/penpot/penpot/issues/9620) (PR: [#9708](https://github.com/penpot/penpot/pull/9708)) + + +## 2.15.4 (Unreleased) ### :bug: Bugs fixed -- Fix Alt/Option to draw shapes from center point (by @offreal) [Github #8361](https://github.com/penpot/penpot/pull/8361) -- Add token name on broken token pill on sidebar [Taiga #13527](https://tree.taiga.io/project/penpot/issue/13527) -- Fix tooltip activated when tab change [Taiga #13627](https://tree.taiga.io/project/penpot/issue/13627) -- Fix title on shared button [Taiga #13730](https://tree.taiga.io/project/penpot/issue/13730) -- Fix hover on layers [Taiga #13799](https://tree.taiga.io/project/penpot/issue/13799) -- Fix highlight after name edition [Taiga #13783](https://tree.taiga.io/project/penpot/issue/13783) -- Fix id prop on switch component [Taiga #13534](https://tree.taiga.io/project/penpot/issue/13534) -- Fix dashboard navigation tabs overlap with projects content when scrolling [Taiga #13962](https://tree.taiga.io/project/penpot/issue/13962) -- Fix text editor v1 focus [Taiga #13961](https://tree.taiga.io/project/penpot/issue/13961) +- Emit `create-shape-layout` for flex/grid layout creation from plugins and MCP (same event as workspace) [#9652](https://github.com/penpot/penpot/issues/9652) (PR: [#9654](https://github.com/penpot/penpot/pull/9654)) +- Fix broken authentication on /assets handlers [#9677](https://github.com/penpot/penpot/issues/9677) (PR: [#9679](https://github.com/penpot/penpot/pull/9679)) +- Fix API doc endpoint returning HTML as text/plain [#9680](https://github.com/penpot/penpot/issues/9680) (PR: [#9681](https://github.com/penpot/penpot/pull/9681)) +- Fix unexpected error when opening the export dialog [#9721](https://github.com/penpot/penpot/issues/9721) (PR: [#9704](https://github.com/penpot/penpot/pull/9704)) + +## 2.15.3 + +### :bug: Bugs fixed + +- Fix Plugin API token methods failing with schema validation error on PRO [GH #9641](https://github.com/penpot/penpot/issues/9641) + (PR: [#9632](https://github.com/penpot/penpot/pull/9632)) +- Sanitize comment content on rendering [GH #9642](https://github.com/penpot/penpot/issues/9642) + (PR: [#9605](https://github.com/penpot/penpot/pull/9605)) +- Sanitize font family names on custom uploaded fonts [GH #9643](https://github.com/penpot/penpot/issues/9643) + (PR: [#9601](https://github.com/penpot/penpot/pull/9601)) + +## 2.15.2 + +### :bug: Bugs fixed + +- Fix mcp related internal config for docker images [GH #9565](https://github.com/penpot/penpot/pull/9565) -## 2.15.0 (Unreleased) +## 2.15.1 ### :sparkles: New features & Enhancements -- Add MCP server integration [Taiga #13112](https://tree.taiga.io/project/penpot/us/13112) -- Add chunked upload API for large media and binary files (removes previous upload size limits) [Github #8909](https://github.com/penpot/penpot/pull/8909) +- Add support for chunked uploading of fonts [GH #9560](https://github.com/penpot/penpot/issues/9560) ### :bug: Bugs fixed -- Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041) -- Fix removeChild errors from unmount race conditions [Github #8927](https://github.com/penpot/penpot/pull/8927) +- Fix "Help & Learning" submenu vertical alignment in account menu (by @juan-flores077) [#9137](https://github.com/penpot/penpot/issues/9137) (PR: [#9138](https://github.com/penpot/penpot/pull/9138)) +## 2.15.0 + +### :sparkles: New features & Enhancements + +- Add MCP server integration [GH #9174](https://github.com/penpot/penpot/issues/9174) + (PR: [#9032](https://github.com/penpot/penpot/pull/9032), [#9321](https://github.com/penpot/penpot/pull/9321)) +- Add chunked upload API for large media and binary files (removes previous upload size limits) [GH #9516](https://github.com/penpot/penpot/issues/9516) + (PR: [#8909](https://github.com/penpot/penpot/pull/8909)) +- Add anonymous telemetry event collection [GH #9467](https://github.com/penpot/penpot/issues/9467) + (PR: [#9065](https://github.com/penpot/penpot/pull/9065), [#9483](https://github.com/penpot/penpot/pull/9483)) +- Improve team name validation [GH #9517](https://github.com/penpot/penpot/issues/9517) + (PR: [#9176](https://github.com/penpot/penpot/pull/9176)) +- Enhance readability of applied tokens in plugins API [GH #9175](https://github.com/penpot/penpot/issues/9175) + (PR: [#8607](https://github.com/penpot/penpot/pull/8607)) +- Encourage use of flex/grid layouts in designs generated via MCP [GH #9081](https://github.com/penpot/penpot/issues/9081) + (PR: [#9084](https://github.com/penpot/penpot/pull/9084)) +- Improve MCP server logging, adding Loki support [GH #9415](https://github.com/penpot/penpot/issues/9415) + (PR: [#9425](https://github.com/penpot/penpot/pull/9425)) +- Add security headers to Nginx on Docker images [GH #9519](https://github.com/penpot/penpot/issues/9519) + (PR: [#9473](https://github.com/penpot/penpot/pull/9473)) + +### :bug: Bugs fixed + +- Fix text edition mode not exited when changing selection, blocking token application [GH #9346](https://github.com/penpot/penpot/issues/9346) + (PR: [#9355](https://github.com/penpot/penpot/pull/9355)) +- Reduce memory usage of MCP server when handling images (by @opcode81) [GH #9420](https://github.com/penpot/penpot/issues/9420) + (PR: [#9431](https://github.com/penpot/penpot/pull/9431)) +- Fix Plugin API token methods rejecting JS array of strings (by @boskodev790) [GH #9162](https://github.com/penpot/penpot/issues/9162) + (PR: [#9166](https://github.com/penpot/penpot/pull/9166)) +- Fix release notes modal appearing behind the dashboard sidebar (by @RenzoMXD) [GH #8296](https://github.com/penpot/penpot/issues/8296) + (PR: [#9126](https://github.com/penpot/penpot/pull/9126), [#9233](https://github.com/penpot/penpot/pull/9233)) +- Fix empty warning on login [GH #9520](https://github.com/penpot/penpot/issues/9520) + (PR: [#9056](https://github.com/penpot/penpot/pull/9056)) +- Fix maximum call stack size exceeded in SSE read-stream [GH #9470](https://github.com/penpot/penpot/issues/9470) + (PR: [#9484](https://github.com/penpot/penpot/pull/9484)) +- Fix incorrect handling of version restore operation [GH #9515](https://github.com/penpot/penpot/issues/9515) + (PR: [#9041](https://github.com/penpot/penpot/pull/9041)) +- Fix MCP ReplServer binding to all interfaces (0.0.0.0) instead of localhost, allowing unauthenticated RCE [GH #9518](https://github.com/penpot/penpot/issues/9518) + (PR: [#9400](https://github.com/penpot/penpot/pull/9400)) +- Fix MCP integrations URL copy action to match the URL displayed in settings [GH #9238](https://github.com/penpot/penpot/issues/9238) + (PR: [#9239](https://github.com/penpot/penpot/pull/9239)) +- Fix swapped analytics event names on MCP tab-switch dialog (by @Dexterity104) [GH #9496](https://github.com/penpot/penpot/issues/9496) + (PR: [#9322](https://github.com/penpot/penpot/pull/9322)) +- Fix multiple selection on shapes with token applied to stroke color [GH #9522](https://github.com/penpot/penpot/issues/9522) + (PR: [#9110](https://github.com/penpot/penpot/pull/9110)) +- Fix onboarding modals appearing behind libraries and templates panel [GH #9521](https://github.com/penpot/penpot/issues/9521) + (PR: [#9178](https://github.com/penpot/penpot/pull/9178)) +- Fix keep-alive interval leak in PluginBridge (by @opcode81) [GH #9430](https://github.com/penpot/penpot/issues/9430) + (PR: [#9435](https://github.com/penpot/penpot/pull/9435)) + +## 2.14.5 + +### :bug: Bugs fixed + +- Fix incorrect invitation token handling on register process [GH #9380](https://github.com/penpot/penpot/pull/9380) + +## 2.14.4 + +### :bug: Bugs fixed + +- Fix email validation [Taiga #14006](https://tree.taiga.io/project/penpot/issue/14006) +- Fix email blacklisting [GH #9122](https://github.com/penpot/penpot/pull/9122) +- Fix removeChild errors from unmount race conditions [GH #8927](https://github.com/penpot/penpot/pull/8927) + ## 2.14.3 ### :sparkles: New features & Enhancements -- Add webp export format to plugin types [Github #8870](https://github.com/penpot/penpot/pull/8870) -- Use shared singleton containers for React portals to reduce DOM growth [Github #8957](https://github.com/penpot/penpot/pull/8957) +- Add webp export format to plugin types [GH #8870](https://github.com/penpot/penpot/pull/8870) +- Use shared singleton containers for React portals to reduce DOM growth [GH #8957](https://github.com/penpot/penpot/pull/8957) ### :bug: Bugs fixed -- Fix component "broken" after switch variant [Taiga #12984](https://tree.taiga.io/project/penpot/issue/12984) -- Fix variants corner cases with selrect and points [Github #8882](https://github.com/penpot/penpot/pull/8882) +- Fix variants corner cases with selrect and points [GH #8882](https://github.com/penpot/penpot/pull/8882) - Fix dashboard navigation tabs overlap with projects content when scrolling [Taiga #13962](https://tree.taiga.io/project/penpot/issue/13962) - Fix text editor v1 focus [Taiga #13961](https://tree.taiga.io/project/penpot/issue/13961) -- Fix highlight on frames after rename [Github #8938](https://github.com/penpot/penpot/pull/8938) -- Fix TypeError in sd-token-uuid when resolving tokens interactively [Github #8929](https://github.com/penpot/penpot/pull/8929) +- Fix highlight on frames after rename [GH #8938](https://github.com/penpot/penpot/pull/8938) +- Fix TypeError in sd-token-uuid when resolving tokens interactively [GH #8929](https://github.com/penpot/penpot/pull/8929) - Fix path drawing preview passing shape instead of content to next-node - Fix swapped arguments in CLJS PathData `-nth` with default - Normalize PathData coordinates to safe integer bounds on read -- Fix RangeError from re-entrant error handling causing stack overflow [Github #8962](https://github.com/penpot/penpot/pull/8962) -- Fix builder bool styles and media validation [Github #8963](https://github.com/penpot/penpot/pull/8963) +- Fix RangeError from re-entrant error handling causing stack overflow [GH #8962](https://github.com/penpot/penpot/pull/8962) +- Fix builder bool styles and media validation [GH #8963](https://github.com/penpot/penpot/pull/8963) - Fix "Move to" menu allowing same project as target when multiple files are selected - Fix crash when index query param is duplicated in URL - Fix wrong extremity point in path `calculate-extremities` for line-to segments @@ -144,48 +359,48 @@ - Fix wrong `mapcat` call in `collect-main-shapes` - Fix stale accumulator in `get-children-in-instance` recursion - Fix typo `:podition` in swap-shapes grid cell - +- Fix multiple selection on shapes with token applied to stroke color ## 2.14.2 ### :sparkles: New features & Enhancements -- Add protection for stale JS asset cache to force reload on version mismatch [Github #8638](https://github.com/penpot/penpot/pull/8638) -- Normalize newsletter opt-in checkbox across different register flows [Github #8839](https://github.com/penpot/penpot/pull/8839) +- Add protection for stale JS asset cache to force reload on version mismatch [GH #8638](https://github.com/penpot/penpot/pull/8638) +- Normalize newsletter opt-in checkbox across different register flows [GH #8839](https://github.com/penpot/penpot/pull/8839) ### :bug: Bugs fixed - Fix PathData corruption root causes across WASM and CLJS (unsafe transmute and byteOffset handling) - Handle corrupted PathData segments gracefully instead of crashing - Fix swapped move-to/line-to type codes in PathData binary readers -- Fix non-integer row/column values in grid cell position inputs [Github #8869](https://github.com/penpot/penpot/pull/8869) -- Fix nil path content crash by exposing safe public API [Github #8806](https://github.com/penpot/penpot/pull/8806) -- Fix infinite recursion in get-frame-ids for thumbnail extraction [Github #8807](https://github.com/penpot/penpot/pull/8807) +- Fix non-integer row/column values in grid cell position inputs [GH #8869](https://github.com/penpot/penpot/pull/8869) +- Fix nil path content crash by exposing safe public API [GH #8806](https://github.com/penpot/penpot/pull/8806) +- Fix infinite recursion in get-frame-ids for thumbnail extraction [GH #8807](https://github.com/penpot/penpot/pull/8807) - Fix stale-asset detector missing protocol-dispatch errors -- Ignore Zone.js toString TypeError in uncaught error handler [Github #8804](https://github.com/penpot/penpot/pull/8804) -- Prevent thumbnail frame recursion overflow [Github #8763](https://github.com/penpot/penpot/pull/8763) -- Fix vector index out of bounds in viewer zoom-to-fit/fill [Github #8834](https://github.com/penpot/penpot/pull/8834) -- Guard delete undo against missing sibling order [Github #8858](https://github.com/penpot/penpot/pull/8858) -- Fix ICounted error on numeric-input token dropdown keyboard nav [Github #8803](https://github.com/penpot/penpot/pull/8803) +- Ignore Zone.js toString TypeError in uncaught error handler [GH #8804](https://github.com/penpot/penpot/pull/8804) +- Prevent thumbnail frame recursion overflow [GH #8763](https://github.com/penpot/penpot/pull/8763) +- Fix vector index out of bounds in viewer zoom-to-fit/fill [GH #8834](https://github.com/penpot/penpot/pull/8834) +- Guard delete undo against missing sibling order [GH #8858](https://github.com/penpot/penpot/pull/8858) +- Fix ICounted error on numeric-input token dropdown keyboard nav [GH #8803](https://github.com/penpot/penpot/pull/8803) ## 2.14.1 ### :sparkles: New features & Enhancements -- Add automatic retry with backoff for idempotent RPC requests on network failures [Github #8792](https://github.com/penpot/penpot/pull/8792) -- Add scroll and zoom throttling to one state update per animation frame [Github #8812](https://github.com/penpot/penpot/pull/8812) -- Improve error handling and exception formatting [Github #8757](https://github.com/penpot/penpot/pull/8757) +- Add automatic retry with backoff for idempotent RPC requests on network failures [GH #8792](https://github.com/penpot/penpot/pull/8792) +- Add scroll and zoom throttling to one state update per animation frame [GH #8812](https://github.com/penpot/penpot/pull/8812) +- Improve error handling and exception formatting [GH #8757](https://github.com/penpot/penpot/pull/8757) ### :bug: Bugs fixed -- Fix crash in apply-text-modifier with nil selrect or modifier [Github #8762](https://github.com/penpot/penpot/pull/8762) -- Fix incorrect attrs references on generate-sync-shape [Github #8776](https://github.com/penpot/penpot/pull/8776) -- Fix regression on subpath support [Github #8793](https://github.com/penpot/penpot/pull/8793) -- Improve error reporting on request parsing failures [Github #8805](https://github.com/penpot/penpot/pull/8805) -- Fix fetch abort errors escaping the unhandled exception handler [Github #8801](https://github.com/penpot/penpot/pull/8801) -- Fix nil deref on missing bounds in layout modifier propagation [Github #8735](https://github.com/penpot/penpot/pull/8735) -- Fix TypeError when token error map lacks :error/fn key [Github #8767](https://github.com/penpot/penpot/pull/8767) -- Fix dissoc error when detaching stroke color from library [Github #8738](https://github.com/penpot/penpot/pull/8738) +- Fix crash in apply-text-modifier with nil selrect or modifier [GH #8762](https://github.com/penpot/penpot/pull/8762) +- Fix incorrect attrs references on generate-sync-shape [GH #8776](https://github.com/penpot/penpot/pull/8776) +- Fix regression on subpath support [GH #8793](https://github.com/penpot/penpot/pull/8793) +- Improve error reporting on request parsing failures [GH #8805](https://github.com/penpot/penpot/pull/8805) +- Fix fetch abort errors escaping the unhandled exception handler [GH #8801](https://github.com/penpot/penpot/pull/8801) +- Fix nil deref on missing bounds in layout modifier propagation [GH #8735](https://github.com/penpot/penpot/pull/8735) +- Fix TypeError when token error map lacks :error/fn key [GH #8767](https://github.com/penpot/penpot/pull/8767) +- Fix dissoc error when detaching stroke color from library [GH #8738](https://github.com/penpot/penpot/pull/8738) - Fix crash when pasting image into text editor - Fix null text crash on paste in text editor - Ensure path content is always PathData when saving @@ -206,37 +421,31 @@ - Optimize sidebar performance for deeply nested shapes [Taiga #13017](https://tree.taiga.io/project/penpot/task/13017) - Remove tokens path node and bulk remove tokens [Taiga #13007](https://tree.taiga.io/project/penpot/us/13007) - Replace themes management modal radio buttons for switches [Taiga #9215](https://tree.taiga.io/project/penpot/us/9215) -- [MCP server] Integrations section [Taiga #13112](https://tree.taiga.io/project/penpot/us/13112) -- [Access Tokens] Look & feel refinement [Taiga #13114](https://tree.taiga.io/project/penpot/us/13114) ### :bug: Bugs fixed -- Remove whitespaces from asset export filename [Github #8133](https://github.com/penpot/penpot/pull/8133) +- Remove whitespaces from asset export filename [GH #8133](https://github.com/penpot/penpot/pull/8133) - Fix prototype connections lost when switching between variants [Taiga #12812](https://tree.taiga.io/project/penpot/issue/12812) - Fix wrong image in the onboarding invitation block [Taiga #13040](https://tree.taiga.io/project/penpot/issue/13040) - Fix wrong register image [Taiga #12955](https://tree.taiga.io/project/penpot/task/12955) - Fix error message on components doesn't close automatically [Taiga #12012](https://tree.taiga.io/project/penpot/issue/12012) -- Fix incorrect handling of input values on layout gap and padding inputs [Github #8113](https://github.com/penpot/penpot/issues/8113) -- Fix incorrect default option on tokens import dialog [Github #8051](https://github.com/penpot/penpot/pull/8051) -- Fix unhandled exception tokens creation dialog [Github #8110](https://github.com/penpot/penpot/issues/8110) +- Fix incorrect default option on tokens import dialog [GH #8051](https://github.com/penpot/penpot/pull/8051) +- Fix unhandled exception tokens creation dialog [GH #8110](https://github.com/penpot/penpot/issues/8110) - Fix displaying a hidden user avatar when there is only one more [Taiga #13058](https://tree.taiga.io/project/penpot/issue/13058) -- Fix unhandled exception on open-new-window helper [Github #7787](https://github.com/penpot/penpot/issues/7787) -- Fix exception on uploading large fonts [Github #8135](https://github.com/penpot/penpot/pull/8135) +- Fix exception on uploading large fonts [GH #8135](https://github.com/penpot/penpot/pull/8135) - Fix boolean operators in menu for boards [Taiga #13174](https://tree.taiga.io/project/penpot/issue/13174) - Fix viewer can update library [Taiga #13186](https://tree.taiga.io/project/penpot/issue/13186) - Fix remove fill affects different element than selected [Taiga #13128](https://tree.taiga.io/project/penpot/issue/13128) -- Fix unable to finish the create account form using keyboard [Taiga #11333](https://tree.taiga.io/project/penpot/issue/11333) -- Fix 45 rotated board titles rendered incorrectly [Taiga #13306](https://tree.taiga.io/project/penpot/issue/13306) - Fix cannot apply second token after creation while shape is selected [Taiga #13513](https://tree.taiga.io/project/penpot/issue/13513) - Fix error activating a set with invalid shadow token applied [Taiga #13528](https://tree.taiga.io/project/penpot/issue/13528) - Fix component "broken" after variant switch [Taiga #12984](https://tree.taiga.io/project/penpot/issue/12984) -- Fix incorrect query for file versions [Github #8463](https://github.com/penpot/penpot/pull/8463) +- Fix incorrect query for file versions [GH #8463](https://github.com/penpot/penpot/pull/8463) - Fix warning when clicking on number token pills [Taiga #13661](https://tree.taiga.io/project/penpot/issue/13661) -- Fix 'not ISeqable' error when entering float values in layout item and opacity inputs [Github #8569](https://github.com/penpot/penpot/pull/8569) -- Fix crash in select component when options vector is empty [Github #8578](https://github.com/penpot/penpot/pull/8578) +- Fix 'not ISeqable' error when entering float values in layout item and opacity inputs [GH #8569](https://github.com/penpot/penpot/pull/8569) +- Fix crash in select component when options vector is empty [GH #8578](https://github.com/penpot/penpot/pull/8578) - Fix scroll on colorpicker [Taiga #13623](https://tree.taiga.io/project/penpot/issue/13623) -- Fix crash when pasting non-map transit clipboard data [Github #8580](https://github.com/penpot/penpot/pull/8580) -- Fix `penpot.openPage()` plugin API not navigating in the same tab; change default to same-tab navigation and allow passing a UUID string instead of a Page object [Github #8520](https://github.com/penpot/penpot/issues/8520) +- Fix crash when pasting non-map transit clipboard data [GH #8580](https://github.com/penpot/penpot/pull/8580) +- Fix `penpot.openPage()` plugin API not navigating in the same tab; change default to same-tab navigation and allow passing a UUID string instead of a Page object [GH #8520](https://github.com/penpot/penpot/issues/8520) ## 2.13.3 @@ -261,9 +470,7 @@ ### :heart: Community contributions (Thank you!) -- Add 'page' special shapeId to MCP export_shape tool for full-page snapshots [Github #8689](https://github.com/penpot/penpot/issues/8689) - -- Fix mask issues with component swap (by @dfelinto) [Github #7675](https://github.com/penpot/penpot/issues/7675) +- Fix mask issues with component swap (by @dfelinto) [GH #7675](https://github.com/penpot/penpot/issues/7675) ### :sparkles: New features & Enhancements @@ -276,7 +483,7 @@ - Fix problem when drag+duplicate a full grid [Taiga #12565](https://tree.taiga.io/project/penpot/issue/12565) - Fix problem when pasting elements in reverse flex layout [Taiga #12460](https://tree.taiga.io/project/penpot/issue/12460) - Fix wrong board size presets in Android [Taiga #12339](https://tree.taiga.io/project/penpot/issue/12339) -- Fix problem with grid layout components and auto sizing [Github #7797](https://github.com/penpot/penpot/issues/7797) +- Fix problem with grid layout components and auto sizing [GH #7797](https://github.com/penpot/penpot/issues/7797) - Fix some alignments on inspect tab [Taiga #12915](https://tree.taiga.io/project/penpot/issue/12915) - Fix problem with text editor maintaining previous styles [Taiga #12835](https://tree.taiga.io/project/penpot/issue/12835) - Fix color assets from shared libraries not appearing as assets in Selected colors panel [Taiga #12957](https://tree.taiga.io/project/penpot/issue/12957) @@ -285,15 +492,11 @@ - Fix missing text color token from selected shapes in selected colors list [Taiga #12956](https://tree.taiga.io/project/penpot/issue/12956) - Fix dropdown option width in Guides columns dropdown [Taiga #12959](https://tree.taiga.io/project/penpot/issue/12959) - Fix typos on download modal [Taiga #12865](https://tree.taiga.io/project/penpot/issue/12865) -- Fix problem with text editor maintaining previous styles [Taiga #12835](https://tree.taiga.io/project/penpot/issue/12835) -- Fix unhandled exception tokens creation dialog [Github #8110](https://github.com/penpot/penpot/issues/8110) - Fix allow negative spread values on shadow token creation [Taiga #13167](https://tree.taiga.io/project/penpot/issue/13167) - Fix spanish translations on import export token modal [Taiga #13171](https://tree.taiga.io/project/penpot/issue/13171) -- Remove whitespaces from asset export filename [Github #8133](https://github.com/penpot/penpot/pull/8133) -- Fix exception on uploading large fonts [Github #8135](https://github.com/penpot/penpot/pull/8135) -- Fix unhandled exception on open-new-window helper [Github #7787](https://github.com/penpot/penpot/issues/7787) -- Fix incorrect handling of input values on layout gap and padding inputs [Github #8113](https://github.com/penpot/penpot/issues/8113) -- Fix several race conditions on path editor [Github #8187](https://github.com/penpot/penpot/pull/8187) +- Fix unhandled exception on open-new-window helper [GH #7787](https://github.com/penpot/penpot/issues/7787) +- Fix incorrect handling of input values on layout gap and padding inputs [GH #8113](https://github.com/penpot/penpot/issues/8113) +- Fix several race conditions on path editor [GH #8187](https://github.com/penpot/penpot/pull/8187) - Fix app freeze when introducing an error on a very long token name [Taiga #13214](https://tree.taiga.io/project/penpot/issue/13214) - Fix import a file with shadow tokens [Taiga #13229](https://tree.taiga.io/project/penpot/issue/13229) - Fix allow spaces on token description [Taiga #13184](https://tree.taiga.io/project/penpot/issue/13184) @@ -303,9 +506,9 @@ ### :bug: Bugs fixed -- Fix setting a portion of text as bold or underline messes things up [Github #7980](https://github.com/penpot/penpot/issues/7980) +- Fix setting a portion of text as bold or underline messes things up [GH #7980](https://github.com/penpot/penpot/issues/7980) - Fix problem with style in fonts input [Taiga #12935](https://tree.taiga.io/project/penpot/issue/12935) -- Fix problem with path editor and right click [Github #7917](https://github.com/penpot/penpot/issues/7917) +- Fix problem with path editor and right click [GH #7917](https://github.com/penpot/penpot/issues/7917) ## 2.12.0 @@ -365,8 +568,8 @@ example. It's still usable as before, we just removed the example. ### :heart: Community contributions (Thank you!) -- Ensure consistent snap behavior across all zoom levels [Github #7774](https://github.com/penpot/penpot/pull/7774) by [@Tokytome](https://github.com/Tokytome) -- Fix crash in token grid view due to tooltip validation (by @dfelinto) [Github #7887](https://github.com/penpot/penpot/pull/7887) +- Ensure consistent snap behavior across all zoom levels [GH #7774](https://github.com/penpot/penpot/pull/7774) by [@Tokytome](https://github.com/Tokytome) +- Fix crash in token grid view due to tooltip validation (by @dfelinto) [GH #7887](https://github.com/penpot/penpot/pull/7887) - Enable Hindi translations on the application ### :sparkles: New features & Enhancements @@ -375,7 +578,7 @@ example. It's still usable as before, we just removed the example. - Add toggle for switching boolean property values [Taiga #12341](https://tree.taiga.io/project/penpot/us/12341) - Make the file export process more reliable [Taiga #12555](https://tree.taiga.io/project/penpot/us/12555) - Add auth flow changes [Taiga #12333](https://tree.taiga.io/project/penpot/us/12333) -- Add new shape validation mechanism for shapes [Github #7696](https://github.com/penpot/penpot/pull/7696) +- Add new shape validation mechanism for shapes [GH #7696](https://github.com/penpot/penpot/pull/7696) - Apply color tokens from sidebar [Taiga #11353](https://tree.taiga.io/project/penpot/us/11353) - Display tokens in the inspect tab [Taiga #9313](https://tree.taiga.io/project/penpot/us/9313) - Refactor clipboard behavior to assess some minor inconsistencies and make pasting binary data faster. [Taiga #12571](https://tree.taiga.io/project/penpot/task/12571) @@ -383,12 +586,10 @@ example. It's still usable as before, we just removed the example. ### :bug: Bugs fixed - Fix text line-height values are wrong [Taiga #12252](https://tree.taiga.io/project/penpot/issue/12252) -- Fix an error translation [Taiga #12402](https://tree.taiga.io/project/penpot/issue/12402) -- Fix pan cursor not disabling viewport guides [Github #6985](https://github.com/penpot/penpot/issues/6985) +- Fix pan cursor not disabling viewport guides [GH #6985](https://github.com/penpot/penpot/issues/6985) - Fix viewport resize on locked shapes [Taiga #11974](https://tree.taiga.io/project/penpot/issue/11974) -- Fix nested variant in a component doesn't keep inherited overrides [Taiga #12299](https://tree.taiga.io/project/penpot/issue/12299) - Fix on copy instance inside a components chain touched are missing [Taiga #12371](https://tree.taiga.io/project/penpot/issue/12371) -- Fix problem with multiple selection and shadows [Github #7437](https://github.com/penpot/penpot/issues/7437) +- Fix problem with multiple selection and shadows [GH #7437](https://github.com/penpot/penpot/issues/7437) - Fix search shortcut [Taiga #10265](https://tree.taiga.io/project/penpot/issue/10265) - Fix shortcut conflict in text editor (increase/decrease font size vs word selection) - Fix problem with plugins generating code for pages different than current one [Taiga #12312](https://tree.taiga.io/project/penpot/issue/12312) @@ -401,7 +602,7 @@ example. It's still usable as before, we just removed the example. - Fix switch variants with paths [Taiga #12841](https://tree.taiga.io/project/penpot/issue/12841) - Fix referencing typography tokens on font-family tokens [Taiga #12492](https://tree.taiga.io/project/penpot/issue/12492) - Fix horizontal scroll on layer panel [Taiga #12843](https://tree.taiga.io/project/penpot/issue/12843) -- Fix unicode handling on email template abbreviation filter [Github #7966](https://github.com/penpot/penpot/pull/7966) +- Fix unicode handling on email template abbreviation filter [GH #7966](https://github.com/penpot/penpot/pull/7966) ## 2.11.1 @@ -442,15 +643,15 @@ example. It's still usable as before, we just removed the example. - Invitations management improvements [Taiga #3479](https://tree.taiga.io/project/penpot/us/3479) - Alternative ways of creating variants - Button Viewport [Taiga #11931](https://tree.taiga.io/project/penpot/us/11931) - Reorder properties for a component [Taiga #10225](https://tree.taiga.io/project/penpot/us/10225) -- File Data storage layout refactor [Github #7345](https://github.com/penpot/penpot/pull/7345) -- Make several queries optimization on comment threads [Github #7506](https://github.com/penpot/penpot/pull/7506) +- File Data storage layout refactor [GH #7345](https://github.com/penpot/penpot/pull/7345) +- Make several queries optimization on comment threads [GH #7506](https://github.com/penpot/penpot/pull/7506) ### :bug: Bugs fixed - Fix selection problems when devtools open [Taiga #11950](https://tree.taiga.io/project/penpot/issue/11950) - Fix long font names overlap [Taiga #11844](https://tree.taiga.io/project/penpot/issue/11844) - Fix paste behavior according to the selected element [Taiga #11979](https://tree.taiga.io/project/penpot/issue/11979) -- Fix problem with export size [Github #7160](https://github.com/penpot/penpot/issues/7160) +- Fix problem with export size [GH #7160](https://github.com/penpot/penpot/issues/7160) - Fix multi level library dependencies [Taiga #12155](https://tree.taiga.io/project/penpot/issue/12155) - Fix component context menu options order in assets tab [Taiga #11941](https://tree.taiga.io/project/penpot/issue/11941) - Fix error updating library [Taiga #12218](https://tree.taiga.io/project/penpot/issue/12218) @@ -467,7 +668,7 @@ example. It's still usable as before, we just removed the example. - Fix text override is lost after switch [Taiga #12269](https://tree.taiga.io/project/penpot/issue/12269) - Fix exporting a board crashing the app [Taiga #12384](https://tree.taiga.io/project/penpot/issue/12384) - Fix nested variant in a component doesn't keep inherited overrides [Taiga #12299](https://tree.taiga.io/project/penpot/issue/12299) -- Fix selected colors not showing colors from children shapes in multiple selection [Taiga #12384](https://tree.taiga.io/project/penpot/issue/12385) +- Fix selected colors not showing colors from children shapes in multiple selection [Taiga #12385](https://tree.taiga.io/project/penpot/issue/12385) - Fix scrollbar issue in design tab [Taiga #12367](https://tree.taiga.io/project/penpot/issue/12367) - Fix library update notificacions showing when they should not [Taiga #12397](https://tree.taiga.io/project/penpot/issue/12397) - Fix remove flex button doesn’t work within variant [Taiga #12314](https://tree.taiga.io/project/penpot/issue/12314) @@ -478,8 +679,8 @@ example. It's still usable as before, we just removed the example. - Fix options button does not work for comments created in the lower part of the screen [Taiga #12422](https://tree.taiga.io/project/penpot/issue/12422) - Fix problem when checking usage with removed teams [Taiga #12442](https://tree.taiga.io/project/penpot/issue/12442) - Fix focus mode persisting across page/file navigation [Taiga #12469](https://tree.taiga.io/project/penpot/issue/12469) -- Fix shadow color validation [Github #7705](https://github.com/penpot/penpot/pull/7705) -- Fix exception on selection blend-mode using keyboard [Github #7710](https://github.com/penpot/penpot/pull/7710) +- Fix shadow color validation [GH #7705](https://github.com/penpot/penpot/pull/7705) +- Fix exception on selection blend-mode using keyboard [GH #7710](https://github.com/penpot/penpot/pull/7710) - Fix crash when using decimal (floating-point) values for X/Y or width/height [Taiga #12543](https://tree.taiga.io/project/penpot/issue/12543) ## 2.10.1 @@ -504,7 +705,7 @@ example. It's still usable as before, we just removed the example. ### :sparkles: New features & Enhancements -- Add efficiency enhancements to right sidebar [Github #7182](https://github.com/penpot/penpot/pull/7182) +- Add efficiency enhancements to right sidebar [GH #7182](https://github.com/penpot/penpot/pull/7182) - Add defaults for artboard drawing [Taiga #494](https://tree.taiga.io/project/penpot/us/494?milestone=465047) - Continuous display of distances between elements when moving a layer with the keyboard [Taiga #1780](https://tree.taiga.io/project/penpot/us/1780) - New Number token - unitless values [Taiga #10936](https://tree.taiga.io/project/penpot/us/10936) @@ -513,8 +714,8 @@ example. It's still usable as before, we just removed the example. - New text-decoration token [Taiga #10941](https://tree.taiga.io/project/penpot/us/10941) - New letter spacing token [Taiga #10940](https://tree.taiga.io/project/penpot/us/10940) - New font weight token [Taiga #10939](https://tree.taiga.io/project/penpot/us/10939) -- Upgrade Node to v22.18.0 [Github #7283](https://github.com/penpot/penpot/pull/7283) -- Upgrade the base docker image for penpot frontend to v1.29.1 [Github #7283](https://github.com/penpot/penpot/pull/7283) +- Upgrade Node to v22.18.0 [GH #7283](https://github.com/penpot/penpot/pull/7283) +- Upgrade the base docker image for penpot frontend to v1.29.1 [GH #7283](https://github.com/penpot/penpot/pull/7283) - Create variant from an existing component [Taiga #2088](https://tree.taiga.io/project/penpot/us/2088) - Create variant from an existing variant [Taiga #8282](https://tree.taiga.io/project/penpot/us/8282) - Actions over a component with variants [Taiga #10503](https://tree.taiga.io/project/penpot/us/10503) @@ -583,7 +784,7 @@ example. It's still usable as before, we just removed the example. - Hide bounding box while editing visual effects [Taiga #11576](https://tree.taiga.io/project/penpot/issue/11576) - Improved text layer resizing: Allow double-click on text bounding box to set auto-width/auto-height [Taiga #11577](https://tree.taiga.io/project/penpot/issue/11577) - Improve text layer auto-resize: auto-width switches to auto-height on horizontal resize, and only switches to fixed on vertical resize [Taiga #11578](https://tree.taiga.io/project/penpot/issue/11578) -- Add the ability to show login dialog on profile settings [Github #6871](https://github.com/penpot/penpot/pull/6871) +- Add the ability to show login dialog on profile settings [GH #6871](https://github.com/penpot/penpot/pull/6871) - Improve the application of tokens with object specific tokens [Taiga #10209](https://tree.taiga.io/project/penpot/us/10209) - Add info to apply-token event [Taiga #11710](https://tree.taiga.io/project/penpot/task/11710) - Fix double click on set name input [Taiga #11747](https://tree.taiga.io/project/penpot/issue/11747) @@ -623,7 +824,7 @@ example. It's still usable as before, we just removed the example. ### :bug: Bugs fixed -- Fix unexpected exception on processing old texts [Github #6889](https://github.com/penpot/penpot/pull/6889) +- Fix unexpected exception on processing old texts [GH #6889](https://github.com/penpot/penpot/pull/6889) - Fix error on inspect tab when selecting multiple shapes [Taiga #11655](https://tree.taiga.io/project/penpot/issue/11655) - Fix missing package for the penport_exporter Docker image [GitHub #7205](https://github.com/penpot/penpot/issues/7025) @@ -656,13 +857,13 @@ on-premises instances** that want to keep up to date. - Rewrite path shape data PathData encoding [Taiga #8542](https://tree.taiga.io/project/penpot/us/8542?milestone=441308) - Update base image for Docker Backend and Exporter to Ubuntu 24.04 - Update base image for Docker Frontend to Nginx 1.28.0 -- Allow multi file token import [Github #27](https://github.com/tokens-studio/penpot/issues/27) +- Allow multi file token import [GH #27](https://github.com/tokens-studio/penpot/issues/27) - Create `input*` wrapper component, and `label*`, `input-field*` and `hint-message*` components [Taiga #10713](https://tree.taiga.io/project/penpot/us/10713) -- Deselect layers (and path nodes) with Ctrl+Shift+Drag [Github #2509](https://github.com/penpot/penpot/issues/2509) -- Copy to SVG from contextual menu [Github #838](https://github.com/penpot/penpot/issues/838) +- Deselect layers (and path nodes) with Ctrl+Shift+Drag [GH #2509](https://github.com/penpot/penpot/issues/2509) +- Copy to SVG from contextual menu [GH #838](https://github.com/penpot/penpot/issues/838) - Add styles for Inkeep Chat at workspace [Taiga #10708](https://tree.taiga.io/project/penpot/us/10708) - Add configuration for air gapped installations with Docker -- Support system color scheme [Github #5030](https://github.com/penpot/penpot/issues/5030) +- Support system color scheme [GH #5030](https://github.com/penpot/penpot/issues/5030) - Persist ruler visibility across files and reloads [GitHub #4586](https://github.com/penpot/penpot/issues/4586) - Update google fonts (at 2025/05/19) [Taiga 10792](https://tree.taiga.io/project/penpot/us/10792) - Add tooltip component to DS [Taiga 9220](https://tree.taiga.io/project/penpot/us/9220) @@ -673,7 +874,7 @@ on-premises instances** that want to keep up to date. - Fix getCurrentUser for plugins api [Taiga #11057](https://tree.taiga.io/project/penpot/issue/11057) - Fix spacing / sizes of different elements in the measurements section of the design tab [Taiga #11076](https://tree.taiga.io/project/penpot/issue/11076) -- Fix selection of short paths [Github #4472](https://github.com/penpot/penpot/issues/4472) +- Fix selection of short paths [GH #4472](https://github.com/penpot/penpot/issues/4472) - Fix element positioning on the right side to adjust to grid [#11073](https://tree.taiga.io/project/penpot/issue/11073) - Fix palette is over sidebar [#11160](https://tree.taiga.io/project/penpot/issue/11160) - Fix font size input not displaying "mixed" when multiple texts are selected [Taiga #11177](https://tree.taiga.io/project/penpot/issue/11177) @@ -691,15 +892,15 @@ on-premises instances** that want to keep up to date. - Fix entering long project name [Taiga #11417](https://tree.taiga.io/project/penpot/issue/11417) - Fix slow color picker [Taiga #11019](https://tree.taiga.io/project/penpot/issue/11019) - Fix tooltip position after click [Taiga #11405](https://tree.taiga.io/project/penpot/issue/11405) -- Fix incorrect media translation on paste text with fill images [Github #6845](https://github.com/penpot/penpot/pull/6845) +- Fix incorrect media translation on paste text with fill images [GH #6845](https://github.com/penpot/penpot/pull/6845) ## 2.7.2 ### :bug: Bugs fixed -- Update plugins runtime [Github #6604](https://github.com/penpot/penpot/pull/6604) +- Update plugins runtime [GH #6604](https://github.com/penpot/penpot/pull/6604) - Backport from develop a minor fix that enables import of files - generated by penpot library [Github #6614](https://github.com/penpot/penpot/pull/6614) + generated by penpot library [GH #6614](https://github.com/penpot/penpot/pull/6614) - Fix copy in error message [GitHub #6615](https://github.com/penpot/penpot/pull/6615) - Fix url on invitation link [Taiga #11284](https://tree.taiga.io/project/penpot/issue/11284) @@ -750,13 +951,13 @@ on-premises instances** that want to keep up to date. - Fix team info settings alignment [Taiga #10869](https://tree.taiga.io/project/penpot/issue/10869) - Fix left sidebar horizontal scroll on nested layers [Taiga #10791](https://tree.taiga.io/project/penpot/issue/10791) - Improve error message details importing tokens [Taiga Issue #10772](https://tree.taiga.io/project/penpot/issue/10772) -- Fix no selected set after Drag & Drop [Github #71](https://github.com/tokens-studio/penpot/issues/71) -- Styledictionary v5 Update [Github #6283](https://github.com/penpot/penpot/pull/6283) -- Fix Rename a set throws an internal error [Github #78](https://github.com/tokens-studio/penpot/issues/78) -- Fix Out of Sync Token Value & Color Picker [Github #102](https://github.com/tokens-studio/penpot/issues/102) -- Fix Color should preserve color space [Github #69](https://github.com/tokens-studio/penpot/issues/69) +- Fix no selected set after Drag & Drop [GH #71](https://github.com/tokens-studio/penpot/issues/71) +- Styledictionary v5 Update [GH #6283](https://github.com/penpot/penpot/pull/6283) +- Fix Rename a set throws an internal error [GH #78](https://github.com/tokens-studio/penpot/issues/78) +- Fix Out of Sync Token Value & Color Picker [GH #102](https://github.com/tokens-studio/penpot/issues/102) +- Fix Color should preserve color space [GH #69](https://github.com/tokens-studio/penpot/issues/69) - Fix cannot rename Design Token Sets when group of same name exists [Taiga Issue #10773](https://tree.taiga.io/project/penpot/issue/10773) -- Fix problem when duplicating grid layout [Github #6391](https://github.com/penpot/penpot/issues/6391) +- Fix problem when duplicating grid layout [GH #6391](https://github.com/penpot/penpot/issues/6391) - Fix issue that makes workspace shortcuts stop working [Taiga #11062](https://tree.taiga.io/project/penpot/issue/11062) - Fix problem while syncing library colors and typographies [Taiga #11068](https://tree.taiga.io/project/penpot/issue/11068) - Fix problem with path edition of shapes [Taiga #9496](https://tree.taiga.io/project/penpot/issue/9496) @@ -780,7 +981,7 @@ on-premises instances** that want to keep up to date. - Fix unexpected exception on template import from libraries - Fix incorrect uuid parsing from different parts of code - Fix update layout on component restore [Taiga #10637](https://tree.taiga.io/project/penpot/issue/10637) -- Fix horizontal scroll in viewer [Github #6290](https://github.com/penpot/penpot/issues/6290) +- Fix horizontal scroll in viewer [GH #6290](https://github.com/penpot/penpot/issues/6290) - Fix detach component in a particular case [Taiga #10837](https://tree.taiga.io/project/penpot/issue/10837) ## 2.6.1 @@ -820,7 +1021,7 @@ on-premises instances** that want to keep up to date. ### :bug: Bugs fixed -- Fix opacity in frame containers [Github #5858](https://github.com/penpot/penpot/pull/5858) +- Fix opacity in frame containers [GH #5858](https://github.com/penpot/penpot/pull/5858) - Avoid resizing on click [Taiga #10213](https://tree.taiga.io/project/penpot/issue/10213) - Hide horizontal scroll from dashboard sidebar [Taiga #10422](https://tree.taiga.io/project/penpot/issue/10422) - Fix cut and paste a copy a cmponent inside its parent [Taiga #10365](https://tree.taiga.io/project/penpot/us/10365) @@ -845,7 +1046,7 @@ on-premises instances** that want to keep up to date. ### :heart: Community contributions (Thank you!) -- Add support for WEBP format on shape export [Github #6053](https://github.com/penpot/penpot/pull/6053) and [Github #6074](https://github.com/penpot/penpot/pull/6074) +- Add support for WEBP format on shape export [GH #6053](https://github.com/penpot/penpot/pull/6053) and [GH #6074](https://github.com/penpot/penpot/pull/6074) ### :bug: Bugs fixed @@ -1068,20 +1269,20 @@ is a number of cores) - Fix problem with go back button on error page [Taiga #8887](https://tree.taiga.io/project/penpot/issue/8887) - Fix problem with shadows in text for Safari [Taiga #8770](https://tree.taiga.io/project/penpot/issue/8770) - Fix a regression with feedback form subject and content limits [Taiga #8908](https://tree.taiga.io/project/penpot/issue/8908) -- Fix problem with stroke and filter ordering in frames [Github #5058](https://github.com/penpot/penpot/issues/5058) -- Fix problem with hover layers when hidden/blocked [Github #5074](https://github.com/penpot/penpot/issues/5074) +- Fix problem with stroke and filter ordering in frames [GH #5058](https://github.com/penpot/penpot/issues/5058) +- Fix problem with hover layers when hidden/blocked [GH #5074](https://github.com/penpot/penpot/issues/5074) - Fix problem with precision on boolean calculation [Taiga #8482](https://tree.taiga.io/project/penpot/issue/8482) -- Fix problem when translating multiple path points [Github #4459](https://github.com/penpot/penpot/issues/4459) +- Fix problem when translating multiple path points [GH #4459](https://github.com/penpot/penpot/issues/4459) - Fix problem on importing (and exporting) files with flows [Taiga #8914](https://tree.taiga.io/project/penpot/issue/8914) - Fix Internal Error page: "go to your penpot" wrong design [Taiga #8922](https://tree.taiga.io/project/penpot/issue/8922) -- Fix problem updating layout when toggle visibility in component copy [Github #5143](https://github.com/penpot/penpot/issues/5143) +- Fix problem updating layout when toggle visibility in component copy [GH #5143](https://github.com/penpot/penpot/issues/5143) - Fix "Done" button on toolbar on inspect mode should go to design mode [Taiga #8933](https://tree.taiga.io/project/penpot/issue/8933) -- Fix problem with shortcuts in text editor [Github #5078](https://github.com/penpot/penpot/issues/5078) -- Fix problems with show in viewer and interactions [Github #4868](https://github.com/penpot/penpot/issues/4868) -- Add visual feedback when moving an element into a board [Github #3210](https://github.com/penpot/penpot/issues/3210) -- Fix percent calculation on grid layout tracks [Github #4688](https://github.com/penpot/penpot/issues/4688) -- Fix problem with caps and inner shadows [Github #4517](https://github.com/penpot/penpot/issues/4517) -- Fix problem with horizontal/vertical lines and shadows [Github #4516](https://github.com/penpot/penpot/issues/4516) +- Fix problem with shortcuts in text editor [GH #5078](https://github.com/penpot/penpot/issues/5078) +- Fix problems with show in viewer and interactions [GH #4868](https://github.com/penpot/penpot/issues/4868) +- Add visual feedback when moving an element into a board [GH #3210](https://github.com/penpot/penpot/issues/3210) +- Fix percent calculation on grid layout tracks [GH #4688](https://github.com/penpot/penpot/issues/4688) +- Fix problem with caps and inner shadows [GH #4517](https://github.com/penpot/penpot/issues/4517) +- Fix problem with horizontal/vertical lines and shadows [GH #4516](https://github.com/penpot/penpot/issues/4516) - Fix problem with layers overflowing panel [Taiga #9021](https://tree.taiga.io/project/penpot/issue/9021) - Fix in workspace you can manage rulers on view mode [Taiga #8966](https://tree.taiga.io/project/penpot/issue/8966) - Fix problem with swap components in grid layout [Taiga #9066](https://tree.taiga.io/project/penpot/issue/9066) @@ -1174,10 +1375,10 @@ is a number of cores) - Fix fill collapsed options [Taiga #8351](https://tree.taiga.io/project/penpot/issue/8351) - Fix scroll on color picker modal [Taiga #8353](https://tree.taiga.io/project/penpot/issue/8353) - Fix components are not dragged from the group to the assets tab [Taiga #8273](https://tree.taiga.io/project/penpot/issue/8273) -- Fix problem with SVG import [Github #4888](https://github.com/penpot/penpot/issues/4888) +- Fix problem with SVG import [GH #4888](https://github.com/penpot/penpot/issues/4888) - Fix problem with overlay positions in viewer [Taiga #8464](https://tree.taiga.io/project/penpot/issue/8464) - Fix layer panel overflowing [Taiga #8665](https://tree.taiga.io/project/penpot/issue/8665) -- Fix problem when creating a component instance from grid layout [Github #4881](https://github.com/penpot/penpot/issues/4881) +- Fix problem when creating a component instance from grid layout [GH #4881](https://github.com/penpot/penpot/issues/4881) - Fix problem when dismissing shared library update [Taiga #8669](https://tree.taiga.io/project/penpot/issue/8669) - Fix visual problem with stroke cap menu [Taiga #8730](https://tree.taiga.io/project/penpot/issue/8730) - Fix issue when exporting libraries when merging libraries [Taiga #8758](https://tree.taiga.io/project/penpot/issue/8758) @@ -1202,15 +1403,15 @@ is a number of cores) ## 2.1.3 -- Don't allow registration when registration is disabled and invitation token is used [Github #4975](https://github.com/penpot/penpot/issues/4975) +- Don't allow registration when registration is disabled and invitation token is used [GH #4975](https://github.com/penpot/penpot/issues/4975) ## 2.1.2 ### :bug: Bugs fixed -- User switch language to "zh_hant" will get 400 [Github #4884](https://github.com/penpot/penpot/issues/4884) -- Smtp config ignoring port if ssl is set [Github #4872](https://github.com/penpot/penpot/issues/4872) -- Ability to let users to authenticate with a private oidc provider only [Github #4963](https://github.com/penpot/penpot/issues/4963) +- User switch language to "zh_hant" will get 400 [GH #4884](https://github.com/penpot/penpot/issues/4884) +- Smtp config ignoring port if ssl is set [GH #4872](https://github.com/penpot/penpot/issues/4872) +- Ability to let users to authenticate with a private oidc provider only [GH #4963](https://github.com/penpot/penpot/issues/4963) ## 2.1.1 @@ -1253,7 +1454,7 @@ is a number of cores) - Layout and scrollign fixes for the bottom palette [Taiga #7559](https://tree.taiga.io/project/penpot/issue/7559) - Fix expand libraries when search results are present [Taiga #7876](https://tree.taiga.io/project/penpot/issue/7876) - Fix color palette default library [Taiga #8029](https://tree.taiga.io/project/penpot/issue/8029) -- Component Library is lost after exporting/importing in .zip format [Github #4672](https://github.com/penpot/penpot/issues/4672) +- Component Library is lost after exporting/importing in .zip format [GH #4672](https://github.com/penpot/penpot/issues/4672) - Fix problem with moving+selection not working properly [Taiga #7943](https://tree.taiga.io/project/penpot/issue/7943) - Fix problem with flex layout fit to content not positioning correctly children [Taiga #7537](https://tree.taiga.io/project/penpot/issue/7537) - Fix black line is displaying after show main [Taiga #7653](https://tree.taiga.io/project/penpot/issue/7653) @@ -1286,7 +1487,7 @@ is a number of cores) ### :bug: Bugs fixed - Fix chrome scrollbar styling [Taiga #7852](https://tree.taiga.io/project/penpot/issue/7852) -- Fix incorrect password encoding on create-profile manage scritp [Github #3651](https://github.com/penpot/penpot/issues/3651) +- Fix incorrect password encoding on create-profile manage scritp [GH #3651](https://github.com/penpot/penpot/issues/3651) ## 2.0.2 @@ -1304,7 +1505,7 @@ is a number of cores) ### :bug: Bugs fixed -- Fix different issues related to components v2 migrations including [Github #4443](https://github.com/penpot/penpot/issues/4443) +- Fix different issues related to components v2 migrations including [GH #4443](https://github.com/penpot/penpot/issues/4443) ## 2.0.0 - I Just Can't Get Enough @@ -1403,9 +1604,9 @@ is a number of cores) ### :bug: Bugs fixed -- Fix pixelated thumbnails [Github #3681](https://github.com/penpot/penpot/issues/3681), [Github #3661](https://github.com/penpot/penpot/issues/3661) -- Fix problem with not applying colors to boards [Github #3941](https://github.com/penpot/penpot/issues/3941) -- Fix problem with path editor undoing changes [Github #3998](https://github.com/penpot/penpot/issues/3998) +- Fix pixelated thumbnails [GH #3681](https://github.com/penpot/penpot/issues/3681), [GH #3661](https://github.com/penpot/penpot/issues/3661) +- Fix problem with not applying colors to boards [GH #3941](https://github.com/penpot/penpot/issues/3941) +- Fix problem with path editor undoing changes [GH #3998](https://github.com/penpot/penpot/issues/3998) - [View mode] Open overlay places frame in the wrong position when paired with a fixed element [Taiga #6385](https://tree.taiga.io/project/penpot/issue/6385) - Flex Layout: Fit-content not recalculated after deleting an element [Taiga #5968](https://tree.taiga.io/project/penpot/issue/5968) - Selecting from Color Palette does not work for board when there is no existing fill [Taiga #6464](https://tree.taiga.io/project/penpot/issue/6464) @@ -1436,11 +1637,11 @@ is a number of cores) - [VIEWER] Cannot scroll down in code </> mode [Taiga #4655](https://tree.taiga.io/project/penpot/issue/4655) - Strange cursor behavior after clicking viewport with text tool [Taiga #4363](https://tree.taiga.io/project/penpot/issue/4363) - Selected color affects all of them [Taiga #5285](https://tree.taiga.io/project/penpot/issue/5285) -- Fix problem with shadow negative spread [Github #3421](https://github.com/penpot/penpot/issues/3421) -- Fix problem with linked colors to strokes [Github #3522](https://github.com/penpot/penpot/issues/3522) -- Fix problem with hand tool stuck [Github #3318](https://github.com/penpot/penpot/issues/3318) -- Fix problem with fix scrolling on nested elements [Github #3508](https://github.com/penpot/penpot/issues/3508) -- Fix problem when changing typography assets [Github #3683](https://github.com/penpot/penpot/issues/3683) +- Fix problem with shadow negative spread [GH #3421](https://github.com/penpot/penpot/issues/3421) +- Fix problem with linked colors to strokes [GH #3522](https://github.com/penpot/penpot/issues/3522) +- Fix problem with hand tool stuck [GH #3318](https://github.com/penpot/penpot/issues/3318) +- Fix problem with fix scrolling on nested elements [GH #3508](https://github.com/penpot/penpot/issues/3508) +- Fix problem when changing typography assets [GH #3683](https://github.com/penpot/penpot/issues/3683) - Internal error when you copy and paste some main components between files [Taiga #7397](https://tree.taiga.io/project/penpot/issue/7397) - Fix toolbar disappearing [Taiga #7411](https://tree.taiga.io/project/penpot/issue/7411) - Fix long text on tab breaks UI [Taiga #7421](https://tree.taiga.io/project/penpot/issue/7421) @@ -1466,12 +1667,12 @@ is a number of cores) ### :sparkles: New features - Remember last color mode in colorpicker [Taiga #5508](https://tree.taiga.io/project/penpot/issue/5508) -- Improve layers multiselection behaviour [Github #5741](https://github.com/penpot/penpot/issues/5741) -- Remember last active team across logouts / sessions [Github #3325](https://github.com/penpot/penpot/issues/3325) +- Improve layers multiselection behaviour [GH #5741](https://github.com/penpot/penpot/issues/5741) +- Remember last active team across logouts / sessions [GH #3325](https://github.com/penpot/penpot/issues/3325) ### :bug: Bugs fixed -- List view is discarded on tab change on Workspace Assets Sidebar tab [Github #3547](https://github.com/penpot/penpot/issues/3547) +- List view is discarded on tab change on Workspace Assets Sidebar tab [GH #3547](https://github.com/penpot/penpot/issues/3547) - Fix message popup remains open when exiting workspace with browser back button [Taiga #5747](https://tree.taiga.io/project/penpot/issue/5747) - When editing text if font is changed, the proportions of the rendered shape are wrong [Taiga #5786](https://tree.taiga.io/project/penpot/issue/5786) @@ -1485,7 +1686,7 @@ is a number of cores) ### :bug: Bugs fixed -- Fix unexpected output on get-page rpc method when invalid object-id is provided [Github #3546](https://github.com/penpot/penpot/issues/3546) +- Fix unexpected output on get-page rpc method when invalid object-id is provided [GH #3546](https://github.com/penpot/penpot/issues/3546) - Fix Invalid files amount after moving file from Project to Drafts [Taiga #5638](https://tree.taiga.io/project/penpot/us/5638) - Fix deleted pages comments shown in right sidebar [Taiga #5648](https://tree.taiga.io/project/penpot/us/5648) - Fix tooltip on toggle visibility and toggle lock buttons [Taiga #5141](https://tree.taiga.io/project/penpot/issue/5141) @@ -1511,7 +1712,7 @@ is a number of cores) rendered as bitmap images. - Add the ability to disable google fonts provider with the `disable-google-fonts-provider` flag - Add the ability to disable dashboard templates section with the `disable-dashboard-templates-section` flag -- Add the ability to use the registration whitelist with OICD [Github #3348](https://github.com/penpot/penpot/issues/3348) +- Add the ability to use the registration whitelist with OICD [GH #3348](https://github.com/penpot/penpot/issues/3348) - Add support for local caching of google fonts (this avoids exposing the final user IP to goolge and reduces the amount of request sent to google) - Set smooth/instant autoscroll depending on distance [GitHub #3377](https://github.com/penpot/penpot/issues/3377) @@ -1605,20 +1806,20 @@ is a number of cores) ### :heart: Community contributions by (Thank you!) -- Update Typography palette order (by @akshay-gupta7) [Github #3156](https://github.com/penpot/penpot/pull/3156) -- Palettes (color, typographies) empty state (by @akshay-gupta7) [Github #3160](https://github.com/penpot/penpot/pull/3160) -- Duplicate objects via drag + alt (by @akshay-gupta7) [Github #3147](https://github.com/penpot/penpot/pull/3147) -- Set line-height to auto as 1.2 (by @akshay-gupta7) [Github #3185](https://github.com/penpot/penpot/pull/3185) -- Click to select full values at the design sidebar (by @akshay-gupta7) [Github #3179](https://github.com/penpot/penpot/pull/3179) -- Fix rect filter bounds math (by @ryanbreen) [Github #3180](https://github.com/penpot/penpot/pull/3180) -- Removed sizing variables from radius (by @ondrejkonec) [Github #3184](https://github.com/penpot/penpot/pull/3184) -- Dashboard search, set focus after shortcut (by @akshay-gupta7) [Github #3196](https://github.com/penpot/penpot/pull/3196) +- Update Typography palette order (by @akshay-gupta7) [GH #3156](https://github.com/penpot/penpot/pull/3156) +- Palettes (color, typographies) empty state (by @akshay-gupta7) [GH #3160](https://github.com/penpot/penpot/pull/3160) +- Duplicate objects via drag + alt (by @akshay-gupta7) [GH #3147](https://github.com/penpot/penpot/pull/3147) +- Set line-height to auto as 1.2 (by @akshay-gupta7) [GH #3185](https://github.com/penpot/penpot/pull/3185) +- Click to select full values at the design sidebar (by @akshay-gupta7) [GH #3179](https://github.com/penpot/penpot/pull/3179) +- Fix rect filter bounds math (by @ryanbreen) [GH #3180](https://github.com/penpot/penpot/pull/3180) +- Removed sizing variables from radius (by @ondrejkonec) [GH #3184](https://github.com/penpot/penpot/pull/3184) +- Dashboard search, set focus after shortcut (by @akshay-gupta7) [GH #3196](https://github.com/penpot/penpot/pull/3196) - Library name dropdown arrow is overlapped by library name (by @ondrejkonec) [Taiga #5200](https://tree.taiga.io/project/penpot/issue/5200) -- Reorder shadows (by @akshay-gupta7) [Github #3236](https://github.com/penpot/penpot/pull/3236) -- Open project in new tab from workspace (by @akshay-gupta7) [Github #3246](https://github.com/penpot/penpot/pull/3246) -- Distribute fix enabled when two elements were selected (by @dfelinto) [Github #3266](https://github.com/penpot/penpot/pull/3266) -- Distribute vertical spacing failing for overlapped text (by @dfelinto) [Github #3267](https://github.com/penpot/penpot/pull/3267) -- bug Change independent corner radius input tooltips #3332 (by @astudentinearth) [Github #3332](https://github.com/penpot/penpot/pull/3332) +- Reorder shadows (by @akshay-gupta7) [GH #3236](https://github.com/penpot/penpot/pull/3236) +- Open project in new tab from workspace (by @akshay-gupta7) [GH #3246](https://github.com/penpot/penpot/pull/3246) +- Distribute fix enabled when two elements were selected (by @dfelinto) [GH #3266](https://github.com/penpot/penpot/pull/3266) +- Distribute vertical spacing failing for overlapped text (by @dfelinto) [GH #3267](https://github.com/penpot/penpot/pull/3267) +- bug Change independent corner radius input tooltips #3332 (by @astudentinearth) [GH #3332](https://github.com/penpot/penpot/pull/3332) ## 1.18.6 @@ -1648,7 +1849,7 @@ is a number of cores) - Fix problem with layout not reflowing on shape deletion [Taiga #5289](https://tree.taiga.io/project/penpot/issue/5289) - Fix extra long typography names on assets and palette [Taiga #5199](https://tree.taiga.io/project/penpot/issue/5199) - Fix background-color property on inspect code [Taiga #5300](https://tree.taiga.io/project/penpot/issue/5300) -- Preview layer blend modes (by @akshay-gupta7) [Github #3235](https://github.com/penpot/penpot/pull/3235) +- Preview layer blend modes (by @akshay-gupta7) [GH #3235](https://github.com/penpot/penpot/pull/3235) ## 1.18.3 @@ -1700,7 +1901,7 @@ is a number of cores) - Improve deeps selection of nested arboards [Taiga #4913](https://tree.taiga.io/project/penpot/issue/4913) - Fix problem on selection numeric inputs on Firefox [#2991](https://github.com/penpot/penpot/issues/2991) - Changed the text dominant-baseline to use ideographic [Taiga #4791](https://tree.taiga.io/project/penpot/issue/4791) -- Viewer wrong translations [Github #3035](https://github.com/penpot/penpot/issues/3035) +- Viewer wrong translations [GH #3035](https://github.com/penpot/penpot/issues/3035) - Fix problem with text editor in Safari - Fix unlink library color when blur color picker input [#3026](https://github.com/penpot/penpot/issues/3026) - Fix snap pixel when moving path points on high zoom [#2930](https://github.com/penpot/penpot/issues/2930) @@ -1752,13 +1953,13 @@ is a number of cores) - Fix view mode header buttons overlapping in small resolutions [Taiga #5058](https://tree.taiga.io/project/penpot/issue/5058) - Fix precision for wrap in flex [Taiga #5072](https://tree.taiga.io/project/penpot/issue/5072) - Fix relative position overlay positioning [Taiga #5092](https://tree.taiga.io/project/penpot/issue/5092) -- Fix hide grid keyboard shortcut [Github #3071](https://github.com/penpot/penpot/pull/3071) +- Fix hide grid keyboard shortcut [GH #3071](https://github.com/penpot/penpot/pull/3071) - Fix problem with opacity in imported SVG's [Taiga #4923](https://tree.taiga.io/project/penpot/issue/4923) ### :heart: Community contributions by (Thank you!) - To @ondrejkonec: for contributing to the code with: -- Refactor CSS variables [Github #2948](https://github.com/penpot/penpot/pull/2948) +- Refactor CSS variables [GH #2948](https://github.com/penpot/penpot/pull/2948) ## 1.17.3 @@ -1775,7 +1976,7 @@ is a number of cores) ### :sparkles: Enhancements -- Adds environment variables for specifying the export and backend URI for the frontend docker image, thanks to @Supernova3339 for the initial PR and suggestion [Github #2984](https://github.com/penpot/penpot/issues/2984) +- Adds environment variables for specifying the export and backend URI for the frontend docker image, thanks to @Supernova3339 for the initial PR and suggestion [GH #2984](https://github.com/penpot/penpot/issues/2984) ## 1.17.2 @@ -1843,13 +2044,13 @@ is a number of cores) - Fix problem with text edition in Safari [Taiga #4046](https://tree.taiga.io/project/penpot/issue/4046) - Fix show outline with rounded corners on rects [Taiga #4053](https://tree.taiga.io/project/penpot/issue/4053) - Fix wrong interaction between comments and panning modes [Taiga #4297](https://tree.taiga.io/project/penpot/issue/4297) -- Fix bad element positioning on interaction with fixed scroll [Github #2660](https://github.com/penpot/penpot/issues/2660) +- Fix bad element positioning on interaction with fixed scroll [GH #2660](https://github.com/penpot/penpot/issues/2660) - Fix display type of component library not persistent [Taiga #4512](https://tree.taiga.io/project/penpot/issue/4512) - Fix problem when moving texts with keyboard [#2690](https://github.com/penpot/penpot/issues/2690) - Fix problem when drawing boxes won't detect mouse-up [Taiga #4618](https://tree.taiga.io/project/penpot/issue/4618) - Fix missing loading icon on shared libraries [Taiga #4148](https://tree.taiga.io/project/penpot/issue/4148) - Fix selection stroke missing in properties of multiple texts [Taiga #4048](https://tree.taiga.io/project/penpot/issue/4048) -- Fix missing create component menu for frames [Github #2670](https://github.com/penpot/penpot/issues/2670) +- Fix missing create component menu for frames [GH #2670](https://github.com/penpot/penpot/issues/2670) - Fix "currentColor" is not converted when importing SVG [Github 2276](https://github.com/penpot/penpot/issues/2276) - Fix incorrect color in properties of multiple bool shapes [Taiga #4355](https://tree.taiga.io/project/penpot/issue/4355) - Fix pressing the enter key gives you an internal error [Github 2675](https://github.com/penpot/penpot/issues/2675) [Github 2577](https://github.com/penpot/penpot/issues/2577) @@ -1858,10 +2059,10 @@ is a number of cores) - Fix wrong update of text in components [Taiga #4646](https://tree.taiga.io/project/penpot/issue/4646) - Fix problem with SVG imports with style [#2605](https://github.com/penpot/penpot/issues/2605) - Fix ghost shapes after sync groups in components [Taiga #4649](https://tree.taiga.io/project/penpot/issue/4649) -- Fix layer orders messed up on move, group, reparent and undo [Github #2672](https://github.com/penpot/penpot/issues/2672) -- Fix max height in library dialog [Github #2335](https://github.com/penpot/penpot/issues/2335) +- Fix layer orders messed up on move, group, reparent and undo [GH #2672](https://github.com/penpot/penpot/issues/2672) +- Fix max height in library dialog [GH #2335](https://github.com/penpot/penpot/issues/2335) - Fix undo ungroup (shift+g) scrambles positions [Taiga #4674](https://tree.taiga.io/project/penpot/issue/4674) -- Fix justified text is stretched [Github #2539](https://github.com/penpot/penpot/issues/2539) +- Fix justified text is stretched [GH #2539](https://github.com/penpot/penpot/issues/2539) - Fix mousewheel on viewer inspector [Taiga #4221](https://tree.taiga.io/project/penpot/issue/4221) - Fix path edition activated on boards [Taiga #4105](https://tree.taiga.io/project/penpot/issue/4105) - Fix hidden layers inside groups become visible after the group visibility is changed[Taiga #4710](https://tree.taiga.io/project/penpot/issue/4710) @@ -1884,7 +2085,7 @@ is a number of cores) ### :bug: Bugs fixed -- Fix strage cursor behaviour after clicking viewport with text pool [Github #2447](https://github.com/penpot/penpot/issues/2447) +- Fix strage cursor behaviour after clicking viewport with text pool [GH #2447](https://github.com/penpot/penpot/issues/2447) ## 1.16.1-beta @@ -1895,7 +2096,7 @@ is a number of cores) - Fix justify alignes text left [Taiga #4322](https://tree.taiga.io/project/penpot/issue/4322) - Fix text out of borders with "auto width" and center align [Taiga #4308](https://tree.taiga.io/project/penpot/issue/4308) - Fix wrong validation text after interaction with 2 and more files [Taiga #4276](https://tree.taiga.io/project/penpot/issue/4276) -- Fix auto-width for texts can make text appear stretched [Github #2482](https://github.com/penpot/penpot/issues/2482) +- Fix auto-width for texts can make text appear stretched [GH #2482](https://github.com/penpot/penpot/issues/2482) - Fix boards name do not disappear in focus mode [#4272](https://tree.taiga.io/project/penpot/issue/4272) - Fix wrong email in the info message at change email [Taiga #4274](https://tree.taiga.io/project/penpot/issue/4274) - Fix transform to path RMB menu item is not relevant if shape is already path [Taiga #4302](https://tree.taiga.io/project/penpot/issue/4302) @@ -2034,7 +2235,7 @@ is a number of cores) - Fix problems with double-click and selection [Taiga #4005](https://tree.taiga.io/project/penpot/issue/4005) - Fix mismatch between editor and displayed text in workspace [Taiga #3975](https://tree.taiga.io/project/penpot/issue/3975) - Fix validation error on text position [Taiga #4010](https://tree.taiga.io/project/penpot/issue/4010) -- Fix objects jitter while scrolling [Github #2167](https://github.com/penpot/penpot/issues/2167) +- Fix objects jitter while scrolling [GH #2167](https://github.com/penpot/penpot/issues/2167) - Fix on color-picker, click+drag adds lots of recent colors [Taiga #4013](https://tree.taiga.io/project/penpot/issue/4013) - Fix opening profile URL while signed out takes to "your account" section[Taiga #3976](https://tree.taiga.io/project/penpot/issue/3976) @@ -2344,7 +2545,7 @@ is a number of cores) - Scroll bars [Taiga #2550](https://tree.taiga.io/project/penpot/task/2550) - Add select layer option to context menu [Taiga #2474](https://tree.taiga.io/project/penpot/us/2474) - Guides [Taiga #290](https://tree.taiga.io/project/penpot/us/290) -- Improve file menu by adding semantically groups [Github #1203](https://github.com/penpot/penpot/issues/1203) +- Improve file menu by adding semantically groups [GH #1203](https://github.com/penpot/penpot/issues/1203) - Add update components in bulk option in context menu [Taiga #1975](https://tree.taiga.io/project/penpot/us/1975) - Create first E2E tests [Taiga #2608](https://tree.taiga.io/project/penpot/task/2608), [Taiga #2608](https://tree.taiga.io/project/penpot/task/2608) - Redesign of workspace toolbars [Taiga #2319](https://tree.taiga.io/project/penpot/us/2319) @@ -2412,7 +2613,7 @@ is a number of cores) - Add shortcut to create artboard from selected objects [Taiga #2412](https://tree.taiga.io/project/penpot/us/2412) - Add shortcut for opacity [Taiga #2442](https://tree.taiga.io/project/penpot/us/2442) - Setting fill automatically for new texts [Taiga #2441](https://tree.taiga.io/project/penpot/us/2441) -- Add shortcut to move action [Github #1213](https://github.com/penpot/penpot/issues/1213) +- Add shortcut to move action [GH #1213](https://github.com/penpot/penpot/issues/1213) - Add alt as mod key to add stroke color from library menu [Taiga #2207](https://tree.taiga.io/project/penpot/us/2207) - Add detach in bulk option to context menu [Taiga #2210](https://tree.taiga.io/project/penpot/us/2210) - Add penpot look and feel to multiuser cursors [Taiga #1387](https://tree.taiga.io/project/penpot/us/1387) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e8f30e605..a12d23bdb9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,17 @@ Center](https://help.penpot.app/). - [Prerequisites](#prerequisites) - [Reporting Bugs](#reporting-bugs) - [Pull Requests](#pull-requests) + - [Workflow](#workflow) + - [Title format](#title-format) + - [Description](#description) + - [Branch naming](#branch-naming) + - [Review process](#review-process) + - [What we won't accept](#what-we-wont-accept) + - [Good first issues](#good-first-issues) - [Commit Guidelines](#commit-guidelines) + - [Commit types](#commit-types) + - [Rules](#rules) + - [Examples](#examples) - [Formatting and Linting](#formatting-and-linting) - [Changelog](#changelog) - [Code of Conduct](#code-of-conduct) @@ -52,18 +62,104 @@ Advisories](https://github.com/penpot/penpot/security/advisories) 1. **Read the DCO** — see [Developer's Certificate of Origin](#developers-certificate-of-origin-dco) below. All code patches must include a `Signed-off-by` line. -2. **Discuss before building** — open a question/discussion issue before - starting work on a new feature or significant change. No PR will be - accepted without prior discussion, whether it is a new feature, a planned - one, or a quick win. +2. **Discuss before building** — open a [GitHub + Issue](https://github.com/penpot/penpot/issues) before starting work on + a new feature or significant change. For planned features on the roadmap, + reference the corresponding Taiga story. Do not expect your contribution + to be accepted if you submit it without prior discussion — this applies + to new features, planned features, and quick wins alike. 3. **Bug fixes** — you may submit a PR directly, but we still recommend filing an issue first so we can track it independently of your fix. 4. **Format and lint** — run the checks described in [Formatting and Linting](#formatting-and-linting) before submitting. +### Title format + +Pull request titles **must** follow the same convention as commit subjects: + +``` +:emoji: <subject> +``` + +- Use the **imperative mood** (e.g. "Fix", not "Fixed"). +- Capitalize the first letter of the subject. +- Do not end the subject with a period. +- Keep the subject to **70 characters** or fewer. +- Use one of the [commit type emojis](#commit-types) listed below. + +When a PR contains multiple unrelated commits, choose the emoji that +best represents the dominant change. + +**Examples:** + +``` +:bug: Fix unexpected error on launching modal +:sparkles: Enable new modal for profile +:zap: Improve performance of dashboard navigation +``` + +> **Note:** When a PR is squash-merged, the PR title becomes the +> commit message on the main branch. Getting the title right matters. + +### Description + +Every pull request should include a description that helps reviewers +understand the change quickly: + +1. **What and why** — describe the change and its motivation. +2. **Link related issues** — use `Closes #1234` or reference a Taiga + story (e.g. `Taiga #5678`). +3. **Screenshots or recordings** — required for any UI-visible change. +4. **Testing notes** — how did you verify the change? Any edge cases? +5. **Breaking changes** — call out anything that affects existing users + or requires migration steps. + +### Branch naming + +Use a descriptive branch name that reflects the type and scope of the +change: + +``` +<type>/<short-description> +``` + +Types: `fix`, `feat`, `refactor`, `docs`, `chore`, `perf`. + +Optionally include the issue number: + +``` +fix/9122-email-blacklisting +feat/export-webp +refactor/layout-sizing +``` + +### Review process + +- We are a small team and maintainers juggle reviews alongside other + tasks. Please do not expect your code to be reviewed instantly. +- Reviews are handled in dedicated blocks of time, usually in the order + PRs arrive. It may take a few days to get a first review, especially + when urgent tasks come up. +- Address review feedback by **pushing new commits** — do not + force-push during review, as it breaks comment threads. +- PRs require at least **one approval** before merge. +- We use **squash-merge** by default. The PR title becomes the final + commit message, so follow the [title format](#title-format) above. + +### What we won't accept + +To save time on both sides, please avoid submitting PRs that: + +- Introduce new dependencies without prior discussion. +- Change the build system or CI configuration without maintainer + approval. +- Mix unrelated changes in a single PR — keep PRs focused on one + concern. +- Skip the [discussion step](#workflow) for non-bug-fix changes. + ### Good first issues -We use the `easy fix` label to mark issues appropriate for newcomers. +We use the `good first issue` label to mark issues appropriate for newcomers. ## Commit Guidelines @@ -79,26 +175,26 @@ Commit messages must follow this format: ### Commit types -| Emoji | Description | -|-------|-------------| -| :bug: | Bug fix | -| :sparkles: | Improvement or enhancement | -| :tada: | New feature | -| :recycle: | Refactor | -| :lipstick: | Cosmetic changes | -| :ambulance: | Critical bug fix | -| :books: | Documentation | -| :construction: | Work in progress | -| :boom: | Breaking change | -| :wrench: | Configuration update | -| :zap: | Performance improvement | -| :whale: | Docker-related change | -| :paperclip: | Other non-relevant changes | -| :arrow_up: | Dependency update | -| :arrow_down: | Dependency downgrade | -| :fire: | Removal of code or files | +| Emoji | Description | +| ---------------------- | -------------------------- | +| :bug: | Bug fix | +| :sparkles: | Improvement or enhancement | +| :tada: | New feature | +| :recycle: | Refactor | +| :lipstick: | Cosmetic changes | +| :ambulance: | Critical bug fix | +| :books: | Documentation | +| :construction: | Work in progress | +| :boom: | Breaking change | +| :wrench: | Configuration update | +| :zap: | Performance improvement | +| :whale: | Docker-related change | +| :paperclip: | Other non-relevant changes | +| :arrow_up: | Dependency update | +| :arrow_down: | Dependency downgrade | +| :fire: | Removal of code or files | | :globe_with_meridians: | Add or update translations | -| :rocket: | Epic or highlight | +| :rocket: | Epic or highlight | ### Rules @@ -135,6 +231,19 @@ We use [cljfmt](https://github.com/weavejester/cljfmt) for formatting and ./scripts/lint ``` +For frontend SCSS, we use `stylelint` for linting and +`Prettier` for formatting: + +```bash +cd frontend + +# Lint SCSS +pnpm run lint:scss (does not modify files) + +# Fix SCSS formatting (modifies files in place) +pnpm run fmt:scss +``` + Ideally, run these as git pre-commit hooks. [Husky](https://typicode.github.io/husky/#/) is a convenient option for setting this up. @@ -164,23 +273,23 @@ By submitting code you agree to and can certify the following: > By making a contribution to this project, I certify that: > > (a) The contribution was created in whole or in part by me and I have the -> right to submit it under the open source license indicated in the file; or +> right to submit it under the open source license indicated in the file; or > > (b) The contribution is based upon previous work that, to the best of my -> knowledge, is covered under an appropriate open source license and I have -> the right under that license to submit that work with modifications, -> whether created in whole or in part by me, under the same open source -> license (unless I am permitted to submit under a different license), as -> indicated in the file; or +> knowledge, is covered under an appropriate open source license and I have +> the right under that license to submit that work with modifications, +> whether created in whole or in part by me, under the same open source +> license (unless I am permitted to submit under a different license), as +> indicated in the file; or > > (c) The contribution was provided directly to me by some other person who -> certified (a), (b) or (c) and I have not modified it. +> certified (a), (b) or (c) and I have not modified it. > > (d) I understand and agree that this project and the contribution are public -> and that a record of the contribution (including all personal information -> I submit with it, including my sign-off) is maintained indefinitely and -> may be redistributed consistent with this project or the open source -> license(s) involved. +> and that a record of the contribution (including all personal information +> I submit with it, including my sign-off) is maintained indefinitely and +> may be redistributed consistent with this project or the open source +> license(s) involved. ### Signed-off-by diff --git a/README.md b/README.md index 988407f1b3..fd681f9afc 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,21 @@ +<img width="100%" src="https://github.com/user-attachments/assets/da17b160-f289-436f-b140-972083a08602" /> [uri_license]: https://www.mozilla.org/en-US/MPL/2.0 [uri_license_image]: https://img.shields.io/badge/MPL-2.0-blue.svg -<picture> - <source media="(prefers-color-scheme: dark)" srcset="https://penpot.app/images/readme/github-dark-mode.png"> - <source media="(prefers-color-scheme: light)" srcset="https://penpot.app/images/readme/github-light-mode.png"> - <img alt="penpot header image" src="https://penpot.app/images/readme/github-light-mode.png"> -</picture> - <p align="center"> - <a href="https://www.mozilla.org/en-US/MPL/2.0" rel="nofollow"><img alt="License: MPL-2.0" src="https://img.shields.io/badge/MPL-2.0-blue.svg" style="max-width:100%;"></a> - <a href="https://community.penpot.app" rel="nofollow"><img alt="Penpot Community" src="https://img.shields.io/discourse/posts?server=https%3A%2F%2Fcommunity.penpot.app" style="max-width:100%;"></a> - <a href="https://tree.taiga.io/project/penpot/" title="Managed with Taiga.io" rel="nofollow"><img alt="Managed with Taiga.io" src="https://img.shields.io/badge/managed%20with-TAIGA.io-709f14.svg" style="max-width:100%;"></a> - <a href="https://gitpod.io/#https://github.com/penpot/penpot" rel="nofollow"><img alt="Gitpod ready-to-code" src="https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod" style="max-width:100%;"></a> + <a href="https://www.digitalpublicgoods.net/r/penpot" rel="nofollow"> + <img alt="Verified DPG" src="https://img.shields.io/badge/Verified-DPG-blue.svg"> + </a> + <a href="https://community.penpot.app" rel="nofollow"> + <img alt="Penpot Community" src="https://img.shields.io/discourse/posts?server=https%3A%2F%2Fcommunity.penpot.app"> + </a> + <a href="https://tree.taiga.io/project/penpot/" rel="nofollow"> + <img alt="Managed with Taiga.io" src="https://img.shields.io/badge/managed%20with-TAIGA.io-709f14.svg"> + </a> + <a href="https://gitpod.io/#https://github.com/penpot/penpot" rel="nofollow"> + <img alt="Gitpod ready-to-code" src="https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod"> + </a> </p> <p align="center"> @@ -33,15 +36,21 @@ [Penpot video](https://github.com/user-attachments/assets/7c67fd7c-04d3-4c9b-88ec-b6f5e23f8332) -Penpot is the first **open-source** design tool for design and code collaboration. Designers can create stunning designs, interactive prototypes, design systems at scale, while developers enjoy ready-to-use code and make their workflow easy and fast. And all of this with no handoff drama. +Penpot is the open-source design platform for teams that build digital products at scale. -Available on browser or self-hosted, Penpot works with open standards like SVG, CSS, HTML and JSON, and it’s free! +Penpot’s key strength lies in giving you **full ownership of your design infrastructure**. Built on open source and designed for [self-hosting](https://help.penpot.app/technical-guide/getting-started/), it puts teams in complete control of their design environment supporting strict compliance and governance requirements. Whether used in the **browser or deployed on your own servers**, Penpot **works with open standards** like SVG, CSS, HTML, and JSON. -The latest updates take Penpot even further. It’s the first design tool to integrate native [design tokens](https://penpot.dev/collaboration/design-tokens)—a single source of truth to improve efficiency and collaboration between product design and development. +Real-time collaboration strengthens this foundation, helping teams scale and bring design closer to the product through top-tier capabilities. Additionally, developers feel at home using Penpot, because design is expressed as code, enabling a direct translation and shipping products faster. -With the [huge 2.0 release](https://penpot.app/dev-diaries), Penpot took the platform to a whole new level. This update introduces the ground-breaking [CSS Grid Layout feature](https://penpot.app/penpot-2.0), a complete UI redesign, a new Components system, and much more. +Best-in-class native [Design Tokens](https://penpot.dev/collaboration/design-tokens) provide a single source of truth between design and development. They ensure consistency, improve collaboration, and make it easier to manage complex design systems. -For organizations that need extra service for its teams, [get in touch](https://cal.com/team/penpot/talk-to-us). +The [MCP server](https://penpot.app/penpot-mcp-server) takes it further by enabling multi-directional workflows between design and code. A [powerful open API](https://help.penpot.app/mcp/#quick-start) and plugin system makes the workspace programmable, enabling automation, AI-driven workflows, and integrations with the tools and systems you already use. + +With [CSS Grid and Flex Layout](https://help.penpot.app/user-guide/designing/flexible-layouts/), teams can design responsive interfaces that behave like real code from the start. + +Combined, these features turn Penpot into a **full-stack design platform** for building scalable design systems and fully integrated product development processes. + +If your organization is scaling and needs extra support, we’re here to help. [Talk to us](https://penpot.app/talk-to-us) ## Table of contents ## @@ -54,7 +63,7 @@ For organizations that need extra service for its teams, [get in touch](https:// ## Why Penpot ## -Penpot expresses designs as code. Designers can do their best work and see it will be beautifully implemented by developers in a two-way collaboration. +Penpot connects design, code, and AI workflows through a code-based approach, making designs readable by developers and AI via the MCP server. This approach helps teams ship what’s actually designed and manage design systems at scale with powerful design tokens. As a self-hosted, open-source and real-time collaboration platform, Penpot offers full flexibility, security, and ownership without vendor lock-in. Learn more about [why Penpot](https://penpot.app/why-penpot) is the platform for your team. ### Plugin system ### @@ -68,21 +77,15 @@ Penpot was built to serve both designers and developers and create a fluid desig Work with ready-to-use code and make your workflow easy and fast. The inspect tab gives instant access to SVG, CSS and HTML code. -### Self host your own instance ### - -Provide your team or organization with a completely owned collaborative design tool. Use Penpot's cloud service or deploy your own Penpot server. - ### Integrations ### -Penpot offers integration into the development toolchain, thanks to its support for webhooks and an API accessible through access tokens. +Penpot offers [integration](https://penpot.app/integrations-api) into the development toolchain, thanks to its support for webhooks and an API accessible through access tokens. ### Building Design Systems: design tokens, components and variants ### -Penpot brings design systems to code-minded teams: a single source of truth with native Design Tokens, Components, and Variants for scalable, reusable, and consistent UI across projects and platforms. +Penpot brings [design systems](https://penpot.app/design/design-systems) to code-minded teams: a single source of truth with native Design Tokens, Components, and Variants for scalable, reusable, and consistent UI across projects and platforms. -<p align="center"> - <img src="https://github.com/user-attachments/assets/cce75ad6-f783-473f-8803-da9eb8255fef"> -</p> +<img width="100%" alt="Penpot Design Systems" src="https://github.com/user-attachments/assets/cce75ad6-f783-473f-8803-da9eb8255fef"> ## Getting started ## @@ -90,37 +93,31 @@ Penpot is the only design & prototype platform that is deployment agnostic. You Learn how to install it with Docker, Kubernetes, Elestio or other options on [our website](https://penpot.app/self-host). -<p align="center"> - <img src="https://github.com/user-attachments/assets/93578500-2dbd-4045-a180-e640ea5b3bd5" style="width: 65%;"> -</p> - ## Community ## We love the Open Source software community. Contributing is our passion and if it’s yours too, participate and [improve](https://community.penpot.app/c/help-us-improve-penpot/7) Penpot. All your designs, code and ideas are welcome! +Want to go a step further? Become a [Penpot Ambassador](https://penpot.app/ambassador-program) and help grow the Penpot community in your region while contributing to a global, open design ecosystem. + If you need help or have any questions; if you’d like to share your experience using Penpot or get inspired; if you’d rather meet our community of developers and designers, [join our Community](https://community.penpot.app/)! -You will find the following categories: +Categories include: - [Ask the Community](https://community.penpot.app/c/ask-for-help-using-penpot/6) - [Troubleshooting](https://community.penpot.app/c/technical/8) - [Help us Improve Penpot](https://community.penpot.app/c/help-us-improve-penpot/7) -- [#MadeWithPenpot](https://community.penpot.app/c/madewithpenpot/9) - [Events and Announcements](https://community.penpot.app/c/announcements/5) -- [Inside Penpot](https://community.penpot.app/c/inside-penpot/21) - [Penpot in your language](https://community.penpot.app/c/penpot-in-your-language/12) -- [Design and Code Essentials](https://community.penpot.app/c/design-and-code-essentials/22) +- [Education](https://community.penpot.app/c/education/28) -<p align="center"> - <img src="https://github.com/user-attachments/assets/7b7d0f6b-a579-4822-a9ae-d3d5a9fc9d19" alt="Community" style="width: 65%;"> -</p> +<img width="100%" alt="Pentpot Community" src="https://github.com/user-attachments/assets/4b2a4360-12b5-4994-bd45-641449f86c4e" /> ### Code of Conduct ### Anyone who contributes to Penpot, whether through code, in the community, or at an event, must adhere to the [code of conduct](https://help.penpot.app/contributing-guide/coc/) and foster a positive and safe environment. -## Contributing ## +### Contributing ### Any contribution will make a difference to improve Penpot. How can you get involved? @@ -137,9 +134,7 @@ Choose your way: To find (almost) everything you need to know on how to contribute to Penpot, refer to the [contributing guide](https://help.penpot.app/contributing-guide/). -<p align="center"> - <img src="https://github.com/penpot/penpot/assets/5446186/fea18923-dc06-49be-86ad-c3496a7956e6" alt="Libraries and templates" style="width: 65%;"> -</p> +<img width="100%" alt="Penpot hub" src="https://github.com/user-attachments/assets/0abc02f0-625c-45ab-ad81-4927bec7a055" /> ## Resources ## @@ -155,14 +150,16 @@ You can ask and answer questions, have open-ended conversations, and follow alon 📚 [Dev Diaries](https://penpot.app/dev-diaries.html) +🧑‍🏫​ [UI Design Course](https://penpot.app/courses/) + + ## License ## -```text +``` 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 ``` - Penpot is a Kaleidos’ [open source project](https://kaleidos.net/) diff --git a/backend/AGENTS.md b/backend/AGENTS.md deleted file mode 100644 index 913540f808..0000000000 --- a/backend/AGENTS.md +++ /dev/null @@ -1,259 +0,0 @@ -# Penpot Backend – Agent Instructions - -Clojure backend (RPC) service running on the JVM. - -Uses Integrant for dependency injection, PostgreSQL for storage, and -Redis for messaging/caching. - -## General Guidelines - -To ensure consistency across the Penpot JVM stack, all contributions must adhere -to these criteria: - -### 1. Testing & Validation - -* **Coverage:** If code is added or modified in `src/`, corresponding - tests in `test/backend_tests/` must be added or updated. - -* **Execution:** - * **Isolated:** Run `clojure -M:dev:test --focus backend-tests.my-ns-test` for the specific test namespace. - * **Regression:** Run `clojure -M:dev:test` to ensure the suite passes without regressions in related functional areas. - -### 2. Code Quality & Formatting - -* **Linting:** All code must pass `clj-kondo` checks (run `pnpm run lint:clj`) -* **Formatting:** All the code must pass the formatting check (run `pnpm run - check-fmt`). Use `pnpm run fmt` to fix formatting issues. Avoid "dirty" - diffs caused by unrelated whitespace changes. -* **Type Hinting:** Use explicit JVM type hints (e.g., `^String`, `^long`) in - performance-critical paths to avoid reflection overhead. - -## Code Conventions - -### Namespace Overview - -The source is located under `src` directory and this is a general overview of -namespaces structure: - -- `app.rpc.commands.*` – RPC command implementations (`auth`, `files`, `teams`, etc.) -- `app.http.*` – HTTP routes and middleware -- `app.db.*` – Database layer -- `app.tasks.*` – Background job tasks -- `app.main` – Integrant system setup and entrypoint -- `app.loggers` – Internal loggers (auditlog, mattermost, etc.) (not to be confused with `app.common.logging`) - -### RPC - -The RPC methods are implemented using a multimethod-like structure via the -`app.util.services` namespace. The main RPC methods are collected under -`app.rpc.commands` namespace and exposed under `/api/rpc/command/<cmd-name>`. - -The RPC method accepts POST and GET requests indistinctly and uses the `Accept` -header to negotiate the response encoding (which can be Transit — the default — -or plain JSON). It also accepts Transit (default) or JSON as input, which should -be indicated using the `Content-Type` header. - -The main convention is: use `get-` prefix on RPC name when we want READ -operation. - -Example of RPC method definition: - -```clojure -(sv/defmethod ::my-command - {::rpc/auth true ;; requires auth - ::doc/added "1.18" - ::sm/params [:map ...] ;; malli input schema - ::sm/result [:map ...]} ;; malli output schema - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}] - ;; return a plain map or throw - {:id (uuid/next)}) -``` - -Look under `src/app/rpc/commands/*.clj` to see more examples. - -### Tests - -Test namespaces match `.*-test$` under `test/`. Config is in `tests.edn`. - - -### Integrant System - -The `src/app/main.clj` declares the system map. Each key is a component; values -are config maps with `::ig/ref` for dependencies. Components implement -`ig/init-key` / `ig/halt-key!`. - - -### Connecting to the Database - -Two PostgreSQL databases are used in this environment: - -| Database | Purpose | Connection string | -|---------------|--------------------|----------------------------------------------------| -| `penpot` | Development / app | `postgresql://penpot:penpot@postgres/penpot` | -| `penpot_test` | Test suite | `postgresql://penpot:penpot@postgres/penpot_test` | - -**Interactive psql session:** - -```bash -# development DB -psql "postgresql://penpot:penpot@postgres/penpot" - -# test DB -psql "postgresql://penpot:penpot@postgres/penpot_test" -``` - -**One-shot query (non-interactive):** - -```bash -psql "postgresql://penpot:penpot@postgres/penpot" -c "SELECT id, name FROM team LIMIT 5;" -``` - -**Useful psql meta-commands:** - -``` -\dt -- list all tables -\d <table> -- describe a table (columns, types, constraints) -\di -- list indexes -\q -- quit -``` - -> **Migrations table:** Applied migrations are tracked in the `migrations` table -> with columns `module`, `step`, and `created_at`. When renaming a migration -> logical name, update this table in both databases to match the new name; -> otherwise the runner will attempt to re-apply the migration on next startup. - -```bash -# Example: fix a renamed migration entry in the test DB -psql "postgresql://penpot:penpot@postgres/penpot_test" \ - -c "UPDATE migrations SET step = 'new-name' WHERE step = 'old-name';" -``` - -### Database Access (Clojure) - -`app.db` wraps next.jdbc. Queries use a SQL builder that auto-converts kebab-case ↔ snake_case. - -```clojure -;; Query helpers -(db/get cfg-or-pool :table {:id id}) ; fetch one row (throws if missing) -(db/get* cfg-or-pool :table {:id id}) ; fetch one row (returns nil) -(db/query cfg-or-pool :table {:team-id team-id}) ; fetch multiple rows -(db/insert! cfg-or-pool :table {:name "x" :team-id id}) ; insert -(db/update! cfg-or-pool :table {:name "y"} {:id id}) ; update -(db/delete! cfg-or-pool :table {:id id}) ; delete - -;; Run multiple statements/queries on single connection -(db/run! cfg (fn [{:keys [::db/conn]}] - (db/insert! conn :table row1) - (db/insert! conn :table row2)) - - -;; Transactions -(db/tx-run! cfg (fn [{:keys [::db/conn]}] - (db/insert! conn :table row))) -``` - -Almost all methods in the `app.db` namespace accept `pool`, `conn`, or -`cfg` as params. - -Migrations live in `src/app/migrations/` as numbered SQL files. They run automatically on startup. - - -### Error Handling - -The exception helpers are defined on Common module, and are available under -`app.common.exceptions` namespace. - -Example of raising an exception: - -```clojure -(ex/raise :type :not-found - :code :object-not-found - :hint "File does not exist" - :file-id id) -``` - -Common types: `:not-found`, `:validation`, `:authorization`, `:conflict`, `:internal`. - - -### Performance Macros (`app.common.data.macros`) - -Always prefer these macros over their `clojure.core` equivalents — they provide -optimized implementations: - -```clojure -(dm/select-keys m [:a :b]) ;; faster than core/select-keys -(dm/get-in obj [:a :b :c]) ;; faster than core/get-in -(dm/str "a" "b" "c") ;; string concatenation -``` - -### Configuration - -`src/app/config.clj` reads `PENPOT_*` environment variables, validated with -Malli. Access anywhere via `(cf/get :smtp-host)`. Feature flags: `(cf/flags -:enable-smtp)`. - - -### Background Tasks - -Background tasks live in `src/app/tasks/`. Each task is an Integrant component -that exposes a `::handler` key and follows this three-method pattern: - -```clojure -(defmethod ig/assert-key ::handler ;; validate config at startup - [_ params] - (assert (db/pool? (::db/pool params)) "expected a valid database pool")) - -(defmethod ig/expand-key ::handler ;; inject defaults before init - [k v] - {k (assoc v ::my-option default-value)}) - -(defmethod ig/init-key ::handler ;; return the task fn - [_ cfg] - (fn [_task] ;; receives the task row from the worker - (db/tx-run! cfg (fn [{:keys [::db/conn]}] - ;; … do work … - )))) -``` - -**Wiring a new task** requires two changes in `src/app/main.clj`: - -1. **Handler config** – add an entry in `system-config` with the dependencies: - -```clojure -:app.tasks.my-task/handler -{::db/pool (ig/ref ::db/pool)} -``` - -2. **Registry + cron** – register the handler name and schedule it: - -```clojure -;; in ::wrk/registry ::wrk/tasks map: -:my-task (ig/ref :app.tasks.my-task/handler) - -;; in worker-config ::wrk/cron ::wrk/entries vector: -{:cron #penpot/cron "0 0 0 * * ?" ;; daily at midnight - :task :my-task} -``` - -**Useful cron patterns** (Quartz format — six fields: s m h dom mon dow): - -| Expression | Meaning | -|------------------------------|--------------------| -| `"0 0 0 * * ?"` | Daily at midnight | -| `"0 0 */6 * * ?"` | Every 6 hours | -| `"0 */5 * * * ?"` | Every 5 minutes | - -**Time helpers** (`app.common.time`): - -```clojure -(ct/now) ;; current instant -(ct/duration {:hours 1}) ;; java.time.Duration -(ct/minus (ct/now) some-duration) ;; subtract duration from instant -``` - -`db/interval` converts a `Duration` (or millis / string) to a PostgreSQL -interval object suitable for use in SQL queries: - -```clojure -(db/interval (ct/duration {:hours 1})) ;; → PGInterval "3600.0 seconds" -``` diff --git a/backend/deps.edn b/backend/deps.edn index af73aecbc8..d53cf9a6c2 100644 --- a/backend/deps.edn +++ b/backend/deps.edn @@ -3,7 +3,7 @@ :deps {penpot/common {:local/root "../common"} - org.clojure/clojure {:mvn/version "1.12.4"} + org.clojure/clojure {:mvn/version "1.12.5"} org.clojure/tools.namespace {:mvn/version "1.5.0"} com.github.luben/zstd-jni {:mvn/version "1.5.7-4"} @@ -17,7 +17,7 @@ io.prometheus/simpleclient_httpserver {:mvn/version "0.16.0"} - io.lettuce/lettuce-core {:mvn/version "6.8.1.RELEASE"} + io.lettuce/lettuce-core {:mvn/version "7.5.1.RELEASE"} ;; Minimal dependencies required by lettuce, we need to include them ;; explicitly because clojure dependency management does not support ;; yet the BOM format. @@ -28,18 +28,18 @@ com.google.guava/guava {:mvn/version "33.4.8-jre"} funcool/yetti - {:git/tag "v11.9" - :git/sha "5fad7a9" + {:git/tag "v11.10" + :git/sha "88701f4" :git/url "https://github.com/funcool/yetti.git" :exclusions [org.slf4j/slf4j-api]} com.github.seancorfield/next.jdbc - {:mvn/version "1.3.1070"} + {:mvn/version "1.3.1093"} metosin/reitit-core {:mvn/version "0.9.1"} - nrepl/nrepl {:mvn/version "1.4.0"} + nrepl/nrepl {:mvn/version "1.7.0"} - org.postgresql/postgresql {:mvn/version "42.7.9"} + org.postgresql/postgresql {:mvn/version "42.7.11"} org.xerial/sqlite-jdbc {:mvn/version "3.50.3.0"} com.zaxxer/HikariCP {:mvn/version "7.0.2"} @@ -49,7 +49,7 @@ buddy/buddy-hashers {:mvn/version "2.0.167"} buddy/buddy-sign {:mvn/version "3.6.1-359"} - com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.3"} + com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"} org.jsoup/jsoup {:mvn/version "1.21.2"} org.im4java/im4java @@ -57,7 +57,8 @@ :git/sha "e2b3e16" :git/url "https://github.com/penpot/im4java"} - org.lz4/lz4-java {:mvn/version "1.8.0"} + at.yawk.lz4/lz4-java + {:mvn/version "1.11.0"} org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"} @@ -66,17 +67,17 @@ ;; Pretty Print specs pretty-spec/pretty-spec {:mvn/version "0.1.4"} - software.amazon.awssdk/s3 {:mvn/version "2.41.21"}} + software.amazon.awssdk/s3 {:mvn/version "2.44.4"}} :paths ["src" "resources" "target/classes"] :aliases {:dev {:extra-deps - {com.bhauman/rebel-readline {:mvn/version "RELEASE"} + {com.bhauman/rebel-readline {:mvn/version "0.1.5"} clojure-humanize/clojure-humanize {:mvn/version "0.2.2"} - org.clojure/data.csv {:mvn/version "RELEASE"} - com.clojure-goes-fast/clj-async-profiler {:mvn/version "RELEASE"} - mockery/mockery {:mvn/version "RELEASE"}} + org.clojure/data.csv {:mvn/version "1.1.1"} + com.clojure-goes-fast/clj-async-profiler {:mvn/version "2.0.0-beta1"} + mockery/mockery {:mvn/version "0.1.4"}} :extra-paths ["test" "dev"]} :build @@ -92,7 +93,7 @@ :extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"}}} :outdated - {:extra-deps {com.github.liquidz/antq {:mvn/version "RELEASE"}} + {:extra-deps {com.github.liquidz/antq {:mvn/version "2.11.1276"}} :main-opts ["-m" "antq.core"]} :jmx-remote diff --git a/backend/resources/app/email/invite-to-org/en.html b/backend/resources/app/email/invite-to-org/en.html index 45ec53596a..5ee16f6942 100644 --- a/backend/resources/app/email/invite-to-org/en.html +++ b/backend/resources/app/email/invite-to-org/en.html @@ -198,9 +198,9 @@ <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="20" height="20" style="display:inline-block;vertical-align:middle;"> <tr> <td width="20" height="20" align="center" valign="middle" - background="{{org-logo}}" + background="{{organization-logo}}" style="width:20px;height:20px;text-align:center;font-weight:bold;font-size:9px;line-height:20px;color:#ffffff;background-size:cover;background-position:center;background-repeat:no-repeat;border-radius: 50%;color:black"> - {{org-initials}} + {% if organization-initials %}{{organization-initials}}{% endif %} </td> </tr> </table> diff --git a/backend/scripts/_env b/backend/scripts/_env index a18cbc2896..2a89244a81 100644 --- a/backend/scripts/_env +++ b/backend/scripts/_env @@ -13,7 +13,7 @@ export PENPOT_PUBLIC_URI=https://localhost:3449 export PENPOT_FLAGS="\ $PENPOT_FLAGS \ - enable-login-with-password + enable-login-with-password \ disable-login-with-ldap \ disable-login-with-oidc \ disable-login-with-google \ @@ -66,7 +66,7 @@ export PENPOT_OBJECTS_STORAGE_BACKEND=s3 export PENPOT_OBJECTS_STORAGE_S3_ENDPOINT=http://minio:9000 export PENPOT_OBJECTS_STORAGE_S3_BUCKET=penpot -export PENPOT_NITRATE_BACKEND_URI=http://localhost:3000/control-center +export PENPOT_NITRATE_BACKEND_URI=http://localhost:3000/admin-console export JAVA_OPTS="\ -Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \ diff --git a/backend/src/app/auth/ldap.clj b/backend/src/app/auth/ldap.clj index 63b7c93672..687a10dd4d 100644 --- a/backend/src/app/auth/ldap.clj +++ b/backend/src/app/auth/ldap.clj @@ -111,7 +111,7 @@ [:host {:optional true} :string] [:port {:optional true} ::sm/int] [:bind-dn {:optional true} :string] - [:bind-passwor {:optional true} :string] + [:bind-password {:optional true} :string] [:query {:optional true} :string] [:base-dn {:optional true} :string] [:attrs-email {:optional true} :string] diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index 9c292ff2c2..26986fd781 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -27,6 +27,7 @@ [app.rpc.commands.profile :as profile] [app.setup :as-alias setup] [app.tokens :as tokens] + [app.util.cache :as cache] [app.util.inet :as inet] [app.util.json :as json] [buddy.sign.jwk :as jwk] @@ -43,7 +44,7 @@ (defn- discover-oidc-config [cfg {:keys [base-uri] :as provider}] (let [uri (u/join base-uri ".well-known/openid-configuration") - rsp (http/req! cfg {:method :get :uri (dm/str uri)})] + rsp (http/req cfg {:method :get :uri (dm/str uri)})] (if (= 200 (:status rsp)) (let [data (-> rsp :body json/decode) @@ -105,7 +106,7 @@ (defn- fetch-oidc-jwks [cfg jwks-uri] - (let [{:keys [status body]} (http/req! cfg {:method :get :uri jwks-uri})] + (let [{:keys [status body]} (http/req cfg {:method :get :uri jwks-uri})] (if (= 200 status) (-> body json/decode :keys process-oidc-jwks) (ex/raise :type ::internal @@ -235,7 +236,7 @@ :timeout 6000 :method :get} - {:keys [status body]} (http/req! cfg params)] + {:keys [status body]} (http/req cfg params)] (when-not (int-in-range? status 200 300) (ex/raise :type :internal @@ -453,7 +454,7 @@ :grant-type (:grant_type params) :redirect-uri (:redirect_uri params)) - (let [{:keys [status body]} (http/req! cfg req)] + (let [{:keys [status body]} (http/req cfg req)] (if (= status 200) (let [data (json/decode body) data {:token/access (get data :access_token) @@ -508,7 +509,7 @@ :headers {"Authorization" (str (:token/type tdata) " " (:token/access tdata))} :timeout 6000 :method :get} - response (http/req! cfg params)] + response (http/req cfg params)] (l/trc :hint "user info response" :status (:status response) @@ -548,16 +549,29 @@ (def ^:private valid-info? (sm/validator schema:info)) +(defn- select-user-info-source + "Normalise the provider's configured user-info source into a keyword the + dispatch below can match. The raw value comes from config as a string + per the malli schema in `app.config` (`\"token\"`, `\"userinfo\"`, or + `\"auto\"`) and from hard-coded per-provider maps as strings as well; + any unrecognised or missing value falls back to `:auto` (prefer claims, + use userinfo as fallback)." + [source] + (case source + "token" :token + "userinfo" :userinfo + :auto)) + (defn- get-info [cfg provider state code] (let [tdata (fetch-access-token cfg provider code) claims (get-id-token-claims provider tdata) - info (case (get provider :user-info-source) - :token (dissoc claims :exp :iss :iat :aud :sid) + info (case (select-user-info-source (get provider :user-info-source)) + :token (dissoc claims :exp :iss :iat :aud :sid) :userinfo (fetch-user-info cfg provider tdata) - (or (some-> claims (dissoc :exp :iss :iat :aud :sid)) - (fetch-user-info cfg provider tdata))) + :auto (or (some-> claims (dissoc :exp :iss :iat :aud :sid)) + (fetch-user-info cfg provider tdata))) info (process-user-info provider tdata info)] @@ -681,15 +695,24 @@ (db/pgarray? roles) (assoc :roles (db/decode-pgarray roles #{})))) -;; TODO: add cache layer for avoid build an discover each time +;; A short TTL avoids paying the OIDC discovery + JWKS fetch on every +;; login; Caffeine will not store the entry when the load fn throws, +;; so a transient failure at the provider's discovery endpoint does +;; not poison the cache. +(defonce ^:private provider-cache + (cache/create :expire "10m" :max-size 64)) + +(defn- load-provider + [cfg id] + (when-let [params (some->> (db/get* cfg :sso-provider {:id id :is-enabled true}) + (decode-row))] + (case (:type params) + "oidc" (prepare-oidc-provider cfg params)))) (defn get-provider [cfg id] (try - (when-let [params (some->> (db/get* cfg :sso-provider {:id id :is-enabled true}) - (decode-row))] - (case (:type params) - "oidc" (prepare-oidc-provider cfg params))) + (cache/get provider-cache id (partial load-provider cfg)) (catch Throwable cause (l/err :hint "unable to configure custom SSO provider" :provider (str id) @@ -805,12 +828,12 @@ props (audit/profile->props profile) context (d/without-nils {:external-session-id (:external-session-id info)})] - (audit/submit! cfg {::audit/type "action" - ::audit/name "login-with-oidc" - ::audit/profile-id (:id profile) - ::audit/ip-addr (inet/parse-request request) - ::audit/props props - ::audit/context context}) + (audit/submit cfg {:type "action" + :name "login-with-oidc" + :profile-id (:id profile) + :ip-addr (inet/parse-request request) + :props props + :context context}) (->> (redirect-to-verify-token token) (sxf request))))) diff --git a/backend/src/app/binfile/common.clj b/backend/src/app/binfile/common.clj index f57b8775df..4adccbb72d 100644 --- a/backend/src/app/binfile/common.clj +++ b/backend/src/app/binfile/common.clj @@ -315,8 +315,8 @@ (defn get-file "Get file, resolve all features and apply migrations. - Usefull when you have plan to apply massive or not cirurgical - operations on file, because it removes the ovehead of lazy fetching + Useful when you have plan to apply massive or not surgical + operations on file, because it removes the overhead of lazy fetching and decoding." [cfg file-id & {:as opts}] (db/run! cfg get-file* file-id opts)) @@ -440,11 +440,28 @@ (db/run! cfg (fn [{:keys [::db/conn]}] (let [ids (db/create-array conn "uuid" ids) - sql (str "SELECT flr.* FROM file_library_rel AS flr " - " JOIN file AS l ON (flr.library_file_id = l.id) " - " WHERE flr.file_id = ANY(?) AND l.deleted_at IS NULL")] + sql (str "SELECT flr.*," + " fls.synced_at" + " FROM file_library_rel AS flr" + " JOIN file AS l" + " ON flr.library_file_id = l.id" + " LEFT JOIN file_library_sync AS fls" + " ON fls.file_id = flr.file_id" + " AND fls.library_file_id = flr.library_file_id" + " WHERE flr.file_id = ANY(?)" + " AND l.deleted_at IS NULL;")] (db/exec! conn [sql ids]))))) +(def ^:private sql:upsert-file-library-sync + "INSERT INTO file_library_sync (file_id, library_file_id, synced_at) + VALUES (?::uuid, ?::uuid, ?::timestamptz) + ON CONFLICT (file_id, library_file_id) + DO UPDATE SET synced_at = EXCLUDED.synced_at;") + +(defn upsert-file-library-sync! + [conn {:keys [file-id library-file-id synced-at]}] + (db/exec-one! conn [sql:upsert-file-library-sync file-id library-file-id synced-at])) + (def ^:private sql:get-libraries "WITH RECURSIVE libs AS ( SELECT fl.id @@ -799,32 +816,41 @@ (def ^:private sql:get-file-libraries "WITH RECURSIVE libs AS ( - SELECT fl.*, flr.synced_at - FROM file AS fl - JOIN file_library_rel AS flr ON (flr.library_file_id = fl.id) - WHERE flr.file_id = ?::uuid - UNION - SELECT fl.*, flr.synced_at - FROM file AS fl - JOIN file_library_rel AS flr ON (flr.library_file_id = fl.id) - JOIN libs AS l ON (flr.file_id = l.id) - ) - SELECT l.id, - l.features, - l.project_id, - p.team_id, - l.created_at, - l.modified_at, - l.deleted_at, - l.name, - l.revn, - l.vern, - l.synced_at, - l.is_shared, - l.version - FROM libs AS l - INNER JOIN project AS p ON (p.id = l.project_id) - WHERE l.deleted_at IS NULL;") + SELECT fl.* + FROM file AS fl + JOIN file_library_rel AS flr + ON flr.library_file_id = fl.id + WHERE flr.file_id = ?::uuid + + UNION + + SELECT fl.* + FROM file AS fl + JOIN file_library_rel AS flr + ON flr.library_file_id = fl.id + JOIN libs AS l + ON flr.file_id = l.id + ) + SELECT l.id, + l.features, + l.project_id, + p.team_id, + l.created_at, + l.modified_at, + l.deleted_at, + l.name, + l.revn, + l.vern, + l.is_shared, + l.version, + fls.synced_at + FROM libs AS l + JOIN project AS p + ON p.id = l.project_id + LEFT JOIN file_library_sync AS fls + ON fls.file_id = ?::uuid + AND fls.library_file_id = l.id + WHERE l.deleted_at IS NULL;") (defn get-file-libraries [conn file-id] @@ -834,7 +860,7 @@ ;; completly useless (map #(assoc % :is-indirect false)) (map decode-row-features)) - (db/exec! conn [sql:get-file-libraries file-id]))) + (db/exec! conn [sql:get-file-libraries file-id file-id]))) (defn get-resolved-file-libraries "Get all file libraries including itself. Returns an instance of diff --git a/backend/src/app/binfile/v1.clj b/backend/src/app/binfile/v1.clj index 04b390bb99..ae2fc04e0c 100644 --- a/backend/src/app/binfile/v1.clj +++ b/backend/src/app/binfile/v1.clj @@ -40,8 +40,8 @@ [promesa.util :as pu] [yetti.adapter :as yt]) (:import - com.github.luben.zstd.ZstdIOException com.github.luben.zstd.ZstdInputStream + com.github.luben.zstd.ZstdIOException com.github.luben.zstd.ZstdOutputStream java.io.DataInputStream java.io.DataOutputStream @@ -573,7 +573,6 @@ ;; Insert all file relations (doseq [{:keys [library-file-id] :as rel} rels] (let [rel (-> rel - (assoc :synced-at timestamp) (update :file-id bfc/lookup-index) (update :library-file-id bfc/lookup-index))] @@ -583,7 +582,12 @@ :file-id (:file-id rel) :lib-id (:library-file-id rel) ::l/sync? true) - (db/insert! conn :file-library-rel rel)) + (let [rel-params (dissoc rel :synced-at)] + (db/insert! conn :file-library-rel rel-params) + (bfc/upsert-file-library-sync! conn {:file-id (:file-id rel-params) + :library-file-id (:library-file-id rel-params) + :synced-at (or (:synced-at rel) + timestamp)}))) (l/warn :hint "ignoring file library link" :file-id (:file-id rel) diff --git a/backend/src/app/binfile/v2.clj b/backend/src/app/binfile/v2.clj index c8acf2dc99..5adf25bd34 100644 --- a/backend/src/app/binfile/v2.clj +++ b/backend/src/app/binfile/v2.clj @@ -314,10 +314,10 @@ (doseq [rel (read-obj cfg :file-rels file-id)] (let [rel (-> rel (update :file-id bfc/lookup-index) - (update :library-file-id bfc/lookup-index) - (assoc :synced-at timestamp))] + (update :library-file-id bfc/lookup-index))] (db/insert! conn :file-library-rel rel - ::db/return-keys false))) + ::db/return-keys false) + (bfc/upsert-file-library-sync! conn (assoc rel :synced-at timestamp)))) (doseq [media (read-seq cfg :file-media-object file-id)] (let [media (-> media diff --git a/backend/src/app/binfile/v3.clj b/backend/src/app/binfile/v3.clj index 0db826e407..c6d3a7d6bd 100644 --- a/backend/src/app/binfile/v3.clj +++ b/backend/src/app/binfile/v3.clj @@ -281,7 +281,7 @@ thumbnails (bfc/get-file-object-thumbnails cfg file-id)] - (events/tap :progress {:section :file :id file-id}) + (events/tap :progress {:section :file :id file-id :name (:name file)}) (vswap! bfc/*state* update :files assoc file-id {:id file-id @@ -301,6 +301,7 @@ (write-entry! output path file)) (doseq [[index page-id] (d/enumerate pages)] + (let [path (str "files/" file-id "/pages/" page-id ".json") page (get pages-index page-id) objects (:objects page) @@ -311,6 +312,8 @@ (write-entry! output path page) + (events/tap :progress {:section :page :id page-id :name (:name page) :file-id file-id}) + (doseq [[shape-id shape] objects] (let [path (str "files/" file-id "/pages/" page-id "/" shape-id ".json") shape (assoc shape :page-id page-id) @@ -323,6 +326,8 @@ (doseq [{:keys [id] :as media} media] (let [path (str "files/" file-id "/media/" id ".json") media (encode-media media)] + + (events/tap :progress {:section :media :id id :file-id file-id}) (write-entry! output path media))) (doseq [thumbnail thumbnails] @@ -332,11 +337,13 @@ data (-> data (assoc :media-id (:media-id thumbnail)) (encode-file-thumbnail))] + (events/tap :progress {:section :thumbnails :id (:object-id thumbnail) :file-id file-id}) (write-entry! output path data))) (doseq [[id component] components] (let [path (str "files/" file-id "/components/" id ".json") component (encode-component component)] + (events/tap :progress {:section :component :id id :file-id file-id}) (write-entry! output path component))) (doseq [[id color] colors] @@ -347,17 +354,20 @@ (and (contains? color :path) (str/empty? (:path color))) (dissoc :path))] + (events/tap :progress {:section :color :id id :file-id file-id}) (write-entry! output path color))) (doseq [[id object] typographies] (let [path (str "files/" file-id "/typographies/" id ".json") typography (encode-typography object)] + (events/tap :progress {:section :typography :id id :file-id file-id}) (write-entry! output path typography))) (when (and tokens-lib (not (ctob/empty-lib? tokens-lib))) (let [path (str "files/" file-id "/tokens.json") encoded-tokens (encode-tokens-lib tokens-lib)] + (events/tap :progress {:section :tokens-lib :file-id file-id}) (write-entry! output path encoded-tokens))))) (defn- export-files @@ -600,6 +610,7 @@ (let [object (->> (read-entry input entry) (decode-color) (validate-color))] + (events/tap :progress {:section :color :id id :file-id file-id}) (if (= id (:id object)) (assoc result id object) result))) @@ -631,6 +642,7 @@ (clean-component-pre-decode) (decode-component) (clean-component-post-decode))] + (events/tap :progress {:section :component :id id :file-id file-id}) (if (= id (:id object)) (assoc result id object) result))) @@ -644,6 +656,7 @@ (let [object (->> (read-entry input entry) (decode-typography) (validate-typography))] + (events/tap :progress {:section :typography :id id :file-id file-id}) (if (= id (:id object)) (assoc result id object) result))) @@ -653,6 +666,7 @@ (defn- read-file-tokens-lib [{:keys [::bfc/input ::entries]} file-id] (when-let [entry (d/seek (match-tokens-lib-entry-fn file-id) entries)] + (events/tap :progress {:section :tokens-lib :file-id file-id}) (->> (read-plain-entry input entry) (decode-tokens-lib) (validate-tokens-lib)))) @@ -678,6 +692,7 @@ (let [page (->> (read-entry input entry) (decode-page)) page (dissoc page :options)] + (events/tap :progress {:section :page :id id :file-id file-id}) (when (= id (:id page)) (let [objects (read-file-shapes cfg file-id id)] (assoc page :objects objects)))))) @@ -693,6 +708,7 @@ (let [object (->> (read-entry input entry) (decode-file-thumbnail) (validate-file-thumbnail))] + (if (and (= frame-id (:frame-id object)) (= page-id (:page-id object)) (= tag (:tag object))) @@ -733,8 +749,6 @@ (vswap! bfc/*state* update :index bfc/update-index media :id) - (events/tap :progress {:section :media :file-id file-id}) - (doseq [item media] (let [params (-> item (update :id bfc/lookup-index) @@ -742,6 +756,8 @@ (d/update-when :media-id bfc/lookup-index) (d/update-when :thumbnail-id bfc/lookup-index))] + (events/tap :progress {:section :media :id (:id params) :file-id file-id}) + (l/dbg :hint "inserting media object" :file-id (str file-id') :id (str (:id params)) @@ -753,8 +769,6 @@ (db/insert! conn :file-media-object params ::db/on-conflict-do-nothing? (::bfc/overwrite cfg)))) - (events/tap :progress {:section :thumbnails :file-id file-id}) - (doseq [item thumbnails] (let [media-id (bfc/lookup-index (:media-id item)) object-id (-> (assoc item :file-id file-id') @@ -769,6 +783,8 @@ :media-id (str media-id) ::l/sync? true) + (events/tap :progress {:section :thumbnail :file-id file-id :object-id object-id}) + (db/insert! conn :file-tagged-object-thumbnail params ::db/on-conflict-do-nothing? true))) @@ -808,10 +824,10 @@ :file-id (str file-id) :lib-id (str libr-id) ::l/sync? true) - (db/insert! conn :file-library-rel - {:synced-at timestamp - :file-id file-id - :library-file-id libr-id}))))) + (let [rel-params {:file-id file-id + :library-file-id libr-id}] + (db/insert! conn :file-library-rel rel-params) + (bfc/upsert-file-library-sync! conn (assoc rel-params :synced-at timestamp))))))) (defn- import-storage-objects [{:keys [::bfc/input ::entries ::bfc/timestamp] :as cfg}] diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 246d440c73..a4a1b19bd1 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -72,6 +72,7 @@ :telemetry-uri "https://telemetry.penpot.app/" :media-max-file-size (* 1024 1024 30) ; 30MiB + :font-max-file-size (* 1024 1024 30) ; 30MiB :ldap-user-query "(|(uid=:username)(mail=:username))" :ldap-attrs-username "uid" @@ -85,7 +86,11 @@ :email-verify-threshold "15m" :quotes-upload-sessions-per-profile 5 - :quotes-upload-chunks-per-session 20}) + :quotes-upload-chunks-per-session 20 + + ;; SSRF protection + :ssrf-allowed-hosts #{} + :ssrf-extra-blocked-cidrs #{}}) (def schema:config (do #_sm/optional-keys @@ -116,6 +121,7 @@ [:auto-file-snapshot-timeout {:optional true} ::ct/duration] [:media-max-file-size {:optional true} ::sm/int] + [:font-max-file-size {:optional true} ::sm/int] [:deletion-delay {:optional true} ::ct/duration] [:file-clean-delay {:optional true} ::ct/duration] [:telemetry-enabled {:optional true} ::sm/boolean] @@ -245,17 +251,26 @@ [:objects-storage-fs-directory {:optional true} :string] [:objects-storage-s3-bucket {:optional true} :string] [:objects-storage-s3-region {:optional true} :keyword] - [:objects-storage-s3-endpoint {:optional true} ::sm/uri]])) + [:objects-storage-s3-endpoint {:optional true} ::sm/uri] + + ;; SSRF protection + [:ssrf-allowed-hosts {:optional true} [::sm/set :string]] + [:ssrf-extra-blocked-cidrs {:optional true} [::sm/set :string]]])) (defn- parse-flags [config] (let [public-uri (c/get config :public-uri) public-uri (some-> public-uri (u/uri)) - extra-flags (if (and public-uri - (= (:scheme public-uri) "http") - (not= (:host public-uri) "localhost")) - #{:disable-secure-session-cookies} - #{})] + extra-flags (cond-> #{} + ;; When public-uri is http (non-localhost), disable secure cookies + (and public-uri + (= (:scheme public-uri) "http") + (not= (:host public-uri) "localhost")) + (conj :disable-secure-session-cookies) + + ;; When telemetry-enabled config is true, add :telemetry flag + (true? (c/get config :telemetry-enabled)) + (conj :enable-telemetry))] (flags/parse flags/default extra-flags (:flags config)))) (defn read-env @@ -280,7 +295,7 @@ (sm/explainer schema:config)) (defn read-config - "Reads the configuration from enviroment variables and decodes all + "Reads the configuration from environment variables and decodes all known values." [& {:keys [prefix default] :or {prefix "penpot"}}] (->> (read-env prefix) diff --git a/backend/src/app/db.clj b/backend/src/app/db.clj index b00f84e3e2..c23ea07524 100644 --- a/backend/src/app/db.clj +++ b/backend/src/app/db.clj @@ -36,11 +36,11 @@ java.sql.Connection java.sql.PreparedStatement java.sql.Savepoint - org.postgresql.PGConnection org.postgresql.geometric.PGpoint org.postgresql.jdbc.PgArray org.postgresql.largeobject.LargeObject org.postgresql.largeobject.LargeObjectManager + org.postgresql.PGConnection org.postgresql.util.PGInterval org.postgresql.util.PGobject)) diff --git a/backend/src/app/email.clj b/backend/src/app/email.clj index f44e80c6c6..7496738e6d 100644 --- a/backend/src/app/email.clj +++ b/backend/src/app/email.clj @@ -22,15 +22,34 @@ [cuerdas.core :as str] [integrant.core :as ig]) (:import - jakarta.mail.Message$RecipientType - jakarta.mail.Session - jakarta.mail.Transport jakarta.mail.internet.InternetAddress jakarta.mail.internet.MimeBodyPart jakarta.mail.internet.MimeMessage jakarta.mail.internet.MimeMultipart + jakarta.mail.Message$RecipientType + jakarta.mail.Session + jakarta.mail.Transport java.util.Properties)) +(defn clean + "Clean and normalizes email address string" + [email] + (let [email (str/lower email) + email (if (str/starts-with? email "mailto:") + (subs email 7) + email) + email (if (or (str/starts-with? email "<") + (str/ends-with? email ">")) + (str/trim email "<>") + email)] + email)) + +(defn get-domain + [email] + (let [email (clean email) + [_ domain] (str/split email "@" 2)] + domain)) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; EMAIL IMPL ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -416,8 +435,8 @@ [:map [:invited-by ::sm/text] [:organization-name ::sm/text] - [:org-initials ::sm/text] - [:org-logo ::sm/uri] + [:organization-initials [:maybe :string]] + [:organization-logo ::sm/uri] [:user-name [:maybe ::sm/text]] [:token ::sm/text]]) diff --git a/backend/src/app/email/blacklist.clj b/backend/src/app/email/blacklist.clj index ca80afb6c9..a07dfccf91 100644 --- a/backend/src/app/email/blacklist.clj +++ b/backend/src/app/email/blacklist.clj @@ -36,10 +36,18 @@ :cause cause))))) (defn contains? - "Check if email is in the blacklist." + "Check if email is in the blacklist. Also matches subdomains: if + 'somedomain.com' is blacklisted, 'xxx@foo.somedomain.com' will also + be rejected." [{:keys [::email/blacklist]} email] - (let [[_ domain] (str/split email "@" 2)] - (c/contains? blacklist (str/lower domain)))) + (let [[_ domain] (str/split email "@" 2) + parts (str/split (str/lower domain) #"\.")] + (loop [parts parts] + (if (empty? parts) + false + (if (c/contains? blacklist (str/join "." parts)) + true + (recur (rest parts))))))) (defn enabled? "Check if the blacklist is enabled" diff --git a/backend/src/app/features/file_snapshots.clj b/backend/src/app/features/file_snapshots.clj index 192030cbf8..e013b90d00 100644 --- a/backend/src/app/features/file_snapshots.clj +++ b/backend/src/app/features/file_snapshots.clj @@ -112,8 +112,9 @@ THEN (c.deleted_at IS NULL OR c.deleted_at >= ?::timestamptz) END")) -(defn- get-snapshot - "Get snapshot with decoded data" +(defn get-snapshot-data + "Get a fully decoded snapshot for read-only preview or restoration. + Returns the snapshot map with decoded :data field." [cfg file-id snapshot-id] (let [now (ct/now)] (->> (db/get-with-sql cfg [sql:get-snapshot file-id snapshot-id now] @@ -326,7 +327,7 @@ (sto/resolve cfg {::db/reuse-conn true}) snapshot - (get-snapshot cfg file-id snapshot-id)] + (get-snapshot-data cfg file-id snapshot-id)] (when-not snapshot (ex/raise :type :not-found diff --git a/backend/src/app/http/assets.clj b/backend/src/app/http/assets.clj index c716ce8b85..430ee2bbc3 100644 --- a/backend/src/app/http/assets.clj +++ b/backend/src/app/http/assets.clj @@ -12,43 +12,57 @@ [app.common.time :as ct] [app.common.uri :as u] [app.db :as db] + [app.http.access-token :as actoken] + [app.http.session :as session] [app.storage :as sto] [integrant.core :as ig] [yetti.response :as-alias yres])) -(def ^:private cache-max-age +(def ^:private default-cache-max-age (ct/duration {:hours 24})) -(def ^:private signature-max-age +(def ^:private default-signature-max-age (ct/duration {:hours 24 :minutes 15})) +;; Buckets that are legitimately public and do not require authentication. +;; These are used by public shared board viewing, profile photos in UI, +;; and embedded export/binfile flows. +(def ^:private public-buckets + #{"file-media-object" + "file-object-thumbnail" + "team-font-variant" + "file-data-fragment"}) + (defn get-id [{:keys [path-params]}] (or (some-> path-params :id d/parse-uuid) (ex/raise :type :not-found - :hunt "object not found"))) + :hint "object not found"))) (defn- get-file-media-object [pool id] (db/get pool :file-media-object {:id id} {::db/remove-deleted false})) (defn- serve-object-from-s3 - [{:keys [::sto/storage] :as cfg} obj] - (let [{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age signature-max-age})] + [{:keys [::sto/storage ::signature-max-age ::cache-max-age] :as cfg} obj] + (let [sig-max-age (or signature-max-age default-signature-max-age) + cch-max-age (or cache-max-age default-cache-max-age) + {:keys [host port] :as url} (sto/get-object-url storage obj {:max-age sig-max-age})] {::yres/status 307 ::yres/headers {"location" (str url) "x-host" (cond-> host port (str ":" port)) "x-mtype" (-> obj meta :content-type) - "cache-control" (str "max-age=" (inst-ms cache-max-age))}})) + "cache-control" (str "max-age=" (inst-ms cch-max-age))}})) (defn- serve-object-from-fs - [{:keys [::path]} obj] - (let [purl (u/join (u/uri path) + [{:keys [::path ::cache-max-age]} obj] + (let [cch-max-age (or cache-max-age default-cache-max-age) + purl (u/join (u/uri path) (sto/object->relative-path obj)) mdata (meta obj) headers {"x-accel-redirect" (:path purl) "content-type" (:content-type mdata) - "cache-control" (str "max-age=" (inst-ms cache-max-age))}] + "cache-control" (str "max-age=" (inst-ms cch-max-age))}] {::yres/status 204 ::yres/headers headers})) @@ -60,14 +74,36 @@ (:s3 :assets-s3) (serve-object-from-s3 cfg obj) (:fs :assets-fs) (serve-object-from-fs cfg obj))) +(defn- requires-auth? + "Check if the storage object requires authentication based on its bucket." + [obj] + (let [bucket (-> obj meta :bucket)] + (not (contains? public-buckets bucket)))) + +(defn- authenticated? + "Check if the request has an authenticated profile, either via session + or access token." + [request] + (or (some? (::session/profile-id request)) + (some? (::actoken/profile-id request)))) + (defn objects-handler - "Handler that servers storage objects by id." + "Handler that serves storage objects by id. + For non-public buckets (e.g. profile), requires authentication + via session cookie or access token." [{:keys [::sto/storage] :as cfg} request] (let [id (get-id request) obj (sto/get-object storage id)] - (if obj - (serve-object cfg obj) - {::yres/status 404}))) + (cond + (nil? obj) + {::yres/status 404} + + (and (requires-auth? obj) + (not (authenticated? request))) + {::yres/status 401} + + :else + (serve-object cfg obj)))) (defn- generic-handler "A generic handler helper/common code for file-media based handlers." @@ -96,11 +132,13 @@ (defmethod ig/assert-key ::routes [_ params] (assert (sto/valid-storage? (::sto/storage params)) "expected valid storage instance") + (assert (session/manager? (::session/manager params)) "expected valid session manager") (assert (string? (::path params)))) (defmethod ig/init-key ::routes [_ cfg] - ["/assets" + ["/assets" {:middleware [[session/authz cfg] + [actoken/authz cfg]]} ["/by-id/:id" {:handler (partial objects-handler cfg)}] ["/by-file-media-id/:id" {:handler (partial file-objects-handler cfg)}] ["/by-file-media-id/:id/thumbnail" {:handler (partial file-thumbnails-handler cfg)}]]) diff --git a/backend/src/app/http/awsns.clj b/backend/src/app/http/awsns.clj index 051a2ecf8b..c90b0d7a82 100644 --- a/backend/src/app/http/awsns.clj +++ b/backend/src/app/http/awsns.clj @@ -53,7 +53,7 @@ (let [surl (get body "SubscribeURL") stopic (get body "TopicArn")] (l/info :action "subscription received" :topic stopic :url surl) - (http/req! cfg {:uri surl :method :post :timeout 10000} {:sync? true})) + (http/req cfg {:uri surl :method :post :timeout 10000} {:sync? true})) (= mtype "Notification") (when-let [message (parse-json (get body "Message"))] diff --git a/backend/src/app/http/client.clj b/backend/src/app/http/client.clj index 7fcbd3ff00..5e10ee4c8f 100644 --- a/backend/src/app/http/client.clj +++ b/backend/src/app/http/client.clj @@ -5,13 +5,24 @@ ;; Copyright (c) KALEIDOS INC (ns app.http.client - "Http client abstraction layer." + "Http client abstraction layer. + + All outbound requests made through `req` and `req-with-redirects` + are validated against the SSRF blocklist by default. Pass + `:skip-ssrf-check? true` in the options map only when the target + is a well-known, operator-configured endpoint that cannot be + influenced by user input (e.g. internal telemetry, error webhooks)." (:require [app.common.schema :as sm] + [app.util.ssrf :as ssrf] + [cuerdas.core :as str] [integrant.core :as ig] [java-http-clj.core :as http]) (:import - java.net.http.HttpClient)) + java.net.http.HttpClient + java.net.URI)) + +(def default-max-redirects 5) (defn client? [o] @@ -23,8 +34,8 @@ (defmethod ig/init-key ::client [_ _] - (http/build-client {:connect-timeout 30000 ;; 10s - :follow-redirects :always})) + (http/build-client {:connect-timeout 30000 + :follow-redirects :never})) (defn send! ([client req] (send! client req {})) @@ -44,14 +55,82 @@ :else (throw (UnsupportedOperationException. "invalid arguments")))) -(defn req! - "A convencience toplevel function for gradual migration to a new API - convention." +(defn req + "Issue a single HTTP request. SSRF validation is applied to the + target URI by default; pass `:skip-ssrf-check? true` in `options` + to bypass it for known-safe, operator-configured endpoints." ([cfg-or-client request] - (let [client (resolve-client cfg-or-client) - request (update request :uri str)] - (send! client request {}))) - ([cfg-or-client request options] - (let [client (resolve-client cfg-or-client) - request (update request :uri str)] - (send! client request options)))) + (req cfg-or-client request {})) + ([cfg-or-client request {:keys [skip-ssrf-check?] :as options}] + (let [request (if skip-ssrf-check? + (update request :uri str) + (update request :uri ssrf/validate-uri)) + client (resolve-client cfg-or-client)] + (send! client request (dissoc options :skip-ssrf-check?))))) + +(defn- resolve-location + "Resolve a Location header value against the original request URI. + Handles: + - Absolute URLs (http:// or https://) — returned as-is. + - Protocol-relative URLs (//host/path) — inherit the scheme from base-uri. + - Path-absolute and relative URLs — resolved against base-uri via URI.resolve." + [^String base-uri ^String location] + (cond + (or (str/starts-with? location "http://") + (str/starts-with? location "https://")) + location + + (str/starts-with? location "//") + (let [scheme (.getScheme (URI. base-uri))] + (str scheme ":" location)) + + :else + (str (.resolve (URI. base-uri) location)))) + +(defn- redirect-request + "Build the next request for a 3xx redirect. + Per RFC 7231 §6.4: + - 303 always issues GET (body dropped). + - 301/302 with non-GET/HEAD methods: downgrade to GET (body dropped). + - 307/308 preserve the original method and body. + The Location URI has already been resolved by the caller." + [orig-request ^String next-uri status] + (let [method (:method orig-request)] + (if (or (= status 303) + (and (contains? #{301 302} status) + (not (contains? #{:get :head} method)))) + ;; Downgrade to GET, drop body and content-type + (-> orig-request + (assoc :uri next-uri :method :get) + (dissoc :body) + (update :headers dissoc "content-type" "content-length")) + ;; Preserve method/body (307, 308, or GET/HEAD 301/302) + (assoc orig-request :uri next-uri)))) + +(defn req-with-redirects + "Like `req`, but follows up to `max-redirects` HTTP 3xx redirects. + SSRF validation is applied before every hop (initial request and + each redirect target) unless `:skip-ssrf-check? true` is passed. + Redirect semantics follow RFC 7231 §6.4: 301/302 POST is downgraded + to GET; 303 always uses GET; 307/308 preserve the original method." + ([cfg-or-client request] + (req-with-redirects cfg-or-client request {})) + ([cfg-or-client request {:keys [max-redirects skip-ssrf-check?] + :or {max-redirects default-max-redirects} + :as opts}] + (let [send-opts (dissoc opts :max-redirects :skip-ssrf-check?) + uri-coerce (if skip-ssrf-check? str ssrf/validate-uri)] + (loop [current-req (update request :uri uri-coerce) + hops 0] + (let [client (resolve-client cfg-or-client) + resp (send! client current-req send-opts) + status (:status resp)] + (if (and (<= 300 status 399) + (< hops max-redirects)) + (if-let [location (get-in resp [:headers "location"])] + (let [next-uri (resolve-location (str (:uri current-req)) location)] + (recur (update (redirect-request current-req next-uri status) :uri uri-coerce) + (inc hops))) + ;; No Location header on a 3xx — return the response as-is + resp) + resp)))))) diff --git a/backend/src/app/http/errors.clj b/backend/src/app/http/errors.clj index bcf6df27f6..bf6066c66f 100644 --- a/backend/src/app/http/errors.clj +++ b/backend/src/app/http/errors.clj @@ -144,6 +144,15 @@ {::yres/status 404 ::yres/body (ex-data err)}) +(defmethod handle-error :nitrate-unavailable + [err request _] + (binding [l/*context* (request->context request)] + (l/warn :hint "nitrate is unreachable; blocking request" :cause err) + ;; Do not leak Nitrate's internal URL/status to the client; the + ;; full context is already logged above for operators. + {::yres/status 503 + ::yres/body {:type :nitrate-unavailable}})) + (defmethod handle-error :internal [error request parent-cause] (binding [l/*context* (request->context request)] diff --git a/backend/src/app/http/sse.clj b/backend/src/app/http/sse.clj index 765f0c894d..2bc76dc57b 100644 --- a/backend/src/app/http/sse.clj +++ b/backend/src/app/http/sse.clj @@ -21,7 +21,7 @@ (defn- write! [^OutputStream output ^bytes data] - (l/trc :hint "writting data" :data data :length (alength data)) + (l/trc :hint "writing data" :data data :length (alength data)) (.write output data) (.flush output)) diff --git a/backend/src/app/loggers/audit.clj b/backend/src/app/loggers/audit.clj index 89119b04e1..bc40efd78d 100644 --- a/backend/src/app/loggers/audit.clj +++ b/backend/src/app/loggers/audit.clj @@ -16,12 +16,12 @@ [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] + [app.email :as email] [app.http :as-alias http] [app.http.access-token :as-alias actoken] [app.loggers.audit.tasks :as-alias tasks] [app.loggers.webhooks :as-alias webhooks] [app.rpc :as-alias rpc] - [app.rpc.retry :as rtry] [app.setup :as-alias setup] [app.util.inet :as inet] [app.util.services :as-alias sv] @@ -33,6 +33,63 @@ ;; HELPERS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(def ^:private filter-auth-events + #{"login-with-oidc" "login-with-password" "register-profile" "update-profile"}) + +(def ^:private safe-backend-context-keys + #{:version + :initiator + :client-version + :client-user-agent}) + +(def ^:private safe-frontend-context-keys + #{:version + :locale + :browser + :browser-version + :engine + :engine-version + :os + :os-version + :device-type + :device-arch + :screen-width + :screen-height + :screen-color-depth + :screen-orientation + :event-origin + :event-namespace + :event-symbol}) + +(def profile-props + [:id + :is-active + :is-muted + :auth-backend + :email + :default-team-id + :default-project-id + :fullname + :lang]) + +(def ^:private event-keys + #{:id + :name + :type + :profile-id + :ip-addr + :props + :context + :source + :tracked-at + :created-at}) + +(def reserved-props + #{:session-id + :password + :old-password + :token}) + (defn extract-utm-params "Extracts additional data from params and namespace them under `penpot` ns." @@ -47,17 +104,6 @@ (assoc (->> sk str/kebab (keyword "penpot")) v))))] (reduce-kv process-param {} params))) -(def profile-props - [:id - :is-active - :is-muted - :auth-backend - :email - :default-team-id - :default-project-id - :fullname - :lang]) - (defn profile->props [profile] (-> profile @@ -65,12 +111,6 @@ (merge (:props profile)) (d/without-nils))) -(def reserved-props - #{:session-id - :password - :old-password - :token}) - (defn clean-props [props] (into {} @@ -121,15 +161,16 @@ (def ^:private schema:event [:map {:title "AuditEvent"} - [::type ::sm/text] - [::name ::sm/text] - [::profile-id ::sm/uuid] - [::ip-addr {:optional true} ::sm/text] - [::props {:optional true} [:map-of :keyword :any]] - [::context {:optional true} [:map-of :keyword :any]] - [::tracked-at {:optional true} ::ct/inst] - [::created-at {:optional true} ::ct/inst] - [::source {:optional true} ::sm/text] + [:id {:optional true} ::sm/uuid] + [:type ::sm/text] + [:name ::sm/text] + [:profile-id ::sm/uuid] + [:props [:map-of :keyword :any]] + [:context [:map-of :keyword :any]] + [:tracked-at ::ct/inst] + [:created-at ::ct/inst] + [:source ::sm/text] + [:ip-addr {:optional true} ::sm/text] [::webhooks/event? {:optional true} ::sm/boolean] [::webhooks/batch-timeout {:optional true} ::ct/duration] [::webhooks/batch-key {:optional true} @@ -141,7 +182,156 @@ (def valid-event? (sm/validator schema:event)) -(defn prepare-event +(defn- prepare-context-from-request + "Prepare backend event context from request" + [request] + (let [client-event-origin (get-client-event-origin request) + client-version (get-client-version request) + client-user-agent (get-client-user-agent request) + session-id (get-external-session-id request) + key-id (::http/auth-key-id request) + token-id (::actoken/id request) + token-type (::actoken/type request)] + {:external-session-id session-id + :initiator (or key-id "app") + :access-token-id (some-> token-id str) + :access-token-type (some-> token-type str) + :client-event-origin client-event-origin + :client-user-agent client-user-agent + :client-version client-version + :version (:full cf/version)})) + +(defn- append-audit-entry + [cfg params] + (let [params (-> params + (assoc :id (uuid/next)) + (update :props db/tjson) + (update :context db/tjson) + (update :ip-addr db/inet)) + params (select-keys params event-keys)] + (db/insert! cfg :audit-log params))) + +(def ^:private xf:filter-telemetry-props + "Transducer that keeps only map entries whose values are UUIDs, + booleans or numbers." + (filter (fn [[k v]] + (and (simple-keyword? k) + (or (uuid? v) (boolean? v) (number? v)))))) + +(declare filter-telemetry-props) +(declare filter-telemetry-context) + +(defn- process-event + [cfg event] + (when (contains? cf/flags :audit-log-logger) + (l/log! ::l/logger "app.audit" + ::l/level :info + :profile-id (str (:profile-id event)) + :ip-addr (str (:ip-addr event)) + :type (:type event) + :name (:name event) + :props (json/encode (:props event) :key-fn json/write-camel-key) + :context (json/encode (:context event) :key-fn json/write-camel-key))) + + (when (contains? cf/flags :audit-log) + (append-audit-entry cfg event)) + + (when (contains? cf/flags :telemetry) + ;; NOTE: when both audit-log and telemetry are enabled, events are stored + ;; twice: once with full details (above) and once stripped of props and + ;; ip-addr, tagged with source="telemetry" so the telemetry task can + ;; collect and ship them. The profile-id is preserved (UUIDs are already + ;; anonymous random identifiers). Only a safe subset of context fields + ;; is kept: initiator, version, client-version and client-user-agent. + ;; Timestamps are truncated to day precision to avoid leaking exact event + ;; timing. + (let [event (-> event + (filter-telemetry-props) + (filter-telemetry-context) + (update :created-at ct/truncate :days) + (update :tracked-at ct/truncate :days) + (assoc :source "telemetry:backend") + (assoc :ip-addr "0.0.0.0"))] + (append-audit-entry cfg event))) + + (when (and (contains? cf/flags :webhooks) + (::webhooks/event? event)) + (let [batch-key (::webhooks/batch-key event) + batch-timeout (::webhooks/batch-timeout event) + label (dm/str "rpc:" (:name event)) + label (cond + (ifn? batch-key) (dm/str label ":" (batch-key (::rpc/params event))) + (string? batch-key) (dm/str label ":" batch-key) + :else label) + dedupe? (boolean (and batch-key batch-timeout))] + + (wrk/submit! (-> cfg + (assoc ::wrk/task :process-webhook-event) + (assoc ::wrk/queue :webhooks) + (assoc ::wrk/max-retries 0) + (assoc ::wrk/delay (or batch-timeout 0)) + (assoc ::wrk/dedupe dedupe?) + (assoc ::wrk/label label) + (assoc ::wrk/params (-> event + (d/without-qualified) + (dissoc :source) + (dissoc :context) + (dissoc :ip-addr) + (dissoc :type))))))) + event) + +(defn submit* + "A public API, lower-level than submit, assumes all required fields are filled" + [cfg event] + (try + (let [event (check-event event)] + (db/tx-run! cfg process-event event)) + (catch Throwable cause + (l/error :hint "unexpected error processing event" :cause cause)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; PUBLIC API +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn filter-telemetry-props + [{:keys [source name props type] :as params}] + (cond + (or (and (= source "frontend") + (= type "identify")) + (and (= source "backend") + (filter-auth-events name))) + + (let [props' (into {} xf:filter-telemetry-props props) + props' (-> props' + (assoc :lang (:lang props)) + (assoc :auth-backend (:auth-backend props)) + (assoc :email-domain (email/get-domain (:email props))) + (d/without-nils))] + (assoc params :props props')) + + (and (= source "backend") + (= type "trigger") + (= name "instance-start")) + params + + (and (= source "frontend") + (= type "action") + (= name "navigate")) + (assoc params :props (select-keys props [:route :file-id :team-id :page-id])) + + :else + (let [props (into {} xf:filter-telemetry-props props)] + (assoc params :props props)))) + +(defn filter-telemetry-context + [{:keys [source context] :as params}] + (let [context (case source + "backend" (select-keys context safe-backend-context-keys) + "frontend" (select-keys context safe-frontend-context-keys) + {})] + (assoc params :context context))) + +(defn prepare-rpc-event [cfg mdata params result] (let [resultm (meta result) request (-> params meta ::http/request) @@ -154,23 +344,29 @@ (merge params (::props resultm))) (clean-props)) - context (merge (::context resultm) - (prepare-context-from-request request)) + context (-> (::context resultm) + (merge (prepare-context-from-request request)) + (assoc :request-id (::rpc/request-id params)) + (d/without-nils)) + ip-addr (inet/parse-request request) module (get cfg ::rpc/module)] - {::type (or (::type resultm) - (::rpc/type cfg)) - ::name (or (::name resultm) - (let [sname (::sv/name mdata)] - (if (not= module "main") - (str module "-" sname) - sname))) + {:type (or (::type resultm) + (::rpc/type cfg)) + :name (or (::name resultm) + (let [sname (::sv/name mdata)] + (if (not= module "main") + (str module "-" sname) + sname))) - ::profile-id profile-id - ::ip-addr ip-addr - ::props props - ::context context + :profile-id profile-id + :ip-addr ip-addr + :props props + :context context + + :created-at (::rpc/request-at params) + :tracked-at (::rpc/request-at params) ;; NOTE: for batch-key lookup we need the params as-is ;; because the rpc api does not need to know the @@ -190,148 +386,49 @@ (::webhooks/event? resultm) false)})) -(defn- prepare-context-from-request - "Prepare backend event context from request" - [request] - (let [client-event-origin (get-client-event-origin request) - client-version (get-client-version request) - client-user-agent (get-client-user-agent request) - session-id (get-external-session-id request) - key-id (::http/auth-key-id request) - token-id (::actoken/id request) - token-type (::actoken/type request)] - (d/without-nils - {:external-session-id session-id - :initiator (or key-id "app") - :access-token-id (some-> token-id str) - :access-token-type (some-> token-type str) - :client-event-origin client-event-origin - :client-user-agent client-user-agent - :client-version client-version - :version (:full cf/version)}))) - (defn event-from-rpc-params "Create a base event skeleton with pre-filled some important data that can be extracted from RPC params object" [params] - (let [context (some-> params meta ::http/request prepare-context-from-request) - event {::type "action" - ::profile-id (or (::rpc/profile-id params) uuid/zero) - ::ip-addr (::rpc/ip-addr params)}] - (cond-> event - (some? context) - (assoc ::context context)))) + (let [context (some-> params meta ::http/request prepare-context-from-request) + context (assoc context :request-id (::rpc/request-id params)) + request-at (::rpc/request-at params)] + {:type "action" + :profile-id (::rpc/profile-id params) + :created-at request-at + :tracked-at request-at + :ip-addr (::rpc/ip-addr params) + :context (d/without-nils context)})) -(defn- event->params - [event] - (let [params {:id (uuid/next) - :name (::name event) - :type (::type event) - :profile-id (::profile-id event) - :ip-addr (::ip-addr event) - :context (::context event {}) - :props (::props event {}) - :source "backend"} - tnow (::tracked-at event)] - - (cond-> params - (some? tnow) - (assoc :tracked-at tnow)))) - -(defn- append-audit-entry - [cfg params] - (let [params (-> params - (update :props db/tjson) - (update :context db/tjson) - (update :ip-addr db/inet))] - (db/insert! cfg :audit-log params))) - -(defn- handle-event! +(defn submit + "Submit an event to be registered under audit-log subsystem" [cfg event] - (let [tnow (ct/now) - params (-> (event->params event) - (assoc :created-at tnow) - (update :tracked-at #(or % tnow)))] + (let [tnow (ct/now) + event (-> event + (assoc :created-at tnow) + (update :profile-id d/nilv uuid/zero) + (update :tracked-at d/nilv tnow) + (update :ip-addr d/nilv "0.0.0.0") + (update :props d/nilv {}) + (update :context d/nilv {}) + (assoc :source "backend") + (d/without-nils))] + (submit* cfg event))) - (when (contains? cf/flags :audit-log-logger) - (l/log! ::l/logger "app.audit" - ::l/level :info - :profile-id (str (::profile-id event)) - :ip-addr (str (::ip-addr event)) - :type (::type event) - :name (::name event) - :props (json/encode (::props event) :key-fn json/write-camel-key) - :context (json/encode (::context event) :key-fn json/write-camel-key))) - - (when (contains? cf/flags :audit-log) - ;; NOTE: this operation may cause primary key conflicts on inserts - ;; because of the timestamp precission (two concurrent requests), in - ;; this case we just retry the operation. - (append-audit-entry cfg params)) - - (when (and (or (contains? cf/flags :telemetry) - (cf/get :telemetry-enabled)) - (not (contains? cf/flags :audit-log))) - ;; NOTE: this operation may cause primary key conflicts on inserts - ;; because of the timestamp precission (two concurrent requests), in - ;; this case we just retry the operation. - ;; - ;; NOTE: this is only executed when general audit log is disabled - (let [params (-> params - (assoc :props {}) - (assoc :context {}))] - (append-audit-entry cfg params))) - - (when (and (contains? cf/flags :webhooks) - (::webhooks/event? event)) - (let [batch-key (::webhooks/batch-key event) - batch-timeout (::webhooks/batch-timeout event) - label (dm/str "rpc:" (:name params)) - label (cond - (ifn? batch-key) (dm/str label ":" (batch-key (::rpc/params event))) - (string? batch-key) (dm/str label ":" batch-key) - :else label) - dedupe? (boolean (and batch-key batch-timeout))] - - (wrk/submit! (-> cfg - (assoc ::wrk/task :process-webhook-event) - (assoc ::wrk/queue :webhooks) - (assoc ::wrk/max-retries 0) - (assoc ::wrk/delay (or batch-timeout 0)) - (assoc ::wrk/dedupe dedupe?) - (assoc ::wrk/label label) - (assoc ::wrk/params (-> params - (dissoc :source) - (dissoc :context) - (dissoc :ip-addr) - (dissoc :type))))))) - params)) - -(defn submit! - "Submit audit event to the collector." - [cfg event] - (try - (let [event (-> (d/without-nils event) - (check-event)) - cfg (-> cfg - (assoc ::rtry/when rtry/conflict-exception?) - (assoc ::rtry/max-retries 6) - (assoc ::rtry/label "persist-audit-log"))] - (rtry/invoke! cfg db/tx-run! handle-event! event)) - (catch Throwable cause - (l/error :hint "unexpected error processing event" :cause cause)))) - -(defn insert! +(defn insert "Submit audit event to the collector, intended to be used only from command line helpers because this skips all webhooks and telemetry logic." [cfg event] (when (contains? cf/flags :audit-log) - (let [event (-> (d/without-nils event) + (let [tnow (ct/now) + event (-> event + (assoc :created-at tnow) + (update :tracked-at d/nilv tnow) + (update :profile-id d/nilv uuid/zero) + (update :props d/nilv {}) + (update :context d/nilv {}) + (assoc :source "backend") + (select-keys event-keys) (check-event))] - (db/run! cfg (fn [cfg] - (let [tnow (ct/now) - params (-> (event->params event) - (assoc :created-at tnow) - (update :tracked-at #(or % tnow)))] - (append-audit-entry cfg params))))))) + (db/run! cfg append-audit-entry event)))) diff --git a/backend/src/app/loggers/audit/archive_task.clj b/backend/src/app/loggers/audit/archive_task.clj index 1915652bbd..b421df9531 100644 --- a/backend/src/app/loggers/audit/archive_task.clj +++ b/backend/src/app/loggers/audit/archive_task.clj @@ -59,7 +59,7 @@ :method :post :headers headers :body body} - resp (http/req! cfg params)] + resp (http/req cfg params {:skip-ssrf-check? true})] (if (= (:status resp) 204) true diff --git a/backend/src/app/loggers/database.clj b/backend/src/app/loggers/database.clj index 4014fe498d..d8bd1390e7 100644 --- a/backend/src/app/loggers/database.clj +++ b/backend/src/app/loggers/database.clj @@ -97,7 +97,7 @@ (l/warn :hint "unexpected exception on database error logger" :cause cause)))) (defn- audit-event->report - [{:keys [::audit/context ::audit/props ::audit/ip-addr] :as record}] + [{:keys [context props ip-addr] :as record}] (let [context (reduce-kv (fn [context k v] (let [k' (keyword "frontend" (name k))] @@ -117,14 +117,14 @@ {:context (-> (into (sorted-map) context) (pp/pprint-str :length 50)) - :origin (::audit/name record) + :origin (:name record) :href (get props :href) :hint (get props :hint) :report (get props :report)})) (defn- handle-audit-event "Convert the log record into a report object and persist it on the database" - [{:keys [::db/pool]} {:keys [::audit/id] :as event}] + [{:keys [::db/pool]} {:keys [id] :as event}] (try (let [uri (cf/get :public-uri) report (-> event audit-event->report d/without-nils)] @@ -189,12 +189,12 @@ (::l/id item) (handle-log-record cfg item) - (::audit/id item) - (handle-audit-event cfg item) - (::rlimit/id item) (handle-rlimit-event cfg item) + (-> item meta ::audit/event) + (handle-audit-event cfg item) + :else (l/warn :hint "received unexpected item" :item item)) @@ -226,4 +226,3 @@ [cfg event] (when-let [{:keys [::input]} (get cfg ::reporter)] (sp/put! input event))) - diff --git a/backend/src/app/loggers/mattermost.clj b/backend/src/app/loggers/mattermost.clj index c2fd3d1dfb..a19bf39836 100644 --- a/backend/src/app/loggers/mattermost.clj +++ b/backend/src/app/loggers/mattermost.clj @@ -52,12 +52,12 @@ trace "```"))) - resp (http/req! cfg - {:uri (cf/get :error-report-webhook) - :method :post - :headers {"content-type" "application/json"} - :body (json/encode-str {:text text})} - {:sync? true})] + resp (http/req cfg + {:uri (cf/get :error-report-webhook) + :method :post + :headers {"content-type" "application/json"} + :body (json/encode-str {:text text})} + {:sync? true})] (when (not= 200 (:status resp)) (l/warn :hint "error on sending data" @@ -83,7 +83,7 @@ :trace (ex/format-throwable cause :detail? false :header? false)})) (defn- audit-event->report - [{:keys [::audit/context ::audit/props ::audit/id] :as event}] + [{:keys [context props id] :as event}] {:id id :type "exception" :origin "audit-log" @@ -92,7 +92,7 @@ :host (cf/get :host) :backend-version (:full cf/version) :frontend-version (:version context) - :profile-id (:audit/profile-id event) + :profile-id (:profile-id event) :href (get props :href)}) (defn- rlimit-event->report @@ -148,12 +148,12 @@ (::l/id item) (handle-event cfg item log-record->report) - (::audit/id item) - (handle-event cfg item audit-event->report) - (::rlimit/id item) (handle-event cfg item rlimit-event->report) + (-> item meta ::audit/event) + (handle-event cfg item audit-event->report) + :else (l/warn :hint "received unexpected item" :item item))) diff --git a/backend/src/app/loggers/webhooks.clj b/backend/src/app/loggers/webhooks.clj index 8edb646260..2f89876e04 100644 --- a/backend/src/app/loggers/webhooks.clj +++ b/backend/src/app/loggers/webhooks.clj @@ -70,14 +70,14 @@ (fn [{:keys [props] :as task}] (let [items (lookup-webhooks cfg props) - event {::audit/profile-id (:profile-id props) - ::audit/name "webhook" - ::audit/type "trigger" - ::audit/props {:name (get props :name) - :event-id (get props :id) - :total-affected (count items)}}] + event {:profile-id (:profile-id props) + :name "webhook" + :type "trigger" + :props {:name (get props :name) + :event-id (get props :id) + :total-affected (count items)}}] - (audit/insert! cfg event) + (audit/insert cfg event) (when items (l/trc :hint "webhooks found for event" :total (count items)) @@ -159,7 +159,7 @@ :method :post :body body}] (try - (let [rsp (http/req! cfg req {:response-type :input-stream :sync? true}) + (let [rsp (http/req cfg req {:response-type :input-stream :sync? true}) err (interpret-response rsp)] (report-delivery! whook req rsp err) (update-webhook! whook err)) @@ -190,4 +190,11 @@ "invalid-uri" (instance? java.net.http.HttpConnectTimeoutException cause) - "timeout")) + "timeout" + + :else + (let [data (ex-data cause)] + (if (and (= :validation (:type data)) + (= :ssrf-blocked-target (:code data))) + (str "blocked-request:" (:hint data)) + nil)))) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index a1501e3ca0..940775bdf0 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -61,21 +61,15 @@ ::mdef/help "A total number of bytes processed by update-file." ::mdef/type :counter} - :rpc-mutation-timing - {::mdef/name "penpot_rpc_mutation_timing" - ::mdef/help "RPC mutation method call timing." + :rpc-main-timing + {::mdef/name "penpot_rpc_main_timing" + ::mdef/help "RPC command method call timing for main" ::mdef/labels ["name"] ::mdef/type :histogram} - :rpc-command-timing - {::mdef/name "penpot_rpc_command_timing" - ::mdef/help "RPC command method call timing." - ::mdef/labels ["name"] - ::mdef/type :histogram} - - :rpc-query-timing - {::mdef/name "penpot_rpc_query_timing" - ::mdef/help "RPC query method call timing." + :rpc-management-timing + {::mdef/name "penpot_rpc_management_timing" + ::mdef/help "RPC command method call timing for management." ::mdef/labels ["name"] ::mdef/type :histogram} @@ -304,10 +298,13 @@ ::session/manager (ig/ref ::session/manager)} :app.http.assets/routes - {::http.assets/path (cf/get :assets-path) - ::http.assets/cache-max-age (ct/duration {:hours 24}) - ::http.assets/cache-max-agesignature-max-age (ct/duration {:hours 24 :minutes 5}) - ::sto/storage (ig/ref ::sto/storage)} + {::http.assets/path (cf/get :assets-path) + ::http.assets/cache-max-age (ct/duration {:hours 24}) + ::http.assets/signature-max-age (ct/duration {:hours 24 :minutes 15}) + ::sto/storage (ig/ref ::sto/storage) + ::session/manager (ig/ref ::session/manager) + ::setup/props (ig/ref ::setup/props) + ::db/pool (ig/ref ::db/pool)} ::rpc/climit {::mtx/metrics (ig/ref ::mtx/metrics) @@ -658,9 +655,8 @@ [& _args] (try (let [p (promise)] - (when (contains? cf/flags :nrepl-server) - (l/inf :hint "start nrepl server" :port 6064) - (nrepl/start-server :bind "0.0.0.0" :port 6064)) + (l/inf :hint "start nrepl server" :port 6064) + (nrepl/start-server :bind "0.0.0.0" :port 6064) (start) (deref p)) diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj index 00b6db36d4..b91c09d38e 100644 --- a/backend/src/app/media.clj +++ b/backend/src/app/media.clj @@ -18,6 +18,7 @@ [app.config :as cf] [app.db :as-alias db] [app.http.client :as http] + [app.media.sanitize :as sanitize] [app.storage :as-alias sto] [app.storage.tmp :as tmp] [buddy.core.bytes :as bb] @@ -31,15 +32,12 @@ (:import clojure.lang.XMLHandler java.io.InputStream - javax.xml.XMLConstants javax.xml.parsers.SAXParserFactory + javax.xml.XMLConstants org.apache.commons.io.IOUtils org.im4java.core.ConvertCmd org.im4java.core.IMOperation)) -(def default-max-file-size - (* 1024 1024 10)) ; 10 MiB - (def schema:upload [:map {:title "Upload"} [:filename :string] @@ -78,6 +76,20 @@ max-size))) upload)) +(defn validate-font-size! + "Validates that the font file `upload` does not exceed the configured + `:font-max-file-size` limit. Accepts the same map shape as + `validate-media-size!` — requires a `:size` key in bytes." + [upload] + (let [max-size (cf/get :font-max-file-size)] + (when (> (:size upload) max-size) + (ex/raise :type :restriction + :code :font-max-file-size-reached + :hint (str/ffmt "the uploaded font size % is greater than the maximum %" + (:size upload) + max-size))) + upload)) + (defmulti process :cmd) (defmulti process-error class) @@ -295,9 +307,7 @@ [{:keys [::http/client]} uri] (letfn [(parse-and-validate [{:keys [status headers] :as response}] (let [size (some-> (get headers "content-length") d/parse-integer) - mtype (get headers "content-type") - format (cm/mtype->format mtype) - max-size (cf/get :media-max-file-size default-max-file-size)] + mtype (get headers "content-type")] (when-not (<= 200 status 299) (ex/raise :type :validation @@ -309,25 +319,17 @@ :code :unknown-size :hint "seems like the url points to resource with unknown size")) - (when (> size max-size) - (ex/raise :type :validation - :code :file-too-large - :hint (str/ffmt "the file size % is greater than the maximum %" - size - default-max-file-size))) - - (when (nil? format) - (ex/raise :type :validation - :code :media-type-not-allowed - :hint "seems like the url points to an invalid media object")) - - {:size size :mtype mtype :format format}))] + (-> {:size size :mtype mtype} + (validate-media-type!) + (validate-media-size!))))] (let [{:keys [body] :as response} (try - (http/req! client - {:method :get :uri uri} - {:response-type :input-stream}) + (http/req-with-redirects + client + {:method :get :uri uri} + {:response-type :input-stream + :max-redirects 3}) (catch java.net.ConnectException cause (ex/raise :type :validation :code :unable-to-download-image @@ -358,9 +360,11 @@ :code :mismatch-write-size :hint "unexpected state: unable to write to file")) - {;; :size size - :path path - :mtype mtype}))) + ;; Sanitize: strip trailing data after image EOF markers + (let [new-size (sanitize/truncate-after-eof path mtype)] + {:path path + :mtype mtype + :size new-size})))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; FONTS diff --git a/backend/src/app/media/sanitize.clj b/backend/src/app/media/sanitize.clj new file mode 100644 index 0000000000..4905f5b603 --- /dev/null +++ b/backend/src/app/media/sanitize.clj @@ -0,0 +1,191 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.media.sanitize + "Image EOF truncation helpers — strips trailing data after image EOF + markers to prevent exfiltration of non-image bytes appended to + valid image files." + (:require + [app.common.buffer :as buf] + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.util.nio :as nio]) + (:import + java.nio.ByteOrder + java.nio.channels.FileChannel)) + +(set! *warn-on-reflection* true) + +(defn- scan-backwards + "Scan byte array `arr` backwards (from the end) for the byte pattern + `marker`. Returns the index in `arr` where the marker starts, or -1 + if not found." + [^bytes arr ^bytes marker] + (let [arr-len (alength arr) + marker-len (alength marker)] + (loop [i (- arr-len marker-len)] + (if (< i 0) + -1 + (if (loop [j 0] + (if (>= j marker-len) + true + (if (= (aget arr (+ i j)) (aget marker j)) + (recur (inc j)) + false))) + i + (recur (dec i))))))) + +(defn- find-last-png-iend + "Find the byte offset of the end of the PNG IEND chunk (12 bytes: + 4-byte length + 4-byte 'IEND' + 4-byte CRC32). Returns the offset + AFTER the CRC32, or nil if not found." + [^FileChannel channel] + (let [size (nio/channel-size channel)] + (when (> size 8) + (let [buf-size (min (int size) (* 1024 1024)) + marker (byte-array [0x49 0x45 0x4E 0x44])] ;; "IEND" + (loop [pos (max 0 (- size buf-size))] + (when (< pos size) + (let [arr (nio/read-at channel pos buf-size) + idx (scan-backwards arr marker)] + (if (neg? idx) + ;; Not found in this chunk, try earlier + (let [next-pos (max 0 (- pos (- buf-size 4)))] + (when (< next-pos pos) + (recur next-pos))) + ;; Found "IEND" at idx. Chunk starts 4 bytes before. + (let [chunk-start (- (+ pos idx) 4)] + (when (>= chunk-start 0) + ;; PNG chunk length is big-endian (network byte order). + ;; buf/wrap defaults to little-endian, so set it to big-endian. + (let [len-arr (nio/read-at channel chunk-start 4) + len-buf (buf/set-order (buf/wrap len-arr) ByteOrder/BIG_ENDIAN) + chunk-len (buf/read-int len-buf 0)] + (when (zero? chunk-len) + (+ chunk-start 12))))))))))))) + +(defn- find-last-jpeg-eoi + "Find the byte offset of the last JPEG EOI marker (0xFF 0xD9). + Returns the offset AFTER the marker, or nil if not found." + [^FileChannel channel] + (let [size (nio/channel-size channel)] + (when (> size 2) + (let [buf-size (min (int size) (* 1024 1024)) + marker (byte-array [(unchecked-byte 0xFF) (unchecked-byte 0xD9)])] + (loop [pos (max 0 (- size buf-size))] + (when (< pos size) + (let [arr (nio/read-at channel pos buf-size) + idx (scan-backwards arr marker)] + (if (neg? idx) + (let [next-pos (max 0 (- pos (- buf-size 2)))] + (when (< next-pos pos) + (recur next-pos))) + (+ pos idx 2))))))))) + +(defn- find-last-gif-trailer + "Find the byte offset immediately after the last GIF trailer byte (0x3B). + Scans backwards through the file so that appended data after the real + trailer is truncated even when it ends with 0x3B. + Returns the offset AFTER the trailer byte, or nil if 0x3B is not found." + [^FileChannel channel] + (let [size (nio/channel-size channel)] + (when (pos? size) + (let [buf-size (min (int size) (* 1024 1024)) + marker (byte-array [(unchecked-byte 0x3B)])] + (loop [pos (max 0 (- size buf-size))] + (when (< pos size) + (let [arr (nio/read-at channel pos buf-size) + idx (scan-backwards arr marker)] + (if (neg? idx) + (let [next-pos (max 0 (- pos (- buf-size 1)))] + (when (< next-pos pos) + (recur next-pos))) + (+ pos idx 1))))))))) + +(defn- find-webp-end + "Parse the WebP RIFF header to find the declared file size. + WebP format: 'RIFF' (4 bytes) + uint32 total-size (4 bytes, little-endian) + + 'WEBP' (4 bytes). The total size is the offset of the end of the file. + Returns nil if the RIFF or WEBP magic bytes are missing." + [^FileChannel channel] + (let [size (nio/channel-size channel)] + (when (>= size 12) + (let [^bytes arr (nio/read-at channel 0 12) + buf (buf/wrap arr)] + ;; Check RIFF magic (bytes 0-3) AND WEBP FourCC (bytes 8-11) + (when (and (= (aget arr 0) (byte 0x52)) ;; 'R' + (= (aget arr 1) (byte 0x49)) ;; 'I' + (= (aget arr 2) (byte 0x46)) ;; 'F' + (= (aget arr 3) (byte 0x46)) ;; 'F' + (= (aget arr 8) (byte 0x57)) ;; 'W' + (= (aget arr 9) (byte 0x45)) ;; 'E' + (= (aget arr 10) (byte 0x42)) ;; 'B' + (= (aget arr 11) (byte 0x50))) ;; 'P' + (let [riff-size (bit-and (buf/read-int buf 4) 0xFFFFFFFF)] + ;; RIFF size field is the size of the file minus 8 bytes + (+ riff-size 8))))))) + +(defn truncate-after-eof + "Given a `java.nio.file.Path` to a freshly-downloaded media file and a + declared MIME type, truncate the file in place to the position of the + format's EOF marker: + - image/png → end of the IEND chunk (12 bytes: 4-byte length + 4-byte type + 4-byte CRC32) + - image/jpeg → 2 bytes after FFD9 + - image/gif → immediately after the last GIF trailer byte 0x3B + - image/webp → end of RIFF chunk declared in bytes 4..8 + - image/svg+xml → no-op (text format; processed by SAX parser) + - other → no-op (return path unchanged) + Returns the new file size. Raises `:validation/:invalid-image` if no + EOF marker is found within the file." + [^java.nio.file.Path path ^String mtype] + (try + (with-open [channel (nio/open-channel path)] + (let [size (nio/channel-size channel)] + (if (zero? size) + 0 + (let [needs-eof-marker? (or (= mtype "image/png") + (= mtype "image/jpeg") + (= mtype "image/gif") + (= mtype "image/webp")) + + eof-offset + (cond + (= mtype "image/png") (find-last-png-iend channel) + (= mtype "image/jpeg") (find-last-jpeg-eoi channel) + (= mtype "image/gif") (find-last-gif-trailer channel) + (= mtype "image/webp") (find-webp-end channel) + :else nil)] + + (cond + ;; No EOF marker applicable (SVG or other) — no-op + (nil? eof-offset) + (if needs-eof-marker? + (ex/raise :type :validation + :code :invalid-image + :hint "image format EOF marker not found") + size) + + ;; Truncate if needed + (< eof-offset size) + (do + (l/dbg :hint "truncating trailing data" + :path (str path) + :mtype mtype + :original-size size + :truncated-to eof-offset) + (nio/truncate channel eof-offset) + eof-offset) + + ;; Already at correct size or marker at end + :else + eof-offset))))) + (catch Exception e + (if (ex/exception? e) + (throw e) + (ex/raise :type :validation + :code :invalid-image + :hint "failed to sanitize image" + :cause e))))) diff --git a/backend/src/app/metrics.clj b/backend/src/app/metrics.clj index 1c7456b7ab..a1f816a304 100644 --- a/backend/src/app/metrics.clj +++ b/backend/src/app/metrics.clj @@ -15,16 +15,16 @@ io.prometheus.client.CollectorRegistry io.prometheus.client.Counter io.prometheus.client.Counter$Child + io.prometheus.client.exporter.common.TextFormat io.prometheus.client.Gauge io.prometheus.client.Gauge$Child io.prometheus.client.Histogram io.prometheus.client.Histogram$Child + io.prometheus.client.hotspot.DefaultExports io.prometheus.client.SimpleCollector io.prometheus.client.Summary io.prometheus.client.Summary$Builder io.prometheus.client.Summary$Child - io.prometheus.client.exporter.common.TextFormat - io.prometheus.client.hotspot.DefaultExports java.io.StringWriter)) (set! *warn-on-reflection* true) diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 7554618cdd..5ea79ff50c 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -468,6 +468,9 @@ {:name "0145-mod-audit-log-table" :fn (mg/resource "app/migrations/sql/0145-mod-audit-log-table.sql")} + {:name "0146-mod-audit-log-table" + :fn (mg/resource "app/migrations/sql/0146-mod-audit-log-table.sql")} + {:name "0146-mod-access-token-table" :fn (mg/resource "app/migrations/sql/0146-mod-access-token-table.sql")} @@ -475,7 +478,13 @@ :fn (mg/resource "app/migrations/sql/0147-mod-team-invitation-table.sql")} {:name "0147-add-upload-session-table" - :fn (mg/resource "app/migrations/sql/0147-add-upload-session-table.sql")}]) + :fn (mg/resource "app/migrations/sql/0147-add-upload-session-table.sql")} + + {:name "0148-add-variant-name-team-font-variant" + :fn (mg/resource "app/migrations/sql/0148-add-variant-name-team-font-variant.sql")} + + {:name "0149-mod-file-library-rel-synced-at" + :fn (mg/resource "app/migrations/sql/0149-mod-file-library-rel-synced-at.sql")}]) (defn apply-migrations! [pool name migrations] diff --git a/backend/src/app/migrations/sql/0145-add-audit-log-telemetry-index.sql b/backend/src/app/migrations/sql/0145-add-audit-log-telemetry-index.sql new file mode 100644 index 0000000000..12c59a6c83 --- /dev/null +++ b/backend/src/app/migrations/sql/0145-add-audit-log-telemetry-index.sql @@ -0,0 +1,5 @@ +-- Add index on audit_log (source, created_at) to support efficient +-- queries for the telemetry batch collection mode. + +CREATE INDEX IF NOT EXISTS audit_log__source__created_at__idx + ON audit_log (source, created_at ASC); diff --git a/backend/src/app/migrations/sql/0146-mod-audit-log-table.sql b/backend/src/app/migrations/sql/0146-mod-audit-log-table.sql new file mode 100644 index 0000000000..12c59a6c83 --- /dev/null +++ b/backend/src/app/migrations/sql/0146-mod-audit-log-table.sql @@ -0,0 +1,5 @@ +-- Add index on audit_log (source, created_at) to support efficient +-- queries for the telemetry batch collection mode. + +CREATE INDEX IF NOT EXISTS audit_log__source__created_at__idx + ON audit_log (source, created_at ASC); diff --git a/backend/src/app/migrations/sql/0148-add-variant-name-team-font-variant.sql b/backend/src/app/migrations/sql/0148-add-variant-name-team-font-variant.sql new file mode 100644 index 0000000000..d90fb83538 --- /dev/null +++ b/backend/src/app/migrations/sql/0148-add-variant-name-team-font-variant.sql @@ -0,0 +1,2 @@ +ALTER TABLE team_font_variant + ADD COLUMN variant_name text NULL; diff --git a/backend/src/app/migrations/sql/0149-mod-file-library-rel-synced-at.sql b/backend/src/app/migrations/sql/0149-mod-file-library-rel-synced-at.sql new file mode 100644 index 0000000000..7929cfc8de --- /dev/null +++ b/backend/src/app/migrations/sql/0149-mod-file-library-rel-synced-at.sql @@ -0,0 +1,19 @@ +CREATE TABLE file_library_sync ( + file_id uuid NOT NULL, + library_file_id uuid NOT NULL, + synced_at timestamptz NOT NULL DEFAULT clock_timestamp(), + + PRIMARY KEY (file_id, library_file_id) +); + +INSERT INTO file_library_sync (file_id, library_file_id, synced_at) +SELECT file_id, library_file_id, synced_at + FROM file_library_rel; + +-- DEPRECATED: the `synced_at` column on `file_library_rel` is deprecated +-- and will be removed in a future migration. It's kept temporarily +-- for backward compatibility while data is migrated to `file_library_sync`. +COMMENT ON COLUMN file_library_rel.synced_at IS + 'DEPRECATED: will be removed in a future migration; kept temporarily for backward compatibility'; + + diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index d096a4e9d2..1a77e6af2b 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -7,12 +7,14 @@ (ns app.nitrate "Module that make calls to the external nitrate aplication" (:require + [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.json :as json] [app.common.logging :as l] [app.common.schema :as sm] [app.common.schema.generators :as sg] [app.common.time :as ct] + [app.common.types.organization :as cto] [app.config :as cf] [app.http.client :as http] [app.rpc :as-alias rpc] @@ -27,14 +29,16 @@ (defn- request-builder [cfg method uri shared-key profile-id request-params] (fn [] - (http/req! cfg (cond-> {:method method - :headers {"content-type" "application/json" - "accept" "application/json" - "x-shared-key" shared-key - "x-profile-id" (str profile-id)} - :uri uri - :version :http1.1} - (= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key)))))) + (http/req cfg + (cond-> {:method method + :headers {"content-type" "application/json" + "accept" "application/json" + "x-shared-key" shared-key + "x-profile-id" (str profile-id)} + :uri uri + :version :http1.1} + (= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key))) + {:skip-ssrf-check? true}))) (defn- with-retries [handler max-retries] @@ -54,25 +58,44 @@ result))))) -(defn- with-validate [handler uri schema] +(defn- with-validate [handler uri schema & {:keys [throw-on-error?]}] (fn [] (let [response (handler) status (:status response)] - (when-not status - (l/error :hint "could't do the nitrate request, it is probably down" - :uri uri) - ;; TODO decide what to do when Nitrate is inaccesible - nil) (cond + (nil? status) + (do + (l/error :hint "couldn't do the nitrate request, it is probably down" + :uri uri) + (ex/raise :type :nitrate-unavailable + :hint (str "nitrate is unreachable at " uri))) + + (>= status 500) + ;; Nitrate is up enough to answer (or the proxy is) but the + ;; service itself is failing; treat as unavailable so callers + ;; surface the static error page. + (do + (l/error :hint "nitrate request failed with server error status" + :uri uri + :status status + :body (:body response)) + (ex/raise :type :nitrate-unavailable + :status status + :hint (str "nitrate is unavailable, HTTP " status " at " uri))) + (>= status 400) - ;; For error status codes (4xx, 5xx), fail immediately without validation + ;; For client error status codes (4xx), fail immediately without validation (do (when (not= status 404) ;; Don't need to log 404 (l/error :hint "nitrate request failed with error status" :uri uri :status status :body (:body response))) - nil) + (if throw-on-error? + (ex/raise :type :nitrate-http-error + :status status + :hint (str "nitrate HTTP " status " at " uri)) + nil)) (= status 204) ;; 204 doesn't return any body nil :else ;; For success status codes, validate the response @@ -88,11 +111,11 @@ nil))))))) (defn- request-to-nitrate - [cfg method uri schema {:keys [::rpc/profile-id request-params] :as params}] + [cfg method uri schema {:keys [::rpc/profile-id request-params throw-on-error?] :as params}] (let [shared-key (-> cfg ::setup/shared-keys :nitrate) full-http-call (-> (request-builder cfg method uri shared-key profile-id request-params) (with-retries 3) - (with-validate uri schema))] + (with-validate uri schema :throw-on-error? throw-on-error?))] (full-http-call))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -108,16 +131,6 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(def ^:private schema:organization - [:map - [:id ::sm/uuid] - [:name ::sm/text] - [:slug ::sm/text] - [:is-your-penpot :boolean] - [:owner-id ::sm/uuid] - [:avatar-bg-url [::sm/text]] - [:logo-id {:optional true} [:maybe ::sm/uuid]]]) - (def ^:private schema:org-summary [:map [:id ::sm/uuid] @@ -129,12 +142,6 @@ [:id ::sm/uuid] [:is-your-penpot :boolean]]]]]) -(def ^:private schema:team - [:map - [:id ::sm/uuid] - [:organization-id ::sm/uuid] - [:is-your-penpot :boolean]]) - (def ^:private schema:profile-org [:map [:is-member :boolean] @@ -182,6 +189,7 @@ "day" "week" "year"]] + [:manual :boolean] [:quantity :int] [:description [:maybe ::sm/text]] [:created-at schema:timestamp] @@ -221,7 +229,7 @@ (str baseuri "/api/teams/" team-id) - schema:organization params))) + cto/schema:team-with-organization params))) (defn- get-org-membership-api [cfg {:keys [profile-id organization-id] :as params}] @@ -256,18 +264,62 @@ "/summary") schema:org-summary params))) +(defn- get-owned-orgs-api + [cfg {:keys [profile-id] :as params}] + (let [baseuri (cf/get :nitrate-backend-uri)] + (request-to-nitrate cfg :get + (str baseuri + "/api/users/" + profile-id + "/owned-organizations") + [:vector schema:org-summary] + params))) + +(def ^:private schema:org-summary-counts + [:map + [:id ::sm/uuid] + [:name ::sm/text] + [:slug ::sm/text] + [:team-count ::sm/int] + [:member-count ::sm/int]]) + +(defn- get-owned-orgs-summary-api + [cfg {:keys [profile-id] :as params}] + (let [baseuri (cf/get :nitrate-backend-uri)] + (request-to-nitrate cfg :get + (str baseuri + "/api/users/" + profile-id + "/owned-organizations-summary") + [:vector schema:org-summary-counts] + params))) + +(defn- delete-owned-orgs-api + [cfg {:keys [profile-id] :as params}] + (let [baseuri (cf/get :nitrate-backend-uri)] + (request-to-nitrate cfg :post + (str baseuri + "/api/users/" + profile-id + "/delete-owned-organizations") + nil params))) (defn- set-team-org-api [cfg {:keys [organization-id team-id is-default] :as params}] (let [baseuri (cf/get :nitrate-backend-uri) params (assoc params :request-params {:team-id team-id - :is-your-penpot (true? is-default)})] - (request-to-nitrate cfg :post - (str baseuri - "/api/organizations/" - organization-id - "/add-team") - schema:team params))) + :is-your-penpot (true? is-default)}) + team (request-to-nitrate cfg :post + (str baseuri + "/api/organizations/" + organization-id + "/add-team") + cto/schema:team-with-organization params) + custom-photo (when-let [logo-id (dm/get-in team [:organization :logo-id])] + (str (cf/get :public-uri) "/assets/by-id/" logo-id))] + (cond-> team + custom-photo + (assoc-in [:organization :custom-photo] custom-photo)))) (defn- add-profile-to-org-api [cfg {:keys [profile-id organization-id team-id email] :as params}] @@ -340,6 +392,32 @@ "/api/connectivity") schema:connectivity params))) +(def ^:private schema:redeem-result + [:map + [:cancel-at [:maybe schema:timestamp]]]) + +(defn- get-org-permissions-api + [cfg {:keys [organization-id] :as params}] + (let [baseuri (cf/get :nitrate-backend-uri)] + (request-to-nitrate cfg :get + (str baseuri + "/api/organizations/" + organization-id + "/permissions") + [:map + [:organization-id ::sm/uuid] + [:owner-id ::sm/uuid] + [:permissions [:map-of :keyword :string]]] + params))) + +(defn- redeem-activation-code-api + [cfg params] + (let [baseuri (cf/get :nitrate-backend-uri)] + (request-to-nitrate cfg :post + (str baseuri "/api/activation-codes/redeem") + schema:redeem-result + (assoc params :throw-on-error? true)))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; INITIALIZATION ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -352,13 +430,18 @@ :get-org-membership (partial get-org-membership-api cfg) :get-org-membership-by-team (partial get-org-membership-by-team-api cfg) :get-org-summary (partial get-org-summary-api cfg) + :get-owned-orgs (partial get-owned-orgs-api cfg) + :get-owned-orgs-summary (partial get-owned-orgs-summary-api cfg) + :delete-owned-orgs (partial delete-owned-orgs-api cfg) :add-profile-to-org (partial add-profile-to-org-api cfg) :remove-profile-from-org (partial remove-profile-from-org-api cfg) :remove-profile-from-all-orgs (partial remove-profile-from-all-orgs-api cfg) + :get-org-permissions (partial get-org-permissions-api cfg) :delete-team (partial delete-team-api cfg) :remove-team-from-org (partial remove-team-from-org-api cfg) :get-subscription (partial get-subscription-api cfg) - :connectivity (partial get-connectivity-api cfg)})) + :connectivity (partial get-connectivity-api cfg) + :redeem-activation-code (partial redeem-activation-code-api cfg)})) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; UTILS @@ -368,41 +451,46 @@ (defn add-nitrate-licence-to-profile "Enriches a profile map with subscription information from Nitrate. Adds a :subscription field containing the user's license details. - Returns the original profile unchanged if the request fails." + Returns the original profile unchanged if the request fails for a reason + other than Nitrate being unreachable. When Nitrate is unreachable the + `:nitrate-unavailable` exception propagates so the request is rejected." [cfg profile] (try (let [subscription (call cfg :get-subscription {:profile-id (:id profile)})] (assoc profile :subscription subscription)) (catch Throwable cause - (l/error :hint "failed to get nitrate licence" - :profile-id (:id profile) - :cause cause) - profile))) + (if (= :nitrate-unavailable (-> cause ex-data :type)) + (throw cause) + (do + (l/error :hint "failed to get nitrate licence" + :profile-id (:id profile) + :cause cause) + profile))))) (defn add-org-info-to-team "Enriches a team map with organization information from Nitrate. Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields. - Returns the original team unchanged if the request fails or org data is nil." + Returns the original team unchanged if the request fails or org data is nil. + Propagates `:nitrate-unavailable` so the request is rejected when Nitrate is unreachable." [cfg team params] (try - (let [params (assoc (or params {}) :team-id (:id team)) - org (call cfg :get-team-org params)] + (let [params (assoc (or params {}) :team-id (:id team)) + team-with-org (call cfg :get-team-org params) + org (:organization team-with-org)] (if (some? org) - (assoc team - :organization-id (:id org) - :organization-name (:name org) - :organization-slug (:slug org) - :organization-owner-id (:owner-id org) - :organization-avatar-bg-url (:avatar-bg-url org) - :organization-custom-photo (when-let [logo-id (:logo-id org)] - (str (cf/get :public-uri) "/assets/by-id/" logo-id)) - :is-default (or (:is-default team) (true? (:is-your-penpot org)))) + (-> (cto/apply-organization team (assoc org :custom-photo + (when-let [logo-id (:logo-id org)] + (str (cf/get :public-uri) "/assets/by-id/" logo-id)))) + (assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-org))))) team)) (catch Throwable cause - (l/error :hint "failed to get team organization info" - :team-id (:id team) - :cause cause) - team))) + (if (= :nitrate-unavailable (-> cause ex-data :type)) + (throw cause) + (do + (l/error :hint "failed to get team organization info" + :team-id (:id team) + :cause cause) + team))))) (defn set-team-organization "Associates a team with an organization in Nitrate. diff --git a/backend/src/app/redis.clj b/backend/src/app/redis.clj index 96e6b07be5..dc1bff9669 100644 --- a/backend/src/app/redis.clj +++ b/backend/src/app/redis.clj @@ -24,28 +24,28 @@ [integrant.core :as ig]) (:import clojure.lang.MapEntry - io.lettuce.core.KeyValue - io.lettuce.core.RedisClient - io.lettuce.core.RedisCommandInterruptedException - io.lettuce.core.RedisCommandTimeoutException - io.lettuce.core.RedisException - io.lettuce.core.RedisURI - io.lettuce.core.ScriptOutputType - io.lettuce.core.SetArgs io.lettuce.core.api.StatefulRedisConnection io.lettuce.core.api.sync.RedisCommands io.lettuce.core.api.sync.RedisScriptingCommands io.lettuce.core.codec.RedisCodec io.lettuce.core.codec.StringCodec + io.lettuce.core.KeyValue + io.lettuce.core.pubsub.api.sync.RedisPubSubCommands io.lettuce.core.pubsub.RedisPubSubListener io.lettuce.core.pubsub.StatefulRedisPubSubConnection - io.lettuce.core.pubsub.api.sync.RedisPubSubCommands + io.lettuce.core.RedisClient + io.lettuce.core.RedisCommandInterruptedException + io.lettuce.core.RedisCommandTimeoutException + io.lettuce.core.RedisException + io.lettuce.core.RedisURI io.lettuce.core.resource.ClientResources io.lettuce.core.resource.DefaultClientResources + io.lettuce.core.ScriptOutputType + io.lettuce.core.SetArgs io.netty.channel.nio.NioEventLoopGroup + io.netty.util.concurrent.EventExecutorGroup io.netty.util.HashedWheelTimer io.netty.util.Timer - io.netty.util.concurrent.EventExecutorGroup java.lang.AutoCloseable java.time.Duration)) diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index 20cb0c150b..9602c01abb 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -109,6 +109,7 @@ (assoc ::handler-name handler-name) (assoc ::ip-addr ip-addr) (assoc ::request-at (ct/now)) + (assoc ::request-id (uuid/next)) (assoc ::session-id (some-> session-id uuid/parse*)) (assoc ::cond/key etag) (cond-> (uuid? profile-id) @@ -165,12 +166,13 @@ (defn- wrap-audit [_ f mdata] (if (or (contains? cf/flags :webhooks) - (contains? cf/flags :audit-log)) + (contains? cf/flags :audit-log) + (contains? cf/flags :telemetry)) (if-not (::audit/skip mdata) (fn [cfg params] (let [result (f cfg params)] - (->> (audit/prepare-event cfg mdata params result) - (audit/submit! cfg)) + (->> (audit/prepare-rpc-event cfg mdata params result) + (audit/submit cfg)) result)) f) f)) diff --git a/backend/src/app/rpc/commands/audit.clj b/backend/src/app/rpc/commands/audit.clj index 757c4fa5cb..44e547f8cd 100644 --- a/backend/src/app/rpc/commands/audit.clj +++ b/backend/src/app/rpc/commands/audit.clj @@ -15,7 +15,7 @@ [app.config :as cf] [app.db :as db] [app.http :as-alias http] - [app.loggers.audit :as-alias audit] + [app.loggers.audit :as audit] [app.loggers.database :as loggers.db] [app.loggers.mattermost :as loggers.mm] [app.rpc :as-alias rpc] @@ -23,7 +23,8 @@ [app.rpc.doc :as-alias doc] [app.rpc.helpers :as rph] [app.util.inet :as inet] - [app.util.services :as sv])) + [app.util.services :as sv] + [clojure.set :as set])) (def ^:private event-columns [:id @@ -38,31 +39,31 @@ :context]) (defn- event->row [event] - [(::audit/id event) - (::audit/name event) - (::audit/source event) - (::audit/type event) - (::audit/tracked-at event) - (::audit/created-at event) - (::audit/profile-id event) - (db/inet (::audit/ip-addr event)) - (db/tjson (::audit/props event)) - (db/tjson (d/without-nils (::audit/context event)))]) + [(:id event) + (:name event) + (:source event) + (:type event) + (:tracked-at event) + (:created-at event) + (:profile-id event) + (db/inet (:ip-addr event)) + (db/tjson (:props event)) + (db/tjson (d/without-nils (:context event)))]) (defn- adjust-timestamp - [{:keys [::audit/tracked-at ::audit/created-at] :as event}] + [{:keys [tracked-at created-at] :as event}] (let [margin (inst-ms (ct/diff tracked-at created-at))] (if (or (neg? margin) (> margin 3600000)) ;; If event is in future or lags more than 1 hour, we reasign ;; tracked-at to the server creation date (-> event - (assoc ::audit/tracked-at created-at) - (update ::audit/context assoc :original-tracked-at tracked-at)) + (assoc :tracked-at created-at) + (update :context assoc :original-tracked-at tracked-at)) event))) (defn- exception-event? - [{:keys [::audit/type ::audit/name] :as ev}] + [{:keys [type name] :as ev}] (and (= "action" type) (or (= "unhandled-exception" name) (= "exception-page" name)))) @@ -72,28 +73,44 @@ (map adjust-timestamp) (map event->row))) -(defn- get-events +(defn- prepare-events [{:keys [::rpc/request-at ::rpc/profile-id events] :as params}] (let [request (-> params meta ::http/request) ip-addr (inet/parse-request request) - - xform (map (fn [event] - {::audit/id (uuid/next) - ::audit/type (:type event) - ::audit/name (:name event) - ::audit/props (:props event) - ::audit/context (:context event) - ::audit/profile-id profile-id - ::audit/ip-addr ip-addr - ::audit/source "frontend" - ::audit/tracked-at (:timestamp event) - ::audit/created-at request-at}))] + xform (comp + (map (fn [event] + {:id (uuid/next) + :type (:type event) + :name (:name event) + :props (:props event) + :context (:context event) + :profile-id profile-id + :ip-addr ip-addr + :source "frontend" + :tracked-at (:timestamp event) + :created-at request-at})) + (map (fn [item] + (with-meta item {::audit/event true}))))] (sequence xform events))) +(def ^:private xf:map-telemetry-event-row + (comp + (map adjust-timestamp) + (map (fn [event] + (-> event + (assoc :id (uuid/next)) + (update :created-at ct/truncate :days) + (update :tracked-at ct/truncate :days) + (audit/filter-telemetry-props) + (audit/filter-telemetry-context) + (assoc :ip-addr "0.0.0.0") + (assoc :source "telemetry:frontend")))) + (map event->row))) + (defn- handle-events [{:keys [::db/pool] :as cfg} params] - (let [events (get-events params)] + (let [events (prepare-events params)] ;; Look for error reports and save them on internal reports table (when-let [events (->> events @@ -102,9 +119,18 @@ (run! (partial loggers.db/emit cfg) events) (run! (partial loggers.mm/emit cfg) events)) - ;; Process and save events - (when (seq events) - (let [rows (sequence xf:map-event-row events)] + (when (contains? cf/flags :audit-log) + ;; Process and save full audit events when audit-log flag is active + (when-let [rows (-> (sequence xf:map-event-row events) + (not-empty))] + (db/insert-many! pool :audit-log event-columns rows))) + + (when (contains? cf/flags :telemetry) + ;; Store anonymized frontend events so the telemetry task can ship them + ;; in batches. Runs independently from the audit-log insert above so + ;; both modes can be active simultaneously. + (when-let [rows (-> (sequence xf:map-telemetry-event-row events) + (not-empty))] (db/insert-many! pool :audit-log event-columns rows))))) (def ^:private valid-event-types @@ -138,17 +164,26 @@ ::doc/skip true ::doc/added "1.17"} [{:keys [::db/pool] :as cfg} params] - (if (or (db/read-only? pool) - (not (contains? cf/flags :audit-log))) - (do - (l/warn :hint "audit: http handler disabled or db is read-only") - (rph/wrap nil)) - - (do + (let [telemetry? (contains? cf/flags :telemetry) + audit-log? (contains? cf/flags :audit-log) + enabled? (and (not (db/read-only? pool)) + (or audit-log? telemetry?))] + (when enabled? (try (handle-events cfg params) (catch Throwable cause (l/error :hint "unexpected error on persisting audit events from frontend" - :cause cause))) + :cause cause)))) - (rph/wrap nil)))) + (rph/wrap nil))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; GET-ENABLED-FLAGS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(sv/defmethod ::get-enabled-flags + {::audit/skip true + ::doc/skip true + ::doc/added "1.20"} + [_cfg _params] + (set/intersection cf/flags #{:audit-log :telemetry})) diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj index c3d5cdf7eb..9514fc419d 100644 --- a/backend/src/app/rpc/commands/auth.clj +++ b/backend/src/app/rpc/commands/auth.clj @@ -258,24 +258,44 @@ (validate-register-attempt! cfg params) (let [email (profile/clean-email email) - profile (profile/get-profile-by-email pool email) - props (-> (audit/extract-utm-params params) - (cond-> (:accept-newsletter-updates params) - (assoc :newsletter-updates true))) - params {:email email - :fullname fullname - :password (:password params) - :invitation-token (:invitation-token params) - :backend "penpot" - :iss :prepared-register - :profile-id (:id profile) - :exp (ct/in-future {:days 7}) - :props props} - params (d/without-nils params) - token (tokens/generate cfg params)] + profile (profile/get-profile-by-email pool email)] - (-> {:token token} - (with-meta {::audit/profile-id uuid/zero})))) + ;; SECURITY: refuse to issue a prepared-register token when an active + ;; profile already exists for this email. + ;; + ;; Active accounts must use the standard login flow; existing-but- + ;; not-yet-active profiles fall through to the duplicate-detection branch in + ;; `register-profile`, which never creates a session. + (when (and (some? profile) + (true? (:is-active profile))) + (ex/raise :type :validation + :code :email-already-exists + :hint "email already exists")) + + (let [props (-> (audit/extract-utm-params params) + (cond-> (:accept-newsletter-updates params) + (assoc :newsletter-updates true))) + ;; SECURITY: do NOT embed `:profile-id` of an existing + ;; profile into the prepared-register JWE. Doing so would + ;; let an anonymous caller, in possession of a valid + ;; team-invitation JWE, ask `register-profile` to load that + ;; profile by id and mint a session for it without password + ;; verification. `register-profile` independently re-detects + ;; duplicates by email and handles them in the + ;; "repeated-registry" branch. + params {:email email + :fullname fullname + :password (:password params) + :invitation-token (:invitation-token params) + :backend "penpot" + :iss :prepared-register + :exp (ct/in-future {:days 7}) + :props props} + params (d/without-nils params) + token (tokens/generate cfg params)] + + (-> {:token token} + (with-meta {::audit/profile-id uuid/zero}))))) (def schema:prepare-register-profile [:map {:title "prepare-register-profile"} @@ -389,25 +409,32 @@ (profile/decode-row)))) (defn send-email-verification! - [{:keys [::db/conn] :as cfg} profile] - (let [vtoken (tokens/generate cfg - {:iss :verify-email - :exp (ct/in-future "72h") - :profile-id (:id profile) - :email (:email profile)}) - ;; NOTE: this token is mainly used for possible complains - ;; identification on the sns webhook - ptoken (tokens/generate cfg - {:iss :profile-identity - :profile-id (:id profile) - :exp (ct/in-future {:days 30})})] - (eml/send! {::eml/conn conn - ::eml/factory eml/register - :public-uri (cf/get :public-uri) - :to (:email profile) - :name (:fullname profile) - :token vtoken - :extra-data ptoken}))) + ([cfg profile] (send-email-verification! cfg profile nil)) + ([{:keys [::db/conn] :as cfg} profile invitation-token] + (let [vclaims (cond-> {:iss :verify-email + :exp (ct/in-future "72h") + :profile-id (:id profile) + :email (:email profile)} + ;; If the user registered through a team-invitation flow but + ;; their profile is not yet active, we carry the invitation + ;; token inside the verify-email JWE so the team-invitation + ;; flow can resume after the user clicks the email link. + (some? invitation-token) + (assoc :invitation-token invitation-token)) + vtoken (tokens/generate cfg vclaims) + ;; NOTE: this token is mainly used for possible complains + ;; identification on the sns webhook + ptoken (tokens/generate cfg + {:iss :profile-identity + :profile-id (:id profile) + :exp (ct/in-future {:days 30})})] + (eml/send! {::eml/conn conn + ::eml/factory eml/register + :public-uri (cf/get :public-uri) + :to (:email profile) + :name (:fullname profile) + :token vtoken + :extra-data ptoken})))) (defn register-profile [{:keys [::db/conn ::wrk/executor] :as cfg} {:keys [token] :as params}] @@ -416,23 +443,16 @@ (:accept-newsletter-updates params) (update :props assoc :newsletter-updates true)) - profile (if-let [profile-id (:profile-id claims)] - (profile/get-profile conn profile-id) - ;; NOTE: we first try to match existing profile - ;; by email, that in normal circumstances will - ;; not return anything, but when a user tries to - ;; reuse the same token multiple times, we need - ;; to detect if the profile is already registered - (or (profile/get-profile-by-email conn (:email claims)) - (let [is-active (or (boolean (:is-active claims)) - (boolean (:email-verified claims)) - (not (contains? cf/flags :email-verification))) - params (-> params - (assoc :is-active is-active) - (update :password auth/derive-password)) - profile (->> (create-profile cfg params) - (create-profile-rels cfg))] - (vary-meta profile assoc :created true)))) + profile (or (profile/get-profile-by-email conn (:email claims)) + (let [is-active (or (boolean (:is-active claims)) + (boolean (:email-verified claims)) + (not (contains? cf/flags :email-verification))) + params (-> params + (assoc :is-active is-active) + (update :password auth/derive-password)) + profile (->> (create-profile cfg params) + (create-profile-rels cfg))] + (vary-meta profile assoc :created true))) created? (-> profile meta :created true?) @@ -463,48 +483,67 @@ ::audit/profile-id (:id profile) ::audit/name "register-profile-retry"})) - ;; If invitation token comes in params, this is because the user - ;; comes from team-invitation process; in this case, regenerate - ;; token and send back to the user a new invitation token (and - ;; mark current session as logged). This happens only if the - ;; invitation email matches with the register email. - (and (some? invitation) - (= (:email profile) - (:member-email invitation))) - (let [invitation (assoc invitation :member-id (:id profile)) - token (tokens/generate cfg invitation)] - (-> {:id (:id profile) - :email (:email profile) - :invitation-token token} - (rph/with-transform (session/create-fn cfg profile claims)) - (rph/with-meta {::audit/replace-props props - ::audit/context {:action "accept-invitation"} - ::audit/profile-id (:id profile)}))) - - ;; When a new user is created and it is already activated by - ;; configuration or specified by OIDC, we just mark the profile - ;; as logged-in + ;; A profile was just created in this call. Invitation handling is a + ;; sub-case of "newly created profile": we never honor invitations for + ;; pre-existing profiles via this anonymous RPC. The split below mirrors + ;; the non-invitation branches but threads the invitation through the + ;; appropriate path: + ;; + ;; - active + matching invitation → mint session and + ;; return :invitation-token. The frontend redirects to + ;; :auth-verify-token, which immediately accepts the + ;; invitation. + ;; - active + no/mismatched invitation → mint session + ;; ("login" action). New profile, no further action. + ;; - not-active + matching invitation → send the + ;; verify-email mail with the invitation token EMBEDDED + ;; into the verify-email JWE. No session yet. When the + ;; user clicks the link, verify-token activates the + ;; profile, mints a session, and propagates the + ;; invitation token to the frontend so it can complete + ;; the team-invitation flow. + ;; - not-active + no/mismatched invitation → standard + ;; "check your email" verification flow. created? - (if (:is-active profile) - (-> (profile/strip-private-attrs profile) - (rph/with-transform (session/create-fn cfg profile claims)) - (rph/with-defer create-welcome-file-when-needed) - (rph/with-meta - {::audit/replace-props props - ::audit/context {:action "login"} - ::audit/profile-id (:id profile)})) + (let [accept-invitation? (and (some? invitation) + (= (:email profile) + (:member-email invitation)))] + (cond + (and (:is-active profile) accept-invitation?) + (let [invitation (assoc invitation :member-id (:id profile)) + token (tokens/generate cfg invitation)] + (-> {:id (:id profile) + :email (:email profile) + :invitation-token token} + (rph/with-transform (session/create-fn cfg profile claims)) + (rph/with-defer create-welcome-file-when-needed) + (rph/with-meta {::audit/replace-props props + ::audit/context {:action "accept-invitation"} + ::audit/profile-id (:id profile)}))) - (do - (when-not (eml/has-reports? conn (:email profile)) - (send-email-verification! cfg profile)) - - (-> {:id (:id profile) - :email (:email profile)} + (:is-active profile) + (-> (profile/strip-private-attrs profile) + (rph/with-transform (session/create-fn cfg profile claims)) (rph/with-defer create-welcome-file-when-needed) (rph/with-meta {::audit/replace-props props - ::audit/context {:action "email-verification"} - ::audit/profile-id (:id profile)})))) + ::audit/context {:action "login"} + ::audit/profile-id (:id profile)})) + + :else + (do + (when-not (eml/has-reports? conn (:email profile)) + (send-email-verification! cfg profile + (when accept-invitation? + (:invitation-token params)))) + + (-> {:id (:id profile) + :email (:email profile)} + (rph/with-defer create-welcome-file-when-needed) + (rph/with-meta + {::audit/replace-props props + ::audit/context {:action "email-verification"} + ::audit/profile-id (:id profile)}))))) :else (let [elapsed? (elapsed-verify-threshold? profile) diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 3a71359aab..d53e1bca7a 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -1064,7 +1064,10 @@ (defn link-file-to-library [conn {:keys [file-id library-id] :as params}] - (db/exec-one! conn [sql:link-file-to-library file-id library-id])) + (db/exec-one! conn [sql:link-file-to-library file-id library-id]) + (bfc/upsert-file-library-sync! conn {:file-id file-id + :library-file-id library-id + :synced-at (ct/now)})) (def ^:private schema:link-file-to-library @@ -1118,11 +1121,9 @@ (defn update-sync [conn {:keys [file-id library-id] :as params}] - (db/update! conn :file-library-rel - {:synced-at (ct/now)} - {:file-id file-id - :library-file-id library-id} - {::db/return-keys true})) + (bfc/upsert-file-library-sync! conn {:file-id file-id + :library-file-id library-id + :synced-at (ct/now)})) (def ^:private schema:update-file-library-sync-status [:map {:title "update-file-library-sync-status"} @@ -1226,38 +1227,39 @@ AND t.id = ? AND f.id = ANY(?::uuid[])") -(defn- restore-file - [conn file-id] - (db/update! conn :file - {:deleted-at nil - :has-media-trimmed false} - {:id file-id} - {::db/return-keys false}) +(def ^:private sql:restore-files + "UPDATE file SET deleted_at = null, has_media_trimmed = false + WHERE id = ANY(?::uuid[])") - (db/update! conn :file-media-object - {:deleted-at nil} - {:file-id file-id} - {::db/return-keys false}) +(def ^:private sql:restore-file-media-objects + "UPDATE file_media_object SET deleted_at = null + WHERE file_id = ANY(?::uuid[])") - (db/update! conn :file-change - {:deleted-at nil} - {:file-id file-id} - {::db/return-keys false}) +(def ^:private sql:restore-file-changes + "UPDATE file_change SET deleted_at = null + WHERE file_id = ANY(?::uuid[])") - (db/update! conn :file-data - {:deleted-at nil} - {:file-id file-id} - {::db/return-keys false}) +(def ^:private sql:restore-file-data + "UPDATE file_data SET deleted_at = null + WHERE file_id = ANY(?::uuid[])") - (db/update! conn :file-thumbnail - {:deleted-at nil} - {:file-id file-id} - {::db/return-keys false}) +(def ^:private sql:restore-file-thumbnails + "UPDATE file_thumbnail SET deleted_at = null + WHERE file_id = ANY(?::uuid[])") - (db/update! conn :file-tagged-object-thumbnail - {:deleted-at nil} - {:file-id file-id} - {::db/return-keys false})) +(def ^:private sql:restore-file-tagged-object-thumbnails + "UPDATE file_tagged_object_thumbnail SET deleted_at = null + WHERE file_id = ANY(?::uuid[])") + +(defn- restore-files + [conn file-ids] + (let [file-ids (db/create-array conn "uuid" file-ids)] + (db/exec-one! conn [sql:restore-files file-ids]) + (db/exec-one! conn [sql:restore-file-media-objects file-ids]) + (db/exec-one! conn [sql:restore-file-changes file-ids]) + (db/exec-one! conn [sql:restore-file-data file-ids]) + (db/exec-one! conn [sql:restore-file-thumbnails file-ids]) + (db/exec-one! conn [sql:restore-file-tagged-object-thumbnails file-ids]))) (def ^:private sql:restore-projects "UPDATE project SET deleted_at = null WHERE id = ANY(?::uuid[])") @@ -1278,17 +1280,18 @@ (reduce (fn [result {:keys [id project-id]}] (let [index (-> result :files count)] (events/tap :progress {:file-id id :index (inc index) :total total-files}) - (restore-file conn id) - (-> result (update :files conj id) (update :projects conj project-id)))) - - {:files #{} :projectes #{}} + {:files #{} :projects #{}} (db/plan conn [sql:resolve-editable-files team-id (db/create-array conn "uuid" ids)]))] - (restore-projects conn projects) + (when (seq files) + (restore-files conn files)) + + (when (seq projects) + (restore-projects conn projects)) files)) diff --git a/backend/src/app/rpc/commands/files_create.clj b/backend/src/app/rpc/commands/files_create.clj index a0d1d9d516..c83b5acf83 100644 --- a/backend/src/app/rpc/commands/files_create.clj +++ b/backend/src/app/rpc/commands/files_create.clj @@ -112,22 +112,30 @@ ::quotes/profile-id profile-id ::quotes/project-id project-id}) - ;; FIXME: IMPORTANT: this code can have race conditions, because - ;; we have no locks for updating team so, creating two files - ;; concurrently can lead to lost team features updating - (when-let [features (-> features - (set/difference (:features team)) - (set/difference cfeat/no-team-inheritable-features) - (not-empty))] - (let [features (-> features - (set/union (:features team)) - (set/difference cfeat/no-team-inheritable-features) - (into-array))] + ;; Acquire a row-level lock on the team and re-read its features + ;; inside the same transaction before the read-modify-write below. + ;; Without the lock, two concurrent create-file calls on the same + ;; team can both observe the same team.features value, each + ;; compute a different union, and the second UPDATE silently + ;; overwrites the first (lost update under READ COMMITTED). + (let [team-features (-> (db/exec-one! conn + ["SELECT features FROM team WHERE id = ? FOR UPDATE" + team-id]) + :features + (db/decode-pgarray #{}))] + (when-let [new-features (-> features + (set/difference team-features) + (set/difference cfeat/no-team-inheritable-features) + (not-empty))] + (let [features (-> new-features + (set/union team-features) + (set/difference cfeat/no-team-inheritable-features) + (into-array))] - (db/update! conn :team - {:features features} - {:id (:id team)} - {::db/return-keys false}))) + (db/update! conn :team + {:features features} + {:id team-id} + {::db/return-keys false})))) (-> (create-file cfg params) (vary-meta assoc ::audit/props {:team-id team-id})))) diff --git a/backend/src/app/rpc/commands/files_snapshot.clj b/backend/src/app/rpc/commands/files_snapshot.clj index 8325772361..7736b66cd9 100644 --- a/backend/src/app/rpc/commands/files_snapshot.clj +++ b/backend/src/app/rpc/commands/files_snapshot.clj @@ -8,6 +8,7 @@ (:require [app.binfile.common :as bfc] [app.common.exceptions :as ex] + [app.common.features :as-alias cfeat] [app.common.schema :as sm] [app.common.time :as ct] [app.db :as db] @@ -35,6 +36,43 @@ (files/check-read-permissions! conn profile-id file-id) (fsnap/get-visible-snapshots conn file-id)))) +;; --- COMMAND QUERY: get-file-snapshot + +(def ^:private schema:get-file-snapshot + [:map {:title "get-file-snapshot"} + [:file-id ::sm/uuid] + [:id ::sm/uuid] + [:features {:optional true} ::cfeat/features]]) + +(sv/defmethod ::get-file-snapshot + "Retrieve a file bundle with data from a specific snapshot for + read-only preview. Does not modify any database state." + {::doc/added "2.16" + ::sm/params schema:get-file-snapshot + ::sm/result files/schema:file-with-permissions + ::db/transaction true} + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id id] :as params}] + (let [perms (bfc/get-file-permissions conn profile-id file-id)] + (files/check-read-permissions! perms) + (let [snapshot (fsnap/get-snapshot-data cfg file-id id)] + (when-not snapshot + (ex/raise :type :not-found + :code :snapshot-not-found + :hint "unable to find snapshot with the provided id" + :snapshot-id id + :file-id file-id)) + ;; Load current file metadata only (no data decoding) then overlay + ;; the snapshot data so the client receives the same shape as a + ;; normal get-file response but with historical page/object content. + (let [base-file (bfc/get-file cfg file-id :load-data? false)] + (-> base-file + (assoc :data (:data snapshot)) + (assoc :version (:version snapshot)) + (assoc :features (:features snapshot)) + (assoc :revn (:revn snapshot)) + (assoc :vern (rand-int 100000)) + (assoc :permissions perms)))))) + (def ^:private schema:create-file-snapshot [:map [:file-id ::sm/uuid] diff --git a/backend/src/app/rpc/commands/files_thumbnails.clj b/backend/src/app/rpc/commands/files_thumbnails.clj index 0e318645d4..e4442ded40 100644 --- a/backend/src/app/rpc/commands/files_thumbnails.clj +++ b/backend/src/app/rpc/commands/files_thumbnails.clj @@ -409,10 +409,7 @@ [cfg {:keys [::rpc/profile-id file-id] :as params}] (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] - ;; TODO For now we check read permissions instead of write, - ;; to allow viewer users to update thumbnails. We might - ;; review this approach on the future. - (files/check-read-permissions! conn profile-id file-id) + (files/check-edition-permissions! conn profile-id file-id) (when-not (db/read-only? conn) (let [media (create-file-thumbnail cfg params)] {:uri (files/resolve-public-uri (:id media)) diff --git a/backend/src/app/rpc/commands/fonts.clj b/backend/src/app/rpc/commands/fonts.clj index b47c6c2e38..a6ec33158a 100644 --- a/backend/src/app/rpc/commands/fonts.clj +++ b/backend/src/app/rpc/commands/fonts.clj @@ -9,9 +9,11 @@ [app.binfile.common :as bfc] [app.common.data.macros :as dm] [app.common.exceptions :as ex] - [app.common.media :as cmedia] + [app.common.logging :as l] + [app.common.media :as cm] [app.common.schema :as sm] [app.common.time :as ct] + [app.common.types.font :as types.font] [app.common.uuid :as uuid] [app.db :as db] [app.db.sql :as-alias sql] @@ -23,6 +25,7 @@ [app.rpc :as-alias rpc] [app.rpc.climit :as-alias climit] [app.rpc.commands.files :as files] + [app.rpc.commands.media :refer [assemble-chunks]] [app.rpc.commands.projects :as projects] [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] @@ -31,6 +34,8 @@ [app.storage :as sto] [app.storage.tmp :as tmp] [app.util.services :as sv] + [cuerdas.core :as str] + [datoteka.fs :as fs] [datoteka.io :as io]) (:import java.io.InputStream @@ -91,35 +96,90 @@ (declare create-font-variant) (def ^:private schema:create-font-variant - [:map {:title "create-font-variant"} - [:team-id ::sm/uuid] - [:data [:map-of ::sm/text [:or ::sm/bytes - [::sm/vec ::sm/bytes]]]] - [:font-id ::sm/uuid] - [:font-family ::sm/text] - [:font-weight [::sm/one-of {:format "number"} valid-weight]] - [:font-style [::sm/one-of {:format "string"} valid-style]]]) + [:and + [:map {:title "create-font-variant"} + [:team-id ::sm/uuid] + [:font-id ::sm/uuid] + [:font-family types.font/schema:font-family] + [:font-weight [::sm/one-of {:format "number"} valid-weight]] + [:font-style [::sm/one-of {:format "string"} valid-style]] + [:data {:optional true} [:map-of ::sm/text [:or ::sm/bytes [::sm/vec ::sm/bytes]]]] + [:uploads {:optional true} [:map-of ::sm/text ::sm/uuid]]] + [:fn {:error/message "one of :data or :uploads is required"} + (fn [{:keys [data uploads]}] + (or (seq data) (seq uploads)))]]) -;; FIXME: IMPORTANT: refactor this, we should not hold a whole db -;; connection around the font creation +(defn- prepare-font-data-from-uploads + "Assembles each chunked-upload session in `uploads` (a `{mtype → + session-id}` map) into a temp file, validates the media type and + size of every entry, and returns a `{mtype → path}` data map." + [cfg {:keys [uploads] :as params}] + (let [data (reduce-kv + (fn [acc mtype session-id] + (let [assembled (assemble-chunks cfg session-id)] + (-> {:mtype mtype :size (:size assembled)} + (media/validate-media-type! cm/font-types) + (media/validate-font-size!)) + (assoc acc mtype (:path assembled)))) + {} + uploads)] + + (-> params + (assoc :data data) + (dissoc :uploads)))) + +(defn- prepare-font-data-from-legacy + "Validates the media type and size of every entry in the legacy + `:data` map (a `{mtype → bytes | [bytes]}` map). Normalises every + entry to a tempfile. Returns params with a normalised + `{mtype → path}` data map." + [{:keys [data] :as params}] + (let [data (reduce-kv + (fn [acc mtype content] + (let [tmp (tmp/tempfile :prefix "penpot.tempfont." :suffix "") + chunks (if (vector? content) content [content]) + streams (map io/input-stream chunks) + streams (Collections/enumeration streams)] + + ;; Generate the tempfile from all chunks + (with-open [^OutputStream output (io/output-stream tmp) + ^InputStream input (SequenceInputStream. streams)] + (io/copy input output)) + + ;; Validate + (-> {:mtype mtype :size (fs/size tmp)} + (media/validate-media-type! cm/font-types) + (media/validate-font-size!)) + + (assoc acc mtype tmp))) + {} + data)] + (assoc params :data data))) (sv/defmethod ::create-font-variant + "Upload a font variant. Font data may be provided either as a + Transit-encoded `:data` map (keyed by mime-type) for small fonts, or + as an `:uploads` map (keyed by mime-type, values are upload-session + UUIDs from the chunked-upload API) for large fonts. Exactly one of + the two must be present." {::doc/added "1.18" + ::doc/changes ["2.16" "Add :uploads param for chunked upload support"] ::climit/id [[:process-font/by-profile ::rpc/profile-id] [:process-font/global]] ::webhooks/event? true ::sm/params schema:create-font-variant} - [cfg {:keys [::rpc/profile-id team-id] :as params}] - (db/tx-run! cfg - (fn [{:keys [::db/conn] :as cfg}] - (teams/check-edition-permissions! conn profile-id team-id) - (quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team - ::quotes/profile-id profile-id - ::quotes/team-id team-id}) - (create-font-variant cfg (assoc params :profile-id profile-id))))) + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id uploads] :as params}] + (teams/check-edition-permissions! pool profile-id team-id) + (quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team + ::quotes/profile-id profile-id + ::quotes/team-id team-id}) + (let [params (if (some? uploads) + (db/tx-run! cfg prepare-font-data-from-uploads params) + (prepare-font-data-from-legacy params))] + (create-font-variant cfg (assoc params :profile-id profile-id)))) (defn create-font-variant - [{:keys [::sto/storage ::db/conn]} {:keys [data] :as params}] + [{:keys [::sto/storage] :as cfg} {:keys [data] :as params}] (letfn [(generate-missing [data] (let [data (media/run {:cmd :generate-fonts :input data})] (when (and (not (contains? data "font/otf")) @@ -131,23 +191,6 @@ :hint "invalid font upload, unable to generate missing font assets")) data)) - (process-chunks [chunks] - (let [tmp (tmp/tempfile :prefix "penpot.tempfont." :suffix "") - streams (map io/input-stream chunks) - streams (Collections/enumeration streams)] - (with-open [^OutputStream output (io/output-stream tmp) - ^InputStream input (SequenceInputStream. streams)] - (io/copy input output)) - tmp)) - - (join-chunks [data] - (reduce-kv (fn [data mtype content] - (if (vector? content) - (assoc data mtype (process-chunks content)) - data)) - data - data)) - (prepare-font [data mtype] (when-let [resource (get data mtype)] @@ -161,22 +204,15 @@ :bucket "team-font-variant"}))) (persist-fonts-files! [data] - (let [otf-params (prepare-font data "font/otf") - ttf-params (prepare-font data "font/ttf") - wf1-params (prepare-font data "font/woff") - wf2-params (prepare-font data "font/woff2")] + (into {} (keep (fn [[kind mtype]] + (when-let [params (prepare-font data mtype)] + [kind (sto/put-object! storage params)]))) + [[:otf "font/otf"] + [:ttf "font/ttf"] + [:woff1 "font/woff"] + [:woff2 "font/woff2"]])) - (cond-> {} - (some? otf-params) - (assoc :otf (sto/put-object! storage otf-params)) - (some? ttf-params) - (assoc :ttf (sto/put-object! storage ttf-params)) - (some? wf1-params) - (assoc :woff1 (sto/put-object! storage wf1-params)) - (some? wf2-params) - (assoc :woff2 (sto/put-object! storage wf2-params))))) - - (insert-font-variant! [{:keys [woff1 woff2 otf ttf]}] + (insert-font-variant! [conn {:keys [woff1 woff2 otf ttf]}] (db/insert! conn :team-font-variant {:id (uuid/next) :team-id (:team-id params) @@ -184,16 +220,44 @@ :font-family (:font-family params) :font-weight (:font-weight params) :font-style (:font-style params) + :variant-name (:variant-name params) :woff1-file-id (:id woff1) :woff2-file-id (:id woff2) :otf-file-id (:id otf) :ttf-file-id (:id ttf)}))] - (let [data (join-chunks data) - data (generate-missing data) - assets (persist-fonts-files! data) - result (insert-font-variant! assets)] - (vary-meta result assoc ::audit/replace-props (update params :data (comp vec keys)))))) + (let [tpoint (ct/tpoint) + mtypes (vec (keys data)) + total-size (reduce-kv (fn [acc _ content] + (+ acc (if (bytes? content) + (alength ^bytes content) + (fs/size content)))) + 0 + data)] + + (l/dbg :hint "create-font-variant" + :step "init" + :font-family (:font-family params) + :font-weight (:font-weight params) + :font-style (:font-style params) + :mtypes (str/join mtypes ",") + :size total-size) + + (let [data (generate-missing data) + assets (persist-fonts-files! data) + result (db/tx-run! cfg #(insert-font-variant! (::db/conn %) assets)) + elapsed (tpoint)] + + (l/dbg :hint "create-font-variant" + :step "end" + :font-family (:font-family params) + :font-weight (:font-weight params) + :font-style (:font-style params) + :mtypes (str/join mtypes ",") + :size total-size + :elapsed (ct/format-duration elapsed)) + + (vary-meta result assoc ::audit/replace-props (update params :data (comp vec keys))))))) ;; --- UPDATE FONT FAMILY @@ -202,7 +266,7 @@ [:map {:title "update-font"} [:team-id ::sm/uuid] [:id ::sm/uuid] - [:name :string]]) + [:name types.font/schema:font-family]]) (sv/defmethod ::update-font {::doc/added "1.18" @@ -324,7 +388,7 @@ [v mtype] (str (:font-family v) "-" (:font-weight v) (when-not (= "normal" (:font-style v)) (str "-" (:font-style v))) - (cmedia/mtype->extension mtype))) + (cm/mtype->extension mtype))) (def ^:private schema:download-font [:map {:title "download-font"} diff --git a/backend/src/app/rpc/commands/ldap.clj b/backend/src/app/rpc/commands/ldap.clj index f4aea5bc10..c4f0f565d1 100644 --- a/backend/src/app/rpc/commands/ldap.clj +++ b/backend/src/app/rpc/commands/ldap.clj @@ -42,7 +42,7 @@ (when-not provider (ex/raise :type :restriction :code :ldap-not-initialized - :hide "ldap auth provider is not initialized")) + :hint "ldap auth provider is not initialized")) (let [info (ldap/authenticate provider params)] (when-not info diff --git a/backend/src/app/rpc/commands/management.clj b/backend/src/app/rpc/commands/management.clj index d078983a27..f8c6c11144 100644 --- a/backend/src/app/rpc/commands/management.clj +++ b/backend/src/app/rpc/commands/management.clj @@ -72,10 +72,14 @@ (doseq [params (sequence (comp (map #(bfc/remap-id % :file-id)) (map #(bfc/remap-id % :library-file-id)) - (map #(assoc % :synced-at timestamp)) (map #(assoc % :created-at timestamp))) flibs)] - (db/insert! conn :file-library-rel params ::db/return-keys false)) + (let [rel-params (dissoc params :synced-at)] + (db/insert! conn :file-library-rel rel-params ::db/return-keys false) + (bfc/upsert-file-library-sync! conn {:file-id (:file-id rel-params) + :library-file-id (:library-file-id rel-params) + :synced-at (or (:synced-at params) + timestamp)}))) (doseq [params (sequence (comp (map #(bfc/remap-id % :id)) @@ -439,10 +443,10 @@ (doseq [file-id result] (let [props (assoc props :id file-id) event (-> (audit/event-from-rpc-params params) - (assoc ::audit/profile-id profile-id) - (assoc ::audit/name "create-file") - (assoc ::audit/props props))] - (audit/submit! cfg event)))))) + (assoc :profile-id profile-id) + (assoc :name "create-file") + (assoc :props props))] + (audit/submit cfg event)))))) result)) diff --git a/backend/src/app/rpc/commands/media.clj b/backend/src/app/rpc/commands/media.clj index 22fedd39b9..98b81a0810 100644 --- a/backend/src/app/rpc/commands/media.clj +++ b/backend/src/app/rpc/commands/media.clj @@ -8,6 +8,7 @@ (:require [app.common.data :as d] [app.common.exceptions :as ex] + [app.common.logging :as l] [app.common.schema :as sm] [app.common.time :as ct] [app.common.uuid :as uuid] @@ -58,8 +59,8 @@ (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] ;; We get the minimal file for proper checking if ;; file is not already deleted - (let [_ (files/get-minimal-file conn file-id) - mobj (create-file-media-object cfg params)] + (let [_ (files/get-minimal-file conn file-id) + mobj (create-file-media-object cfg params)] (db/update! conn :file {:modified-at (ct/now) @@ -149,20 +150,49 @@ (defn- create-file-media-object [{:keys [::sto/storage ::db/conn] :as cfg} - {:keys [id file-id is-local name content]}] - (let [result (process-image content) - image (sto/put-object! storage (::image result)) - thumb (when-let [params (::thumb result)] - (sto/put-object! storage params))] + {:keys [id file-id is-local name content from-url? from-chunks?]}] - (db/exec-one! conn [sql:create-file-media-object - (or id (uuid/next)) - file-id is-local name - (:id image) - (:id thumb) - (:width result) - (:height result) - (:mtype result)]))) + (let [tpoint (ct/tpoint) + id (or id (uuid/next)) + origin (cond + from-url? + "url" + from-chunks? + "chunks" + :else + "direct")] + + (l/dbg :hint "create file-media-object" + :step "init" + :id (str id) + :mtype (:mtype content) + :size (:size content) + :path (str (:path content)) + :origin origin) + + (let [result (process-image content) + image (sto/put-object! storage (::image result)) + thumb (when-let [params (::thumb result)] + (sto/put-object! storage params)) + elapsed (tpoint)] + + (l/dbg :hint "create file-media-object" + :step "end" + :id (str id) + :mtype (:mtype content) + :size (:size content) + :path (str (:path content)) + :origin origin + :elapsed (ct/format-duration elapsed)) + + (db/exec-one! conn [sql:create-file-media-object + id + file-id is-local name + (:id image) + (:id thumb) + (:width result) + (:height result) + (:mtype result)])))) ;; --- Create File Media Object (from URL) @@ -198,6 +228,7 @@ [cfg {:keys [url name] :as params}] (let [content (media/download-image cfg url) params (-> params + (assoc :from-url? true) (assoc :content content) (assoc :name (d/nilv name "unknown")))] @@ -305,7 +336,14 @@ :hint "chunk index is out of range for this session" :session-id session-id :total-chunks (:total-chunks session) - :index index))) + :index index)) + + + (l/trc :hint "upload-chunk" + :session-id session-id + :chunk (str index "/" (:total-chunks session)) + :size (:size content) + :path (:path content))) (let [storage (sto/resolve cfg) data (sto/content (:path content))] @@ -399,14 +437,15 @@ (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] - (let [{:keys [path size]} (assemble-chunks cfg session-id) - content {:filename "upload" - :size size - :path path - :mtype mtype} - _ (media/validate-media-type! content) + (let [content (assemble-chunks cfg session-id) + content (-> content + (assoc :filename (str "upload:" name)) + (assoc :mtype mtype) + (media/validate-media-type!) + (media/validate-media-size!)) mobj (create-file-media-object cfg (assoc params - :id (or id (uuid/next)) + :id id + :from-chunks? true :content content))] (db/update! conn :file diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index db552ae070..55535f54a6 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -11,6 +11,9 @@ [app.common.data :as d] [app.common.exceptions :as ex] [app.common.schema :as sm] + [app.common.time :as ct] + [app.common.types.nitrate-permissions :as nitrate-perms] + [app.config :as cf] [app.db :as db] [app.nitrate :as nitrate] [app.rpc :as-alias rpc] @@ -56,6 +59,41 @@ [cfg _params] (nitrate/call cfg :connectivity {})) +(def ^:private schema:redeem-activation-code-params + [:map {:title "RedeemActivationCodeParams"} + [:activation-code ::sm/text]]) + +(def ^:private schema:redeem-activation-code-result + [:map {:title "RedeemActivationCodeResult"} + [:cancel-at [:maybe ct/schema:inst]]]) + +(sv/defmethod ::redeem-nitrate-activation-code + {::rpc/auth true + ::doc/added "2.14" + ::sm/params schema:redeem-activation-code-params + ::sm/result schema:redeem-activation-code-result} + [cfg {:keys [::rpc/profile-id activation-code]}] + (let [profile (db/get cfg :profile {:id profile-id})] + (try + (let [result (nitrate/call cfg :redeem-activation-code + {:request-params {:code activation-code + :penpot-id profile-id + :email (:email profile)}})] + (when-not result + (ex/raise :type :validation + :code :invalid-activation-code + :hint "The activation code is invalid, expired or fully redeemed")) + result) + (catch Exception cause + (let [{:keys [type status]} (ex-data cause)] + (if (= type :nitrate-http-error) + (ex/raise :type :validation + :code (case status + 410 :expired-activation-code + :invalid-activation-code) + :cause cause) + (throw cause))))))) + (def ^:private sql:prefix-team-name-and-unset-default "UPDATE team SET name = ? || name, @@ -74,7 +112,7 @@ AND t.id = ANY(?) AND t.deleted_at IS NULL") -(def ^:private sql:get-team-files-count +(def sql:get-team-files-count "SELECT count(*) AS total FROM file AS f JOIN project AS p ON (p.id = f.project_id) @@ -244,32 +282,84 @@ (assert-is-owner cfg profile-id team-id) (assert-not-default-team cfg team-id) (assert-membership cfg profile-id organization-id) + ;; Check moveTeams permission on the source organization + (when (contains? cf/flags :nitrate) + (let [org-perms (nitrate/call cfg :get-org-permissions + {:organization-id organization-id})] + (if (nil? org-perms) + (ex/raise :type :validation + :code :not-allowed + :hint "Unable to verify organization permissions") + (when-not (nitrate-perms/allowed? :move-team + {:org-perms org-perms + :profile-id profile-id}) + (ex/raise :type :validation + :code :not-allowed + :hint "You are not allowed to move teams that are part of this organization. If you need more information, contact the owner."))))) ;; Api call to nitrate (nitrate/call cfg :remove-team-from-org {:team-id team-id :organization-id organization-id}) ;; Notify connected users - (notifications/notify-team-change cfg team-id nil nil organization-name "dashboard.team-no-longer-belong-org") + (notifications/notify-team-change cfg {:id team-id :organization {:name organization-name}} "dashboard.team-no-longer-belong-org") nil) -(def ^:private schema:add-team-to-org +(def ^:private schema:add-team-to-organization [:map [:team-id ::sm/uuid] - [:organization-id ::sm/uuid] - [:organization-name ::sm/text]]) + [:organization-id ::sm/uuid]]) -(sv/defmethod ::add-team-to-org +(sv/defmethod ::add-team-to-organization {::rpc/auth true ::doc/added "2.17" - ::sm/params schema:add-team-to-org + ::sm/params schema:add-team-to-organization ::db/transaction true} - [cfg {:keys [::rpc/profile-id team-id organization-id organization-name]}] + [cfg {:keys [::rpc/profile-id team-id organization-id]}] (assert-is-owner cfg profile-id team-id) (assert-not-default-team cfg team-id) (assert-membership cfg profile-id organization-id) + (when (contains? cf/flags :nitrate) + (let [team-with-org (nitrate/call cfg :get-team-org {:team-id team-id}) + source-org-id (get-in team-with-org [:organization :id]) + source-org-perms (when source-org-id + (nitrate/call cfg :get-org-permissions + {:organization-id source-org-id})) + target-org-perms (nitrate/call cfg :get-org-permissions + {:organization-id organization-id}) + target-org-same-owner? (and (some? source-org-perms) + (some? target-org-perms) + (= (:owner-id source-org-perms) + (:owner-id target-org-perms)))] + (when (nil? target-org-perms) + (ex/raise :type :validation + :code :not-allowed + :hint "Unable to verify organization permissions")) + + ;; Team already belongs to an organization: check move-teams on source org. + (when (some? source-org-id) + (when (nil? source-org-perms) + (ex/raise :type :validation + :code :not-allowed + :hint "Unable to verify organization permissions")) + (when-not (nitrate-perms/allowed? :move-team + {:org-perms source-org-perms + :profile-id profile-id + :target-org-same-owner? target-org-same-owner?}) + (ex/raise :type :validation + :code :not-allowed + :hint "You are not allowed to move teams that are part of this organization. If you need more information, contact the owner."))) + + ;; Always check target create-teams permission (new/add and move flows). + (when-not (nitrate-perms/allowed? :create-team + {:org-perms target-org-perms + :profile-id profile-id}) + (ex/raise :type :validation + :code :not-allowed + :hint "You are not allowed to add teams in this organization")))) + (let [team-members (db/query cfg :team-profile-rel {:team-id team-id})] ;; Add teammates to the org if needed (doseq [{member-id :profile-id} team-members @@ -277,8 +367,8 @@ (teams/initialize-user-in-nitrate-org cfg member-id organization-id))) ;; Api call to nitrate - (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false}) + (let [team (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false})] - ;; Notify connected users - (notifications/notify-team-change cfg team-id nil organization-id organization-name "dashboard.team-belong-org") + ;; Notify connected users + (notifications/notify-team-change cfg team "dashboard.team-belong-org")) nil) diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index ce6c1d71c0..27cef605a6 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -48,6 +48,7 @@ (def schema:props [:map {:title "ProfileProps"} [:plugins {:optional true} schema:plugin-registry] + [:renderer {:optional true} [::sm/one-of #{:svg :wasm}]] [:mcp-enabled {:optional true} ::sm/boolean] [:newsletter-updates {:optional true} ::sm/boolean] [:newsletter-news {:optional true} ::sm/boolean] @@ -109,8 +110,10 @@ (nitrate/add-nitrate-licence-to-profile cfg profile) profile)) - (catch Throwable _ - {:id uuid/zero :fullname "Anonymous User"}))) + (catch Throwable cause + (if (= :not-found (-> cause ex-data :type)) + {:id uuid/zero :fullname "Anonymous User"} + (throw cause))))) (defn get-profile "Get profile by id. Throws not-found exception if no profile found." @@ -264,6 +267,7 @@ [cfg {:keys [::rpc/profile-id file] :as params}] ;; Validate incoming mime type (media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"}) + (media/validate-media-size! file) (update-profile-photo cfg (assoc params :profile-id profile-id))) (defn update-profile-photo @@ -481,8 +485,15 @@ {:deleted-at deleted-at} {:id profile-id}) - ;; Api call to nitrate - (nitrate/call cfg :remove-profile-from-all-orgs {:profile-id profile-id}) + ;; Delete owned organizations on the fly (no grace period). + ;; Nitrate iterates the user's owned orgs and, per org, calls + ;; Penpot back via ::notify-organization-deletion which renames + ;; the org's teams (prefixed with "[OrgName] ", including the + ;; user's "Your Penpot" team) and soft-deletes empty ones. + (when (contains? cf/flags :nitrate) + (nitrate/call cfg :delete-owned-orgs {:profile-id profile-id}) + ;; Remove the user from any remaining org memberships. + (nitrate/call cfg :remove-profile-from-all-orgs {:profile-id profile-id})) ;; Schedule cascade deletion to a worker (wrk/submit! {::db/conn conn @@ -491,7 +502,6 @@ :deleted-at deleted-at :id profile-id}}) - (-> (rph/wrap nil) (rph/with-transform (session/delete-fn cfg))))) @@ -518,6 +528,29 @@ (let [editors (db/exec! cfg [sql:get-subscription-editors profile-id])] {:editors editors})) +;; --- QUERY: Owned Organizations Summary (for delete-account modal) + +(def ^:private schema:owned-organization-summary + [:map + [:id ::sm/uuid] + [:name ::sm/text] + [:slug ::sm/text] + [:team-count ::sm/int] + [:member-count ::sm/int]]) + +(def ^:private schema:get-owned-organizations-summary-result + [:vector schema:owned-organization-summary]) + +(sv/defmethod ::get-owned-organizations-summary + "List organizations owned by the current profile with team and member counts. + Used by the delete-account modal to warn the user about cascading deletion." + {::doc/added "2.18" + ::sm/result schema:get-owned-organizations-summary-result} + [cfg {:keys [::rpc/profile-id]}] + (if (contains? cf/flags :nitrate) + (or (nitrate/call cfg :get-owned-orgs-summary {:profile-id profile-id}) []) + [])) + ;; --- HELPERS (def sql:owned-teams diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 58a1fff217..542c77adbd 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -12,6 +12,7 @@ [app.common.features :as cfeat] [app.common.schema :as sm] [app.common.time :as ct] + [app.common.types.nitrate-permissions :as nitrate-perms] [app.common.types.team :as types.team] [app.common.uuid :as uuid] [app.config :as cf] @@ -193,7 +194,9 @@ (dm/with-open [conn (db/open pool)] (cond->> (get-teams conn profile-id) (contains? cf/flags :nitrate) - (map #(nitrate/add-org-info-to-team cfg % params))))) + (map #(nitrate/add-org-info-to-team cfg % params)) + (contains? cf/flags :nitrate) + (remove #(get-in % [:organization :expired-license]))))) (def ^:private sql:get-owned-teams "SELECT t.id, t.name, @@ -497,7 +500,7 @@ (def ^:private schema:create-team [:map {:title "create-team"} - [:name [:string {:max 250}]] + [:name types.team/schema:team-name] [:features {:optional true} ::cfeat/features] [:id {:optional true} ::sm/uuid] [:organization-id {:optional true} ::sm/uuid] @@ -506,11 +509,27 @@ (sv/defmethod ::create-team {::doc/added "1.17" ::sm/params schema:create-team} - [cfg {:keys [::rpc/profile-id] :as params}] + [cfg {:keys [::rpc/profile-id organization-id] :as params}] (quotes/check! cfg {::quotes/id ::quotes/teams-per-profile ::quotes/profile-id profile-id}) + ;; When creating inside an org, verify the user has permission to do so. + ;; Fail closed: if org permissions cannot be fetched, deny the operation. + (when (and organization-id (contains? cf/flags :nitrate)) + (let [org-perms (nitrate/call cfg :get-org-permissions + {:organization-id organization-id})] + (if (nil? org-perms) + (ex/raise :type :validation + :code :not-allowed + :hint "Unable to verify organization permissions") + (when-not (nitrate-perms/allowed? :create-team + {:org-perms org-perms + :profile-id profile-id}) + (ex/raise :type :validation + :code :not-allowed + :hint "You are not allowed to create teams in this organization"))))) + (let [features (-> (cfeat/get-enabled-features cf/flags) (set/difference cfeat/frontend-only-features) (set/difference cfeat/no-team-inheritable-features)) @@ -667,7 +686,7 @@ (def ^:private schema:update-team [:map {:title "update-team"} - [:name [:string {:max 250}]] + [:name types.team/schema:team-name] [:id ::sm/uuid]]) (sv/defmethod ::update-team @@ -757,12 +776,27 @@ (defn delete-team "Mark a team for deletion" - [{:keys [::db/conn] :as cfg} {:keys [profile-id team-id]}] + [{:keys [::db/conn] :as cfg} {:keys [profile-id team-id] :as params}] (let [team (get-team conn :profile-id profile-id :team-id team-id) - perms (get team :permissions)] + team (if (contains? cf/flags :nitrate) + (nitrate/add-org-info-to-team cfg team params) + team) + perms (get team :permissions) + org (:organization team) + in-org? (and (contains? cf/flags :nitrate) org) + can-delete? + (if in-org? + (nitrate-perms/allowed? :delete-team + {:org-perms {:owner-id (dm/get-in team [:organization :owner-id]) + :permissions (dm/get-in team [:organization :permissions])} + :profile-id profile-id + :team-perms perms + ;; `onlyMe` is for a future org-level flow. + :allow-org-owner-delete? false}) + (boolean (:is-owner perms)))] - (when-not (:is-owner perms) + (when-not can-delete? (ex/raise :type :validation :code :only-owner-can-delete-team)) @@ -918,6 +952,7 @@ ;; Validate incoming mime type (media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"}) + (media/validate-media-size! file) (update-team-photo cfg (assoc params :profile-id profile-id))) (defn update-team-photo diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index aaa4f22bc3..a7c551e941 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -19,6 +19,7 @@ [app.config :as cf] [app.db :as db] [app.email :as eml] + [app.email.blacklist :as email.blacklist] [app.loggers.audit :as audit] [app.main :as-alias main] [app.nitrate :as nitrate] @@ -91,6 +92,7 @@ [:map [:id ::sm/uuid] [:name :string] + [:initials [:maybe :string]] [:logo ::sm/uri]]] [:profile [:map @@ -122,6 +124,12 @@ (let [email (profile/clean-email email) member (profile/get-profile-by-email conn email)] + (when (and (email.blacklist/enabled? cfg) + (email.blacklist/contains? cfg email)) + (ex/raise :type :restriction + :code :email-domain-is-not-allowed + :hint "email domain is in the blacklist")) + ;; When we have email verification disabled and invitation user is ;; already present in the database, we proceed to add it to the ;; team as-is, without email roundtrip. @@ -197,9 +205,9 @@ organization "create-org-invitation" :else "create-team-invitation") event (-> (audit/event-from-rpc-params params) - (assoc ::audit/name evname) - (assoc ::audit/props props))] - (audit/submit! cfg event)) + (assoc :name evname) + (assoc :props props))] + (audit/submit cfg event)) (when (allow-invitation-emails? member) (if organization @@ -211,8 +219,8 @@ :invited-by (:fullname profile) :user-name (:fullname member) :organization-name (:name organization) - :org-logo (:logo organization) - :org-initials (d/get-initials (:name organization)) + :organization-logo (:logo organization) + :organization-initials (:initials organization) :token itoken :extra-data ptoken})) (let [team (if (contains? cf/flags :nitrate) @@ -224,18 +232,18 @@ :to email :invited-by (:fullname profile) :team (:name team) - :organization (:organization-name team) + :organization (dm/get-in team [:organization :name]) :token itoken :extra-data ptoken})))) itoken))))) (defn create-org-invitation - [cfg {:keys [::rpc/profile-id id name logo] :as params}] + [cfg {:keys [::rpc/profile-id id name initials logo] :as params}] (let [profile (db/get-by-id cfg :profile profile-id)] (create-invitation cfg (assoc params - :organization {:id id :name name :logo logo} + :organization {:id id :name name :initials initials :logo logo} :profile profile :role :editor)))) @@ -479,9 +487,9 @@ (let [props {:name name :features features} event (-> (audit/event-from-rpc-params params) - (assoc ::audit/name "create-team") - (assoc ::audit/props props))] - (audit/submit! cfg event)) + (assoc :name "create-team") + (assoc :props props))] + (audit/submit cfg event)) ;; Create invitations for all provided emails. (let [profile (db/get-by-id conn :profile profile-id) diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj index cc270b3ded..5061cc84f0 100644 --- a/backend/src/app/rpc/commands/verify_token.clj +++ b/backend/src/app/rpc/commands/verify_token.clj @@ -74,6 +74,11 @@ {:is-active true} {:id (:id profile)})) + ;; NOTE: `claims` is returned verbatim (besides :profile). When the + ;; verify-email JWE was minted by `register-profile` for a not-yet- + ;; active profile that came from an invitation flow, `:invitation- + ;; token` will be present here and the frontend will use it to + ;; complete the team-invitation flow after login. (-> claims (rph/with-transform (session/create-fn cfg profile)) (rph/with-meta {::audit/name "verify-profile-email" @@ -190,6 +195,7 @@ (= member-email (:email profile))) (ex/raise :type :validation :code :invalid-token + :reason :email-mismatch :hint "logged-in user does not matches the invitation")) (when (:is-member membership) @@ -217,24 +223,22 @@ :role (:role claims) :invitation-id (:id invitation)}] - (audit/submit! - cfg - (-> (audit/event-from-rpc-params params) - (assoc ::audit/name "accept-team-invitation") - (assoc ::audit/props props))) + (audit/submit cfg + (-> (audit/event-from-rpc-params params) + (assoc :name "accept-team-invitation") + (assoc :props props))) ;; NOTE: Backward compatibility; old invitations can ;; have the `created-by` to be nil; so in this case we ;; don't submit this event to the audit-log (when-let [created-by (:created-by invitation)] - (audit/submit! - cfg - (-> (audit/event-from-rpc-params params) - (assoc ::audit/profile-id created-by) - (assoc ::audit/name "accept-team-invitation-from") - (assoc ::audit/props (assoc props - :profile-id (:id profile) - :email (:email profile)))))) + (audit/submit cfg + (-> (audit/event-from-rpc-params params) + (assoc :profile-id created-by) + (assoc :name "accept-team-invitation-from") + (assoc :props (assoc props + :profile-id (:id profile) + :email (:email profile)))))) (let [accepted-team-id (accept-invitation cfg claims invitation profile)] (cond-> (assoc claims :state :created) @@ -243,13 +247,21 @@ (:organization-id claims) (assoc :org-team-id accepted-team-id))))) - ;; If we have not logged-in user, and invitation comes with member-id we - ;; redirect user to login, if no memeber-id is present and in the invitation - ;; token and registration is enabled, we redirect user the the register page. - {:invitation-token token - :iss :team-invitation - :redirect-to (if (or member-id registration-disabled?) :auth-login :auth-register) - :state :pending}))) + (do + ;; If the user is not logged-in and the token is invalid we throw the error + ;; Taiga issue #14182 + (when (nil? invitation) + (ex/raise :type :validation + :code :invalid-token + :hint "no invitation associated with the token")) + + ;; If we have not logged-in user, and invitation comes with member-id we + ;; redirect user to login, if no member-id is present and in the invitation + ;; token and registration is enabled, we redirect user the the register page. + {:invitation-token token + :iss :team-invitation + :redirect-to (if (or member-id registration-disabled?) :auth-login :auth-register) + :state :pending})))) ;; --- Default diff --git a/backend/src/app/rpc/commands/viewer.clj b/backend/src/app/rpc/commands/viewer.clj index d2b191aeb4..37adca244f 100644 --- a/backend/src/app/rpc/commands/viewer.clj +++ b/backend/src/app/rpc/commands/viewer.clj @@ -28,19 +28,25 @@ (update :pages-index select-keys allowed))) (defn obfuscate-email + "Obfuscate the `email` for share-link members so the viewer only sees a + partially redacted address. Accepts any string shape (including nil, + missing `@`, or a domain with no `.`) and falls back to a fully-masked + result rather than throwing — the function is called while building the + view-only bundle for anonymous viewers, so an NPE here would abort the + entire share-link response." [email] (let [[name domain] - (str/split email "@" 2) + (str/split (or email "") "@" 2) [_ rest] - (str/split domain "." 2) + (str/split (or domain "") "." 2) name (if (> (count name) 3) (str (subs name 0 1) (apply str (take (dec (count name)) (repeat "*")))) "****")] - (str name "@****." rest))) + (str name "@****" (when rest (str "." rest))))) (defn anonymize-member [member] diff --git a/backend/src/app/rpc/commands/webhooks.clj b/backend/src/app/rpc/commands/webhooks.clj index 5b64541824..7a3c441335 100644 --- a/backend/src/app/rpc/commands/webhooks.clj +++ b/backend/src/app/rpc/commands/webhooks.clj @@ -50,24 +50,27 @@ (defn- validate-webhook! [cfg whook params] (when (not= (:uri whook) (:uri params)) - (let [response (ex/try! - (http/req! cfg + (try + (let [response (http/req cfg {:method :head :uri (str (:uri params)) - :timeout (ct/duration "3s")} - {:sync? true}))] - (if (ex/exception? response) - (if-let [hint (webhooks/interpret-exception response)] - (ex/raise :type :validation - :code :webhook-validation - :hint hint) - (ex/raise :type :internal - :code :webhook-validation - :cause response)) + :timeout (ct/duration "3s")})] (when-let [hint (webhooks/interpret-response response)] (ex/raise :type :validation :code :webhook-validation - :hint hint)))))) + :hint hint))) + + (catch Throwable cause + (if-let [hint (webhooks/interpret-exception cause)] + (ex/raise :type :validation + :code :webhook-validation + :hint hint + :webhook-uri (str (:uri params)) + :cause cause) + (ex/raise :type :internal + :code :webhook-validation + :webhook-uri (str (:uri params)) + :cause cause)))))) (defn- validate-quotes! [{:keys [::db/pool]} {:keys [team-id]}] diff --git a/backend/src/app/rpc/cond.clj b/backend/src/app/rpc/cond.clj index aeb7f7d99d..41c211bf36 100644 --- a/backend/src/app/rpc/cond.clj +++ b/backend/src/app/rpc/cond.clj @@ -19,7 +19,7 @@ of the object. This function can be applied to the object returned by the `get-object` but also to the RPC return value (in case you don't provide the return value calculated key under `::key` metadata prop. - - `::reuse-key?` enables reusing the key calculated on first time; usefull + - `::reuse-key?` enables reusing the key calculated on first time; useful when the target object is not retrieved on the RPC (typical on retrieving dependent objects). " diff --git a/backend/src/app/rpc/doc.clj b/backend/src/app/rpc/doc.clj index 611be93b15..62dffae23f 100644 --- a/backend/src/app/rpc/doc.clj +++ b/backend/src/app/rpc/doc.clj @@ -96,6 +96,7 @@ context (assoc @context :param-style pstyle)] {::yres/status 200 + ::yres/headers {"content-type" "text/html; charset=utf-8"} ::yres/body (-> (io/resource template) (tmpl/render context))}))) (fn [_] diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 58a06e5c65..1bccedef3f 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -11,11 +11,14 @@ [app.common.data :as d] [app.common.exceptions :as ex] [app.common.schema :as sm] + [app.common.time :as ct] + [app.common.types.organization :refer [schema:team-with-organization]] [app.common.types.profile :refer [schema:profile, schema:basic-profile]] [app.common.types.team :refer [schema:team]] [app.config :as cf] [app.db :as db] [app.media :as media] + [app.nitrate :as nitrate] [app.rpc :as-alias rpc] [app.rpc.commands.files :as files] [app.rpc.commands.nitrate :as cnit] @@ -25,7 +28,8 @@ [app.rpc.doc :as doc] [app.rpc.notifications :as notifications] [app.storage :as sto] - [app.util.services :as sv])) + [app.util.services :as sv] + [app.worker :as wrk])) (defn- profile-to-map [profile] @@ -60,13 +64,26 @@ ;; ---- API: get-penpot-version (def ^:private schema:get-penpot-version-result - [:map [:version ::sm/text]]) + [:map + [:version + [:map + [:full [:maybe ::sm/text]] + [:branch [:maybe ::sm/text]] + [:base [:maybe ::sm/text]] + [:main [:maybe ::sm/text]] + [:major [:maybe ::sm/text]] + [:minor [:maybe ::sm/text]] + [:patch [:maybe ::sm/text]] + [:modifier [:maybe ::sm/text]] + [:commit [:maybe ::sm/text]] + [:commit-hash [:maybe ::sm/text]]]]]) (sv/defmethod ::get-penpot-version "Get the current Penpot version" {::doc/added "2.14" ::sm/params [:map] - ::sm/result schema:get-penpot-version-result} + ::sm/result schema:get-penpot-version-result + ::rpc/auth false} [_cfg _params] {:version cf/version}) @@ -116,22 +133,13 @@ ;; ---- API: notify-team-change -(def ^:private schema:notify-team-change - [:map - [:id ::sm/uuid] - [:organization-id ::sm/uuid] - [:organization-name ::sm/text]]) - - - - (sv/defmethod ::notify-team-change "Notify to Penpot a team change from nitrate" {::doc/added "2.14" - ::sm/params schema:notify-team-change + ::sm/params schema:team-with-organization ::rpc/auth false} - [cfg {:keys [id organization-id organization-name]}] - (notifications/notify-team-change cfg id nil organization-id organization-name nil) + [cfg team] + (notifications/notify-team-change cfg (select-keys team [:id :is-your-penpot :organization]) nil) nil) ;; ---- API: notify-user-added-to-organization @@ -142,8 +150,6 @@ [:organization-id ::sm/uuid] [:role ::sm/text]]) - - (sv/defmethod ::notify-user-added-to-organization "Notify to Penpot that an user has joined an org from nitrate" {::doc/added "2.14" @@ -247,30 +253,111 @@ WHERE id = ANY(?) RETURNING id, name;") +(def ^:private sql:get-teams-files-counts + "SELECT p.team_id, COUNT(f.*) AS total + FROM file AS f + JOIN project AS p ON (p.id = f.project_id) + JOIN team AS t ON (t.id = p.team_id) + WHERE t.id = ANY(?) + AND t.deleted_at IS NULL + AND p.deleted_at IS NULL + AND f.deleted_at IS NULL + GROUP BY p.team_id;") -(def ^:private schema:notify-org-deletion +(def ^:private sql:soft-delete-teams + "UPDATE team + SET deleted_at = ? + WHERE id = ANY(?) +RETURNING id, deleted_at;") + + +;; ---- API: notify-organization-deletion + +(def ^:private schema:notify-organization-deletion [:map - [:organization-name ::sm/text] - [:teams [:vector ::sm/uuid]]]) + [:organization-id ::sm/uuid]]) -(sv/defmethod ::notify-org-deletion + +(defn- soft-delete-teams! + "Soft-delete the provided team ids and submit a delete task per team." + [{:keys [::db/conn] :as cfg} team-ids] + (when (seq team-ids) + (let [delay (cf/get-deletion-delay) + deleted-at (ct/in-future delay) + updated (db/exec! conn [sql:soft-delete-teams + deleted-at + (db/create-array conn "uuid" team-ids)])] + (doseq [{:keys [id deleted-at]} updated] + (wrk/submit! {::db/conn conn + ::wrk/task :delete-object + ::wrk/params {:object :team + :deleted-at deleted-at + :id id}})))) + nil) + +(defn manage-deleted-organization-teams + "For a list of teams, rename those with files and delete those without, then notify users." + [cfg {:keys [teams organization-name]}] + (let [teams (->> teams (filter uuid?) distinct (into []))] + (when (seq teams) + (let [org-prefix (str "[" (d/sanitize-string organization-name) "] ")] + (db/tx-run! + cfg + (fn [{:keys [::db/conn] :as cfg}] + (let [teams-array (db/create-array conn "uuid" teams) + teams-with-files (->> (db/exec! conn [sql:get-teams-files-counts teams-array]) + (filter (fn [{:keys [total]}] (pos? total))) + (map :team-id) + (into #{})) + teams-to-keep (->> teams (filter teams-with-files) (into [])) + teams-to-delete (->> teams (remove teams-with-files) (into []))] + + ;; Rename teams that have files in one go + (when (seq teams-to-keep) + (db/exec! conn [sql:prefix-teams-name-and-unset-default + org-prefix + (db/create-array conn "uuid" teams-to-keep)])) + + ;; Soft-delete empty teams in one go + (soft-delete-teams! cfg teams-to-delete) + + (notifications/notify-organization-deletion cfg organization-name teams teams-to-delete) + nil))))))) + + +(sv/defmethod ::notify-organization-deletion "For a list of teams, rename them with the name of the deleted org, and notify of the deletion to the connected users" {::doc/added "2.15" - ::sm/params schema:notify-org-deletion} - [cfg {:keys [teams organization-name]}] - (when (seq teams) - (let [org-prefix (str "[" (d/sanitize-string organization-name) "] ")] - (db/tx-run! - cfg - (fn [{:keys [::db/conn] :as cfg}] - (let [ids-array (db/create-array conn "uuid" teams) - ;; Rename projects - updated-teams (db/exec! conn [sql:prefix-teams-name-and-unset-default org-prefix ids-array])] + ::sm/params schema:notify-organization-deletion + ::rpc/auth false} + [cfg {:keys [organization-id]}] + (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) + teams (->> (:teams org-summary) + (map :id))] + (manage-deleted-organization-teams cfg {:teams teams :organization-name (:name org-summary)}) + nil)) + +;; ---- API: notify-user-organizations-deletion + +(def ^:private schema:notify-user-organizations-deletion + [:map + [:profile-id ::sm/uuid]]) + +(sv/defmethod ::notify-user-organizations-deletion + "For a given user, find all owned organizations and rename or delete their teams." + {::doc/added "2.18" + ::sm/params schema:notify-user-organizations-deletion} + [cfg {:keys [profile-id]}] + (let [owned-orgs (nitrate/call cfg :get-owned-orgs {:profile-id profile-id})] + (doseq [org owned-orgs] + (let [organization-name (:name org) + teams (map :id (:teams org))] + (manage-deleted-organization-teams cfg {:teams teams :organization-name organization-name})))) + nil) + + - ;; Notify users - (doseq [team updated-teams] - (notifications/notify-team-change cfg (:id team) (:name team) nil organization-name "dashboard.org-deleted")))))))) ;; ---- API: get-profile-by-email @@ -369,12 +456,121 @@ RETURNING id, name;") [:email ::sm/email] [:id ::sm/uuid] [:name ::sm/text] + [:initials [:maybe :string]] [:logo ::sm/uri]]} [cfg params] (db/tx-run! cfg ti/create-org-invitation params) nil) +;; API: get-org-invitations + +(def ^:private sql:get-org-invitations + "SELECT DISTINCT ON (email_to) + ti.id, + ti.org_id AS organization_id, + ti.email_to AS email, + ti.created_at AS sent_at, + p.fullname AS name, + p.photo_id + FROM team_invitation AS ti +LEFT JOIN profile AS p + ON p.email = ti.email_to + AND p.deleted_at IS NULL + WHERE ti.valid_until >= now() + AND (ti.org_id = ? OR ti.team_id = ANY(?)) + ORDER BY ti.email_to, ti.valid_until DESC, ti.created_at DESC;") + +(def ^:private schema:get-org-invitations-params + [:map + [:organization-id ::sm/uuid]]) + +(def ^:private schema:get-org-invitations-result + [:vector + [:map + [:id ::sm/uuid] + [:organization-id {:optional true} [:maybe ::sm/uuid]] + [:email ::sm/email] + [:sent-at ::sm/inst] + [:name {:optional true} [:maybe ::sm/text]] + [:photo-url {:optional true} ::sm/uri]]]) + +(sv/defmethod ::get-org-invitations + "Get valid invitations for an organization, returning at most one invitation per email." + {::doc/added "2.16" + ::sm/params schema:get-org-invitations-params + ::sm/result schema:get-org-invitations-result} + [cfg {:keys [organization-id]}] + (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) + team-ids (->> (:teams org-summary) + (map :id) + (filter uuid?) + (into []))] + (db/run! cfg (fn [{:keys [::db/conn]}] + (let [ids-array (db/create-array conn "uuid" team-ids)] + (->> (db/exec! conn [sql:get-org-invitations organization-id ids-array]) + (mapv (fn [{:keys [photo-id] :as invitation}] + (cond-> (dissoc invitation :photo-id) + photo-id + (assoc :photo-url (files/resolve-public-uri photo-id))))))))))) + + +;; API: delete-org-invitations + +(def ^:private sql:delete-org-invitations + "DELETE FROM team_invitation AS ti + WHERE ti.email_to = ? + AND (ti.org_id = ? OR ti.team_id = ANY(?));") + +(def ^:private schema:delete-org-invitations-params + [:map + [:organization-id ::sm/uuid] + [:email ::sm/email]]) + +(sv/defmethod ::delete-org-invitations + "Delete all invitations for one email in an organization scope (org + org teams)." + {::doc/added "2.16" + ::sm/params schema:delete-org-invitations-params} + [cfg {:keys [organization-id email]}] + (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) + clean-email (profile/clean-email email) + team-ids (->> (:teams org-summary) + (map :id) + (filter uuid?) + (into []))] + (db/run! cfg (fn [{:keys [::db/conn]}] + (let [ids-array (db/create-array conn "uuid" team-ids)] + (db/exec! conn [sql:delete-org-invitations clean-email organization-id ids-array])))) + nil)) + + +;; API: delete-all-org-invitations + +(def ^:private sql:delete-all-org-invitations + "DELETE FROM team_invitation AS ti + WHERE ti.org_id = ? + OR ti.team_id = ANY(?);") + +(def ^:private schema:delete-all-org-invitations-params + [:map + [:organization-id ::sm/uuid]]) + +(sv/defmethod ::delete-all-org-invitations + "Delete every pending invitation associated with an organization (org-level + team-level). + Called from Nitrate when an organization is about to be deleted, so users that click + their invitation token hit the existing invalid-token landing page." + {::doc/added "2.18" + ::sm/params schema:delete-all-org-invitations-params + ::rpc/auth false} + [cfg {:keys [organization-id]}] + (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) + team-ids (->> (:teams org-summary) + (map :id))] + (db/run! cfg (fn [{:keys [::db/conn]}] + (let [ids-array (db/create-array conn "uuid" team-ids)] + (db/exec! conn [sql:delete-all-org-invitations organization-id ids-array])))) + nil)) + ;; API: remove-from-org diff --git a/backend/src/app/rpc/notifications.clj b/backend/src/app/rpc/notifications.clj index 3bdb2c8a3b..a439741092 100644 --- a/backend/src/app/rpc/notifications.clj +++ b/backend/src/app/rpc/notifications.clj @@ -10,17 +10,14 @@ [app.msgbus :as mbus])) (defn notify-team-change - [cfg team-id team-name organization-id organization-name notification] + [cfg team notification] (let [msgbus (::mbus/msgbus cfg)] (mbus/pub! msgbus ;;TODO There is a bug on dashboard with teams notifications. ;;For now we send it to uuid/zero instead of team-id :topic uuid/zero :message {:type :team-org-change - :team-id team-id - :team-name team-name - :organization-id organization-id - :organization-name organization-name + :team team :notification notification}))) @@ -33,4 +30,15 @@ :topic profile-id :organization-id organization-id :organization-name organization-name - :notification notification}))) \ No newline at end of file + :notification notification}))) + + +(defn notify-organization-deletion + [cfg organization-name teams deleted-teams] + (let [msgbus (::mbus/msgbus cfg)] + (mbus/pub! msgbus + :topic uuid/zero + :message {:type :organization-deleted + :organization-name organization-name + :teams teams + :deleted-teams deleted-teams}))) \ No newline at end of file diff --git a/backend/src/app/setup.clj b/backend/src/app/setup.clj index 2a860f4262..66d80f1a3b 100644 --- a/backend/src/app/setup.clj +++ b/backend/src/app/setup.clj @@ -11,7 +11,9 @@ [app.common.logging :as l] [app.common.schema :as sm] [app.common.uuid :as uuid] + [app.config :as cf] [app.db :as db] + [app.loggers.audit :as audit] [app.main :as-alias main] [app.setup.keys :as keys] [app.setup.templates] @@ -35,22 +37,20 @@ (into {}))) (defn- handle-instance-id - [instance-id conn read-only?] + [instance-id conn] (or instance-id (let [instance-id (uuid/random)] - (when-not read-only? - (try - (db/insert! conn :server-prop - {:id "instance-id" - :preload true - :content (db/tjson instance-id)}) - (catch Throwable cause - (l/warn :hint "unable to persist instance-id" - :instance-id instance-id - :cause cause)))) + (try + (db/insert! conn :server-prop + {:id "instance-id" + :preload true + :content (db/tjson instance-id)}) + (catch Throwable cause + (l/warn :hint "unable to persist instance-id" + :instance-id instance-id + :cause cause))) instance-id))) - (def sql:add-prop "INSERT INTO server_prop (id, content, preload) VALUES (?, ?, ?) @@ -77,7 +77,12 @@ (assert (db/pool? (::db/pool params)) "expected valid database pool")) (defmethod ig/init-key ::props - [_ {:keys [::db/pool ::key] :as cfg}] + [_ {:keys [::key] :as cfg}] + (audit/submit cfg {:type "trigger" + :name "instance-start" + :props {:version (:full cf/version) + :flags (mapv name cf/flags) + :public-uri (str (cf/get :public-uri))}}) (db/tx-run! cfg (fn [{:keys [::db/conn]}] (db/xact-lock! conn 0) @@ -91,7 +96,7 @@ (-> (get-all-props conn) (assoc :secret-key secret) (assoc :tokens-key (keys/derive secret :salt "tokens")) - (update :instance-id handle-instance-id conn (db/read-only? pool))))))) + (update :instance-id handle-instance-id conn)))))) (defmethod ig/init-key ::shared-keys [_ {:keys [::props] :as cfg}] diff --git a/backend/src/app/setup/templates.clj b/backend/src/app/setup/templates.clj index 476ec25f58..5c86bc485d 100644 --- a/backend/src/app/setup/templates.clj +++ b/backend/src/app/setup/templates.clj @@ -57,9 +57,9 @@ (if (fs/exists? path) (io/input-stream path) - (let [resp (http/req! cfg - {:method :get :uri (:file-uri template)} - {:response-type :input-stream :sync? true})] + (let [resp (http/req cfg + {:method :get :uri (:file-uri template)} + {:response-type :input-stream :sync? true})] (when-not (= 200 (:status resp)) (ex/raise :type :internal :code :unexpected-status-code diff --git a/backend/src/app/srepl/main.clj b/backend/src/app/srepl/main.clj index f25bec50cb..a37ee3e427 100644 --- a/backend/src/app/srepl/main.clj +++ b/backend/src/app/srepl/main.clj @@ -553,14 +553,13 @@ (let [file-id (h/parse-uuid file-id) tnow (ct/now)] - (audit/insert! main/system - {::audit/name "delete-file" - ::audit/type "action" - ::audit/profile-id uuid/zero - ::audit/props {:id file-id} - ::audit/context {:triggered-by "srepl" - :cause "explicit call to delete-file!"} - ::audit/tracked-at tnow}) + (audit/insert main/system + {:name "delete-file" + :type "action" + :props {:id file-id} + :context {:triggered-by "srepl" + :cause "explicit call to delete-file!"} + :tracked-at tnow}) (wrk/invoke! (-> main/system (assoc ::wrk/task :delete-object) (assoc ::wrk/params {:object :file @@ -578,17 +577,14 @@ {:id file-id} {::db/remove-deleted false ::sql/columns [:id :name]})] - (audit/insert! system - {::audit/name "restore-file" - ::audit/type "action" - ::audit/profile-id uuid/zero - ::audit/props file - ::audit/context {:triggered-by "srepl" - :cause "explicit call to restore-file!"} - ::audit/tracked-at (ct/now)}) + (audit/insert system + {:name "restore-file" + :type "action" + :props file + :context {:triggered-by "srepl" + :cause "explicit call to restore-file!"}}) - - (#'files/restore-file conn file-id)) + (#'files/restore-files conn [file-id])) :restored)))) (defn delete-project! @@ -597,14 +593,13 @@ (let [project-id (h/parse-uuid project-id) tnow (ct/now)] - (audit/insert! main/system - {::audit/name "delete-project" - ::audit/type "action" - ::audit/profile-id uuid/zero - ::audit/props {:id project-id} - ::audit/context {:triggered-by "srepl" - :cause "explicit call to delete-project!"} - ::audit/tracked-at tnow}) + (audit/insert main/system + {:name "delete-project" + :type "action" + :props {:id project-id} + :context {:triggered-by "srepl" + :cause "explicit call to delete-project!"} + :tracked-at tnow}) (wrk/invoke! (-> main/system (assoc ::wrk/task :delete-object) @@ -622,7 +617,7 @@ (doseq [{:keys [id]} (db/query conn :file {:project-id project-id} {::sql/columns [:id]})] - (#'files/restore-file conn id)) + (#'files/restore-files conn [id])) :restored) @@ -635,14 +630,12 @@ (when-let [project (db/get* system :project {:id project-id} {::db/remove-deleted false})] - (audit/insert! system - {::audit/name "restore-project" - ::audit/type "action" - ::audit/profile-id uuid/zero - ::audit/props project - ::audit/context {:triggered-by "srepl" - :cause "explicit call to restore-team!"} - ::audit/tracked-at (ct/now)}) + (audit/insert system + {:name "restore-project" + :type "action" + :props project + :context {:triggered-by "srepl" + :cause "explicit call to restore-team!"}}) (restore-project* system project-id)))))) @@ -652,14 +645,13 @@ (let [team-id (h/parse-uuid team-id) tnow (ct/now)] - (audit/insert! main/system - {::audit/name "delete-team" - ::audit/type "action" - ::audit/profile-id uuid/zero - ::audit/props {:id team-id} - ::audit/context {:triggered-by "srepl" - :cause "explicit call to delete-profile!"} - ::audit/tracked-at tnow}) + (audit/insert main/system + {:name "delete-team" + :type "action" + :props {:id team-id} + :context {:triggered-by "srepl" + :cause "explicit call to delete-profile!"} + :tracked-at tnow}) (wrk/invoke! (-> main/system (assoc ::wrk/task :delete-object) @@ -695,14 +687,12 @@ {:id team-id} {::db/remove-deleted false}) (teams/decode-row))] - (audit/insert! system - {::audit/name "restore-team" - ::audit/type "action" - ::audit/profile-id uuid/zero - ::audit/props team - ::audit/context {:triggered-by "srepl" - :cause "explicit call to restore-team!"} - ::audit/tracked-at (ct/now)}) + (audit/insert system + {:name "restore-team" + :type "action" + :props team + :context {:triggered-by "srepl" + :cause "explicit call to restore-team!"}}) (restore-team* system team-id)))))) @@ -712,13 +702,12 @@ (let [profile-id (h/parse-uuid profile-id) tnow (ct/now)] - (audit/insert! main/system - {::audit/name "delete-profile" - ::audit/type "action" - ::audit/profile-id uuid/zero - ::audit/context {:triggered-by "srepl" - :cause "explicit call to delete-profile!"} - ::audit/tracked-at tnow}) + (audit/insert main/system + {:name "delete-profile" + :type "action" + :context {:triggered-by "srepl" + :cause "explicit call to delete-profile!"} + :tracked-at tnow}) (wrk/invoke! (-> main/system (assoc ::wrk/task :delete-object) @@ -737,14 +726,12 @@ {:id profile-id} {::db/remove-deleted false}) (profile/decode-row))] - (audit/insert! system - {::audit/name "restore-profile" - ::audit/type "action" - ::audit/profile-id uuid/zero - ::audit/props (audit/profile->props profile) - ::audit/context {:triggered-by "srepl" - :cause "explicit call to restore-profile!"} - ::audit/tracked-at (ct/now)}) + (audit/insert system + {:name "restore-profile" + :type "action" + :props (audit/profile->props profile) + :context {:triggered-by "srepl" + :cause "explicit call to restore-profile!"}}) (db/update! system :profile {:deleted-at nil} @@ -768,14 +755,14 @@ {::db/remove-deleted false}) (profile/decode-row))] (do - (audit/insert! system - {::audit/name "delete-profile" - ::audit/type "action" - ::audit/profile-id (:id profile) - ::audit/tracked-at deleted-at - ::audit/props (audit/profile->props profile) - ::audit/context {:triggered-by "srepl" - :cause "explicit call to delete-profiles-in-bulk!"}}) + (audit/insert system + {:name "delete-profile" + :type "action" + :profile-id (:id profile) + :tracked-at deleted-at + :props (audit/profile->props profile) + :context {:triggered-by "srepl" + :cause "explicit call to delete-profiles-in-bulk!"}}) (wrk/invoke! (-> system (assoc ::wrk/task :delete-object) (assoc ::wrk/params {:object :profile diff --git a/backend/src/app/storage/s3.clj b/backend/src/app/storage/s3.clj index ef56e8a9b4..9322de70e6 100644 --- a/backend/src/app/storage/s3.clj +++ b/backend/src/app/storage/s3.clj @@ -30,21 +30,18 @@ java.nio.file.Path java.time.Duration java.util.Collection - java.util.Optional java.util.concurrent.atomic.AtomicLong + java.util.Optional org.reactivestreams.Subscriber software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider - software.amazon.awssdk.core.ResponseBytes software.amazon.awssdk.core.async.AsyncRequestBody software.amazon.awssdk.core.async.AsyncResponseTransformer software.amazon.awssdk.core.async.BlockingInputStreamAsyncRequestBody software.amazon.awssdk.core.client.config.ClientAsyncConfiguration + software.amazon.awssdk.core.ResponseBytes software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient software.amazon.awssdk.http.nio.netty.SdkEventLoopGroup software.amazon.awssdk.regions.Region - software.amazon.awssdk.services.s3.S3AsyncClient - software.amazon.awssdk.services.s3.S3AsyncClientBuilder - software.amazon.awssdk.services.s3.S3Configuration software.amazon.awssdk.services.s3.model.Delete software.amazon.awssdk.services.s3.model.DeleteObjectRequest software.amazon.awssdk.services.s3.model.DeleteObjectsRequest @@ -54,9 +51,12 @@ software.amazon.awssdk.services.s3.model.ObjectIdentifier software.amazon.awssdk.services.s3.model.PutObjectRequest software.amazon.awssdk.services.s3.model.S3Error - software.amazon.awssdk.services.s3.presigner.S3Presigner software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest - software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest)) + software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest + software.amazon.awssdk.services.s3.presigner.S3Presigner + software.amazon.awssdk.services.s3.S3AsyncClient + software.amazon.awssdk.services.s3.S3AsyncClientBuilder + software.amazon.awssdk.services.s3.S3Configuration)) (def ^:private max-retries "A maximum number of retries on internal operations" diff --git a/backend/src/app/tasks/telemetry.clj b/backend/src/app/tasks/telemetry.clj index aa2cae58e0..797c3e6050 100644 --- a/backend/src/app/tasks/telemetry.clj +++ b/backend/src/app/tasks/telemetry.clj @@ -11,43 +11,27 @@ (:require [app.common.data :as d] [app.common.exceptions :as ex] + [app.common.logging :as l] [app.config :as cf] [app.db :as db] [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])) -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; IMPL -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn- send! - [cfg data] - (let [request {:method :post - :uri (cf/get :telemetry-uri) - :headers {"content-type" "application/json"} - :body (json/encode-str data)} - response (http/req! cfg request)] - (when (> (:status response) 206) - (ex/raise :type :internal - :code :invalid-response - :response-status (:status response) - :response-body (:body response))))) - -(defn- get-subscriptions-newsletter-updates - [conn] +(defn- get-subscriptions + [cfg] (let [sql "SELECT email FROM profile where props->>'~:newsletter-updates' = 'true'"] - (->> (db/exec! conn [sql]) - (mapv :email)))) + (db/run! cfg (fn [{:keys [::db/conn]}] + (->> (db/exec! conn [sql]) + (mapv :email)))))) -(defn- get-subscriptions-newsletter-news - [conn] - (let [sql "SELECT email FROM profile where props->>'~:newsletter-news' = 'true'"] - (->> (db/exec! conn [sql]) - (mapv :email)))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; LEGACY DATA COLLECTION +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defn- get-num-teams [conn] @@ -161,8 +145,9 @@ (def ^:private sql:get-counters "SELECT name, count(*) AS count FROM audit_log - WHERE source = 'backend' - AND tracked_at >= date_trunc('day', now()) + WHERE source LIKE 'telemetry:%' + AND created_at >= date_trunc('day', now()) + AND created_at < date_trunc('day', now()) + interval '1 day' GROUP BY 1 ORDER BY 2 DESC") @@ -174,23 +159,13 @@ {:total-accomulated-events total :event-counters counters})) -(def ^:private sql:clean-counters - "DELETE FROM audit_log - WHERE ip_addr = '0.0.0.0'::inet -- we know this is from telemetry - AND tracked_at < (date_trunc('day', now()) - '1 day'::interval)") - -(defn- clean-counters-data! - [conn] - (when-not (contains? cf/flags :audit-log) - (db/exec-one! conn [sql:clean-counters]))) - -(defn- get-stats - [conn] +(defn- get-legacy-stats + [{:keys [::db/conn]}] (let [referer (if (cf/get :telemetry-with-taiga) "taiga" (cf/get :telemetry-referer))] (-> {:referer referer - :public-uri (cf/get :public-uri) + :public-uri (str (cf/get :public-uri)) :total-teams (get-num-teams conn) :total-projects (get-num-projects conn) :total-files (get-num-files conn) @@ -207,6 +182,124 @@ (get-action-counters conn)) (d/without-nils)))) +(defn- make-legacy-request + [cfg data] + (let [request {:method :post + :uri (cf/get :telemetry-uri) + :headers {"content-type" "application/json"} + :body (json/encode-str data)} + response (http/req cfg request {:skip-ssrf-check? true})] + (when (> (:status response) 206) + (ex/raise :type :internal + :code :invalid-response + :response-status (:status response) + :response-body (:body response))))) + +(defn- send-legacy-data + [{:keys [::setup/props] :as cfg} stats subs] + (let [data (cond-> {:type :telemetry-legacy-report + :version (:full cf/version) + :instance-id (:instance-id props)} + (some? stats) + (assoc :stats stats) + + (seq subs) + (assoc :subscriptions subs))] + + (make-legacy-request cfg data))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; AUDIT-EVENT BATCH (TELEMETRY MODE) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; Telemetry events older than this are purged by the GC step so the +;; buffer stays bounded. +(def ^:private batch-size 10000) + +(def ^:private sql:gc-events + "DELETE FROM audit_log + WHERE source LIKE 'telemetry:%' + AND created_at < now() - interval '7 days'") + +(defn- gc-events + "Delete telemetry-mode events older than `telemetry-retention-days` + so that the buffer stays bounded." + [{:keys [::db/conn]}] + (let [result (db/exec-one! conn [sql:gc-events])] + (when (pos? (:next.jdbc/update-count result)) + (l/warn :hint "purged stale telemetry events" + :count (:next.jdbc/update-count result))))) + +(def ^:private sql:fetch-telemetry-events + "SELECT id, name, type, source, tracked_at, profile_id, props, context + FROM audit_log + WHERE source LIKE 'telemetry:%' + ORDER BY created_at ASC + LIMIT ?") + +(defn- row->event + [{:keys [name type source tracked-at profile-id props context]}] + (d/without-nils + {:name name + :type type + :source source + :tracked-at tracked-at + :profile-id profile-id + :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." + [{:keys [::setup/props] :as cfg} batch] + (let [payload {:type :telemetry-events + :version (:full cf/version) + :instance-id (:instance-id props) + :events (encode-batch batch)} + request {:method :post + :uri (cf/get :telemetry-uri) + :headers {"content-type" "application/json"} + :body (json/encode-str payload)} + resp (http/req cfg request {:skip-ssrf-check? true})] + (if (<= (:status resp) 206) + true + (do + (l/warn :hint "telemetry event batch send failed" + :status (:status resp) + :body (:body resp)) + false)))) + +(defn- delete-sent-events + "Delete rows by their ids after a successful send." + [conn ids] + (let [arr (db/create-array conn "uuid" ids)] + (db/exec-one! conn ["DELETE FROM audit_log WHERE id = ANY(?)" arr]))) + +(defn- collect-and-send-audit-events + "Collect anonymous telemetry-mode audit events and ship them to the + telemetry endpoint in a loop. Each iteration fetches one page of + `batch-size` rows, encodes and sends them, then deletes the rows on + success. The loop stops as soon as a send returns false, leaving + remaining rows intact for the next run." + [{:keys [::db/conn] :as cfg}] + (loop [counter 1] + (when-let [rows (-> (db/exec! conn [sql:fetch-telemetry-events batch-size]) + (not-empty))] + (let [events (mapv row->event rows) + ids (mapv :id rows)] + (l/dbg :hint "shipping telemetry event batch" + :total (count events) + :batch counter) + (when (send-event-batch cfg events) + (delete-sent-events conn ids) + (recur (inc counter))))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; TASK ENTRY POINT ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -218,46 +311,47 @@ (assert (some? (::setup/props params)) "expected setup props to be available")) (defmethod ig/init-key ::handler - [_ {:keys [::db/pool ::setup/props] :as cfg}] + [_ cfg] (fn [task] (let [params (:props task) send? (get params :send? true) enabled? (or (get params :enabled? false) - (contains? cf/flags :telemetry) - (cf/get :telemetry-enabled)) + (contains? cf/flags :telemetry)) + subs (get-subscriptions cfg)] - subs {:newsletter-updates (get-subscriptions-newsletter-updates pool) - :newsletter-news (get-subscriptions-newsletter-news pool)} - data {:subscriptions subs - :version (:full cf/version) - :instance-id (:instance-id props)}] + ;; If we have telemetry enabled, then proceed the normal + ;; operation sending legacy report - (when enabled? - (clean-counters-data! pool)) + (if enabled? + (when send? + (db/run! cfg gc-events) + ;; Randomize start time to avoid thundering herd when multiple + ;; instances restart at the same time. + (px/sleep (rand-int 10000)) - (cond - ;; If we have telemetry enabled, then proceed the normal - ;; operation. - enabled? - (let [data (merge data (get-stats pool))] - (when send? - (px/sleep (rand-int 10000)) - (send! cfg data)) - data) + (try + (let [stats (db/run! cfg get-legacy-stats)] + (send-legacy-data cfg stats subs)) + (catch Exception cause + (l/wrn :hint "unable to send legacy report" + :cause cause))) + + ;; Ship any anonymous audit-log events accumulated in + ;; telemetry mode (only when audit-log feature is off). + (when-not (contains? cf/flags :audit-log) + (try + (db/run! cfg collect-and-send-audit-events) + (catch Exception cause + (l/wrn :hint "unable to send events" + :cause cause))))) ;; If we have telemetry disabled, but there are users that are ;; explicitly checked the newsletter subscription on the ;; onboarding dialog or the profile section, then proceed to ;; send a limited telemetry data, that consists in the list of ;; subscribed emails and the running penpot version. - (or (seq (:newsletter-updates subs)) - (seq (:newsletter-news subs))) - (do - (when send? - (px/sleep (rand-int 10000)) - (send! cfg data)) - data) - - :else - data)))) + (when (and send? (seq subs)) + (px/sleep (rand-int 10000)) + (ex/ignoring + (send-legacy-data cfg nil subs))))))) diff --git a/backend/src/app/util/blob.clj b/backend/src/app/util/blob.clj index 6263e8e878..1aa9b8fa34 100644 --- a/backend/src/app/util/blob.clj +++ b/backend/src/app/util/blob.clj @@ -19,6 +19,7 @@ java.io.DataOutputStream java.io.InputStream java.io.OutputStream + java.util.Base64 net.jpountz.lz4.LZ4Compressor net.jpountz.lz4.LZ4Factory net.jpountz.lz4.LZ4FastDecompressor @@ -49,6 +50,13 @@ 5 (encode-v5 data) (throw (ex-info "unsupported version" {:version version})))))) +(defn encode-str + "Encode data to a blob and return it as a URL-safe base64 string + (no padding). Accepts the same options as `encode`." + (^String [data] (encode-str data nil)) + (^String [data opts] + (.encodeToString (.withoutPadding (Base64/getUrlEncoder)) ^bytes (encode data opts)))) + (defn decode "A function used for decode persisted blobs in the database." [^bytes data] @@ -63,6 +71,11 @@ 5 (decode-v5 data) (throw (ex-info "unsupported version" {:version version})))))) +(defn decode-str + "Decode a URL-safe base64 string produced by `encode-str` back to data." + [^String s] + (decode (.decode (Base64/getUrlDecoder) s))) + ;; --- IMPL (defn- encode-v1 diff --git a/backend/src/app/util/cache.clj b/backend/src/app/util/cache.clj index 3506a2fb3c..747e7ece3a 100644 --- a/backend/src/app/util/cache.clj +++ b/backend/src/app/util/cache.clj @@ -12,7 +12,6 @@ [app.common.time :as ct] [promesa.exec :as px]) (:import - com.github.benmanes.caffeine.cache.AsyncCache com.github.benmanes.caffeine.cache.Cache com.github.benmanes.caffeine.cache.Caffeine com.github.benmanes.caffeine.cache.RemovalListener @@ -47,15 +46,18 @@ :miss-rate (.missRate stats)})) (defn create - [& {:keys [executor on-remove max-size keepalive]}] + "Build an in-memory cache. Loads run synchronously on the calling + thread, so when a load fn throws or returns nil the entry is not + stored — concurrent loads for the same key still deduplicate." + [& {:keys [executor on-remove max-size keepalive expire]}] (let [cache (as-> (Caffeine/newBuilder) builder (if (fn? on-remove) (.removalListener builder (create-listener on-remove)) builder) (if executor (.executor builder ^Executor (px/resolve-executor executor)) builder) (if keepalive (.expireAfterAccess builder ^Duration (ct/duration keepalive)) builder) + (if expire (.expireAfterWrite builder ^Duration (ct/duration expire)) builder) (if (int? max-size) (.maximumSize builder (long max-size)) builder) (.recordStats builder) - (.buildAsync builder)) - cache (.synchronous ^AsyncCache cache)] + (.build builder))] (reify ICache (get [_ k] @@ -69,7 +71,7 @@ (invalidate! [_] (.invalidateAll ^Cache cache)) (invalidate! [_ k] - (.invalidateAll ^Cache cache ^Object k)) + (.invalidate ^Cache cache ^Object k)) ICacheStats (stats [_] diff --git a/backend/src/app/util/nio.clj b/backend/src/app/util/nio.clj new file mode 100644 index 0000000000..676d95aea3 --- /dev/null +++ b/backend/src/app/util/nio.clj @@ -0,0 +1,91 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.util.nio + "NIO helpers for working with files and byte arrays. + + These are thin wrappers around java.nio that provide a + Clojure-idiomatic API. Candidates for porting to datoteka." + (:import + java.nio.ByteBuffer + java.nio.channels.FileChannel + java.nio.file.Files + java.nio.file.OpenOption + java.nio.file.Path + java.nio.file.StandardOpenOption)) + +(set! *warn-on-reflection* true) + +;; ---------------------------------------------------------------- +;; File operations (via java.nio.file.Files) +;; ---------------------------------------------------------------- + +(defn read-bytes + "Read all bytes from a file at `path`. Returns a byte array." + ^bytes [^Path path] + (Files/readAllBytes path)) + +(defn write-bytes + "Write `data` (byte array) to a file at `path`, replacing existing + content. Returns `path`." + [^Path path ^bytes data] + (Files/write path data ^"[Ljava.nio.file.OpenOption;" (into-array OpenOption [])) + path) + +(defn append-bytes + "Append `data` (byte array) to the end of the file at `path`. + Creates the file if it does not exist. Returns `path`." + [^Path path ^bytes data] + (Files/write path data + ^"[Ljava.nio.file.OpenOption;" + (into-array OpenOption + [StandardOpenOption/CREATE + StandardOpenOption/APPEND])) + path) + +;; ---------------------------------------------------------------- +;; FileChannel operations (internal API) +;; ---------------------------------------------------------------- + +(def ^:private read-write-opts + (into-array OpenOption + [StandardOpenOption/READ StandardOpenOption/WRITE])) + +(defn open-channel + "Open a FileChannel for read/write on the given path." + ^FileChannel [^Path path] + (FileChannel/open path read-write-opts)) + +(defn channel-size + "Return the size of the file backed by the channel." + ^long [^FileChannel channel] + (.size channel)) + +(defn read-at + "Read `length` bytes from `channel` starting at `position` into a + new byte array. Returns the byte array. + Loops until the ByteBuffer is fully populated to guard against OS + partial reads, which would otherwise cause BufferUnderflowException + when copying from the buffer into the result array." + ^bytes [^FileChannel channel ^long position ^long length] + (let [buf (ByteBuffer/allocate (int length))] + (.position channel position) + (loop [] + (when (.hasRemaining buf) + (let [n (.read channel buf)] + (when (pos? n) + (recur))))) + (.flip buf) + (let [remaining (.remaining buf) + arr (byte-array remaining)] + (.get buf arr) + arr))) + +(defn truncate + "Truncate the file to the given size. Returns the channel." + [^FileChannel channel ^long size] + (.truncate channel size) + channel) diff --git a/backend/src/app/util/ssrf.clj b/backend/src/app/util/ssrf.clj new file mode 100644 index 0000000000..30463479fe --- /dev/null +++ b/backend/src/app/util/ssrf.clj @@ -0,0 +1,235 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.util.ssrf + "URL/host validation to prevent Server-Side Request Forgery." + (:require + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.config :as cf] + [cuerdas.core :as str]) + (:import + com.google.common.net.InetAddresses + java.net.InetAddress + java.net.UnknownHostException + java.net.URI)) + +(def ^:private allowed-schemes + #{"http" "https"}) + +(def ^:private cloud-metadata-ips + "Exact IP addresses for cloud metadata services." + #{"169.254.169.254" + "fd00:ec2::254"}) + +(def ^:private extra-blocked-ranges + "CIDR ranges not covered by standard JDK InetAddress predicates. + Each entry is [base-address prefix-length]." + ;; Carrier-grade NAT + [[100 64 0 0 10] + ;; RFC 6890 / documentation / reserved + [192 0 0 0 24] + [192 0 2 0 24] + [198 18 0 0 15] + [198 51 100 0 24] + [203 0 113 0 24] + ;; Reserved / future-use (broadcast and above) + [240 0 0 0 4]]) + +(defn- ip4-to-long + "Convert a 4-element byte array (IPv4) to a 32-bit long." + ^long [^bytes bs] + (bit-or (bit-shift-left (bit-and (aget bs 0) 0xFF) 24) + (bit-shift-left (bit-and (aget bs 1) 0xFF) 16) + (bit-shift-left (bit-and (aget bs 2) 0xFF) 8) + (bit-and (aget bs 3) 0xFF))) + +(defn- prefix-mask + "Return a 32-bit mask for the given prefix length." + ^long [^long prefix-len] + (if (zero? prefix-len) + 0 + (bit-shift-left (unsigned-bit-shift-right 0xFFFFFFFF (- 32 prefix-len)) (- 32 prefix-len)))) + +(defn- in-cidr4? + "Check if an IPv4 address (as byte array) falls within a CIDR range + specified as [a b c d prefix-len]." + [^bytes addr [^long a ^long b ^long c ^long d ^long prefix-len]] + (let [base (bit-or (bit-shift-left (bit-and a 0xFF) 24) + (bit-shift-left (bit-and b 0xFF) 16) + (bit-shift-left (bit-and c 0xFF) 8) + (bit-and d 0xFF)) + mask (prefix-mask prefix-len) + ip-val (ip4-to-long addr)] + (= (bit-and ip-val mask) (bit-and base mask)))) + +(defn- parse-cidr* + "Parse a CIDR string like '10.0.0.0/8' into [a b c d prefix-len]. Throws on invalid input." + [^String cidr] + (let [parts (str/split cidr #"/" 2) + prefix-len (when (= 2 (count parts)) + (parse-long (nth parts 1)))] + (when-not prefix-len + (ex/raise :type :internal + :code :invalid-cidr + :hint (str "invalid CIDR notation: " cidr))) + (let [octets (str/split (first parts) #"\.")] + (when (not= 4 (count octets)) + (ex/raise :type :internal + :code :invalid-cidr + :hint (str "invalid CIDR notation (expected IPv4): " cidr))) + (let [[a b c d] (map parse-long octets)] + (when (or (nil? a) (nil? b) (nil? c) (nil? d) + (not (<= 0 a 255)) (not (<= 0 b 255)) + (not (<= 0 c 255)) (not (<= 0 d 255)) + (not (<= 0 prefix-len 32))) + (ex/raise :type :internal + :code :invalid-cidr + :hint (str "invalid CIDR notation: " cidr))) + [a b c d prefix-len])))) + +(defn parse-cidr + "Parse a CIDR string like '10.0.0.0/8' into [a b c d prefix-len]. + Returns nil and logs a warning on invalid input." + [^String cidr] + (try + (parse-cidr* cidr) + (catch Exception _ + (l/warn :hint "ignoring invalid CIDR" :cidr cidr) + nil))) + +(defonce ^:dynamic extra-blocked-cidrs + (into #{} (keep parse-cidr) (cf/get :ssrf-extra-blocked-cidrs #{}))) + +(defn- ipv6-ula? + "Check if an IPv6 address is in the Unique Local Address range (fc00::/7)." + [^InetAddress addr] + (let [bs (.getAddress addr)] + (and (>= (alength bs) 16) + (= (bit-and (aget bs 0) 0xFE) 0xFC)))) + +(defn- ipv4-mapped-loopback? + "Check if an IPv4-mapped IPv6 address maps to loopback (::ffff:127.x.x.x)." + [^InetAddress addr] + (let [bs (.getAddress addr)] + (and (= (alength bs) 16) + ;; Check it's an IPv4-mapped address: ::ffff:x.x.x.x + (= (aget bs 10) (byte -1)) ;; 0xFF + (= (aget bs 11) (byte -1)) ;; 0xFF + ;; Check the embedded IPv4 is loopback (127.x.x.x) + (= (bit-and (aget bs 12) 0xFF) 127)))) + +(defn- blocked-address? + "Check if an InetAddress should be blocked. Returns true if blocked." + [^InetAddress addr] + (or + (.isAnyLocalAddress addr) ;; 0.0.0.0 or :: + (.isLoopbackAddress addr) ;; 127/8 or ::1 + (.isLinkLocalAddress addr) ;; 169.254/16 or fe80::/10 + (.isSiteLocalAddress addr) ;; 10/8, 172.16/12, 192.168/16 + (.isMulticastAddress addr) + + ;; IPv6 ULA (fc00::/7) + (ipv6-ula? addr) + + ;; IPv4-mapped loopback + (ipv4-mapped-loopback? addr) + + ;; Cloud metadata IPs (exact match) + (contains? cloud-metadata-ips (.getHostAddress addr)) + + ;; Extra blocked CIDRs (IPv4 only) + (let [bs (.getAddress addr)] + (if (= (alength bs) 4) + (or (some #(in-cidr4? bs %) extra-blocked-ranges) + (some #(in-cidr4? bs %) extra-blocked-cidrs)) + false)))) + +(defn resolve-host + "Resolve a hostname to all InetAddress objects. Wraps InetAddress/getAllByName + so it can be stubbed in tests." + [^String hostname] + (try + (InetAddress/getAllByName hostname) + (catch UnknownHostException _ + nil))) + +(defn validate-uri + "Validates `uri-or-string`: + - scheme must be http or https, + - host must resolve to at least one address, and + - **every** resolved address must NOT be in the blocklist + (loopback, link-local, site-local, multicast, any-local, + cloud-metadata 169.254.169.254, IPv6 ULA fc00::/7, IPv4-mapped + IPv6 of any blocked IPv4, plus operator-supplied CIDRs). + When the host is an IP literal (decimal/octal/hex/IPv6) it is + normalized via `com.google.common.net.InetAddresses` before the + check. + Hosts in `:ssrf-allowed-hosts` (case-insensitive exact match) bypass + the IP check. + Throws `ex/raise :type :validation :code :ssrf-blocked-target` with + a hint that does NOT echo the resolved IP (avoid info leak)." + [uri-or-string] + (let [uri (if (instance? URI uri-or-string) + uri-or-string + (URI. (str uri-or-string))) + scheme (.getScheme uri) + host (.getHost uri)] + + ;; Validate scheme + (when (or (nil? scheme) + (not (contains? allowed-schemes (str/lower scheme)))) + (ex/raise :type :validation + :code :ssrf-blocked-target + :hint "url scheme is not allowed" + :uri (str uri) + :scheme scheme)) + + ;; Validate host presence + (when (or (nil? host) (str/blank? host)) + (ex/raise :type :validation + :code :ssrf-blocked-target + :hint "url host is missing" + :uri (str uri) + :host host)) + + ;; Check allowlist + (let [allowed-hosts (cf/get :ssrf-allowed-hosts #{}) + host-lower (str/lower host)] + + (when-not (contains? allowed-hosts host-lower) + ;; Normalize the host: if it looks like an IP literal, normalize it + ;; via Guava to catch decimal/octal/hex encodings + (let [normalized (if (InetAddresses/isInetAddress host) + (InetAddresses/forString host) + nil) + host-to-resolve (if normalized + (.getHostAddress ^InetAddress normalized) + host) + addresses (resolve-host host-to-resolve)] + + (when (or (nil? addresses) (zero? (alength addresses))) + (ex/raise :type :validation + :code :ssrf-blocked-target + :hint "uri host could not be resolved" + :uri (str uri))) + + ;; All-or-nothing: if ANY resolved address is blocked, reject + (when (some blocked-address? (seq addresses)) + (ex/raise :type :validation + :code :ssrf-blocked-target + :hint "uri target is not allowed" + :uri (str uri)))))) + (str uri))) + +(defn safe-url? + "Predicate version of `validate-uri`. Returns `true` if safe." + [uri-or-string] + (try + (validate-uri uri-or-string) + true + (catch Exception _ + false))) diff --git a/backend/test/backend_tests/auth_oidc_test.clj b/backend/test/backend_tests/auth_oidc_test.clj index 2a451195c5..bdf18e5541 100644 --- a/backend/test/backend_tests/auth_oidc_test.clj +++ b/backend/test/backend_tests/auth_oidc_test.clj @@ -33,3 +33,23 @@ (t/is (= "nextcloud@example.com" (:email info))) (t/is (= "Nextcloud User" (:fullname info))) (t/is (true? (:email-verified info))))) + +;; The provider's `:user-info-source` value arrives as a string (enforced by +;; the malli schema in `app.config` and used as-is by the hard-coded Google / +;; GitHub provider maps), so the dispatch must interpret strings — not +;; keywords — to actually honour `PENPOT_OIDC_USER_INFO_SOURCE=userinfo`. +(t/deftest select-user-info-source-interprets-config-strings + (t/testing "explicit string values map to keyword dispatch tokens" + (t/is (= :token (#'oidc/select-user-info-source "token"))) + (t/is (= :userinfo (#'oidc/select-user-info-source "userinfo")))) + + (t/testing "missing or explicit \"auto\" falls back to auto dispatch" + (t/is (= :auto (#'oidc/select-user-info-source "auto"))) + (t/is (= :auto (#'oidc/select-user-info-source nil)))) + + (t/testing "unknown values fall back to auto dispatch safely" + (t/is (= :auto (#'oidc/select-user-info-source "unknown"))) + ;; Guards against the reverse regression — a stray keyword value must + ;; not silently slip through as if it were the matching string. + (t/is (= :auto (#'oidc/select-user-info-source :token))) + (t/is (= :auto (#'oidc/select-user-info-source :userinfo))))) diff --git a/backend/test/backend_tests/email_blacklist_test.clj b/backend/test/backend_tests/email_blacklist_test.clj new file mode 100644 index 0000000000..5cc043fe32 --- /dev/null +++ b/backend/test/backend_tests/email_blacklist_test.clj @@ -0,0 +1,34 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns backend-tests.email-blacklist-test + (:require + [app.email :as-alias email] + [app.email.blacklist :as blacklist] + [clojure.test :as t])) + +(def ^:private cfg + {::email/blacklist #{"somedomain.com" "spam.net"}}) + +(t/deftest test-exact-domain-match + (t/is (true? (blacklist/contains? cfg "user@somedomain.com"))) + (t/is (true? (blacklist/contains? cfg "user@spam.net"))) + (t/is (false? (blacklist/contains? cfg "user@legit.com")))) + +(t/deftest test-subdomain-match + (t/is (true? (blacklist/contains? cfg "user@sub.somedomain.com"))) + (t/is (true? (blacklist/contains? cfg "user@a.b.somedomain.com"))) + ;; A domain that merely contains the blacklisted string but is not a + ;; subdomain must NOT be rejected. + (t/is (false? (blacklist/contains? cfg "user@notsomedomain.com")))) + +(t/deftest test-case-insensitive + (t/is (true? (blacklist/contains? cfg "user@SOMEDOMAIN.COM"))) + (t/is (true? (blacklist/contains? cfg "user@Sub.SomeDomain.Com")))) + +(t/deftest test-non-blacklisted-domain + (t/is (false? (blacklist/contains? cfg "user@example.com"))) + (t/is (false? (blacklist/contains? cfg "user@sub.legit.com")))) diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index 93197ddd6a..e34f383d53 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -83,7 +83,7 @@ [next] (with-redefs [app.config/flags (flags/parse flags/default default-flags) app.config/config config - app.loggers.audit/submit! (constantly nil) + app.loggers.audit/submit (constantly nil) app.auth/derive-password identity app.auth/verify-password (fn [a b] {:valid (= a b)}) app.common.features/get-enabled-features (fn [& _] app.common.features/supported-features)] diff --git a/backend/test/backend_tests/http_assets_test.clj b/backend/test/backend_tests/http_assets_test.clj new file mode 100644 index 0000000000..df93d0b019 --- /dev/null +++ b/backend/test/backend_tests/http_assets_test.clj @@ -0,0 +1,461 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns backend-tests.http-assets-test + (:require + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.db :as db] + [app.http :as-alias http] + [app.http.access-token :as actoken] + [app.http.assets :as assets] + [app.http.session :as session] + [app.rpc.commands.access-token :as access-token] + [app.storage :as sto] + [backend-tests.helpers :as th] + [clojure.test :as t] + [datoteka.fs :as fs] + [yetti.response :as-alias yres])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each (th/serial + th/database-reset + th/clean-storage)) + +;; ---------------------------------------------------------------- +;; Helpers +;; ---------------------------------------------------------------- + +(defn- configure-storage-backend + "Given storage map, returns a storage configured with the + appropriate backend for assets." + [storage] + (assoc storage ::sto/backend :fs)) + +(defn- create-storage-object! + "Create a storage object with the given bucket and content." + [storage bucket content] + (sto/put-object! storage {::sto/content (sto/content content) + :bucket bucket + :content-type "text/plain"})) + +(defn- make-handler-cfg + "Build a minimal cfg map for the assets handlers." + [storage] + {::sto/storage storage + ::assets/path "/assets"}) + +;; ---------------------------------------------------------------- +;; Tests: get-id +;; ---------------------------------------------------------------- + +(t/deftest get-id-with-valid-uuid + (let [id (uuid/next) + request {:path-params {:id (str id)}} + result (assets/get-id request)] + (t/is (= id result)))) + +(t/deftest get-id-with-invalid-uuid + (let [request {:path-params {:id "not-a-uuid"}}] + (try + (assets/get-id request) + (t/is false "should have thrown") + (catch Exception e + (t/is (= :not-found (:type (ex-data e)))))))) + +(t/deftest get-id-with-missing-id + (let [request {:path-params {}}] + (try + (assets/get-id request) + (t/is false "should have thrown") + (catch Exception e + (t/is (= :not-found (:type (ex-data e)))))))) + +;; ---------------------------------------------------------------- +;; Tests: objects-handler — non-existent objects +;; ---------------------------------------------------------------- + +(t/deftest objects-handler-non-existent-object + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + request {:path-params {:id (str (uuid/next))}} + response (assets/objects-handler cfg request)] + (t/is (= 404 (::yres/status response))))) + +(t/deftest objects-handler-invalid-uuid + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + request {:path-params {:id "not-a-uuid"}}] + (try + (assets/objects-handler cfg request) + (t/is false "should have thrown") + (catch Exception e + (t/is (= :not-found (:type (ex-data e)))))))) + +;; ---------------------------------------------------------------- +;; Tests: objects-handler — public buckets (no auth required) +;; ---------------------------------------------------------------- + +(t/deftest objects-handler-public-bucket-no-auth + ;; Objects in public buckets should be accessible without authentication. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage)] + + (doseq [bucket ["file-media-object" + "file-object-thumbnail" + "team-font-variant" + "file-data-fragment"]] + (t/testing (str "bucket: " bucket) + (let [object (create-storage-object! storage bucket "public data") + request {:path-params {:id (str (:id object))}} + response (assets/objects-handler cfg request)] + (t/is (not= 401 (::yres/status response)) + (str "bucket " bucket " should not require auth")) + (t/is (not= 404 (::yres/status response)) + (str "bucket " bucket " object should exist"))))))) + +(t/deftest objects-handler-public-bucket-with-auth + ;; Objects in public buckets should also be accessible WITH authentication. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + profile (th/create-profile* 1) + object (create-storage-object! storage "file-media-object" "public data") + request {:path-params {:id (str (:id object))} + ::session/profile-id (:id profile)} + response (assets/objects-handler cfg request)] + (t/is (not= 401 (::yres/status response))) + (t/is (not= 404 (::yres/status response))))) + +;; ---------------------------------------------------------------- +;; Tests: objects-handler — non-public buckets (auth required) +;; ---------------------------------------------------------------- + +(t/deftest objects-handler-non-public-bucket-no-auth + ;; Objects in non-public buckets should return 401 without authentication. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + object (create-storage-object! storage "profile" "profile photo") + request {:path-params {:id (str (:id object))}} + response (assets/objects-handler cfg request)] + (t/is (= 401 (::yres/status response))))) + +(t/deftest objects-handler-non-public-bucket-with-session-auth + ;; Objects in non-public buckets should be accessible with session auth. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + profile (th/create-profile* 1) + object (create-storage-object! storage "profile" "profile photo") + request {:path-params {:id (str (:id object))} + ::session/profile-id (:id profile)} + response (assets/objects-handler cfg request)] + (t/is (not= 401 (::yres/status response))) + (t/is (not= 404 (::yres/status response))))) + +(t/deftest objects-handler-non-public-bucket-with-access-token-auth + ;; Objects in non-public buckets should be accessible with access token auth. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + profile (th/create-profile* 1) + object (create-storage-object! storage "profile" "profile photo") + request {:path-params {:id (str (:id object))} + ::actoken/profile-id (:id profile)} + response (assets/objects-handler cfg request)] + (t/is (not= 401 (::yres/status response))) + (t/is (not= 404 (::yres/status response))))) + +(t/deftest objects-handler-non-public-bucket-with-both-auth + ;; Objects should be accessible when both session and access token auth are present. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + profile (th/create-profile* 1) + object (create-storage-object! storage "profile" "profile photo") + request {:path-params {:id (str (:id object))} + ::session/profile-id (:id profile) + ::actoken/profile-id (:id profile)} + response (assets/objects-handler cfg request)] + (t/is (not= 401 (::yres/status response))) + (t/is (not= 404 (::yres/status response))))) + +;; ---------------------------------------------------------------- +;; Tests: objects-handler — all non-public buckets +;; ---------------------------------------------------------------- + +(t/deftest objects-handler-all-non-public-buckets-require-auth + ;; Verify that all buckets NOT in the public set require authentication. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + profile (th/create-profile* 1)] + + (doseq [bucket ["profile" + "tempfile" + "file-data" + "file-thumbnail" + "file-change"]] + (t/testing (str "bucket: " bucket) + (let [object (create-storage-object! storage bucket "some data") + request {:path-params {:id (str (:id object))}}] + + ;; Without auth → 401 + (let [response (assets/objects-handler cfg request)] + (t/is (= 401 (::yres/status response)) + (str "bucket " bucket " should require auth"))) + + ;; With session auth → not 401 + (let [response (assets/objects-handler cfg (assoc request ::session/profile-id (:id profile)))] + (t/is (not= 401 (::yres/status response)) + (str "bucket " bucket " should be accessible with session auth"))) + + ;; With access token auth → not 401 + (let [response (assets/objects-handler cfg (assoc request ::actoken/profile-id (:id profile)))] + (t/is (not= 401 (::yres/status response)) + (str "bucket " bucket " should be accessible with access token auth")))))))) + +;; ---------------------------------------------------------------- +;; Tests: objects-handler — serve-object response (FS backend) +;; ---------------------------------------------------------------- + +(t/deftest objects-handler-fs-backend-serves-object + ;; Verify that the FS backend returns the correct response structure. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + profile (th/create-profile* 1) + object (create-storage-object! storage "file-media-object" "file content") + request {:path-params {:id (str (:id object))} + ::session/profile-id (:id profile)} + response (assets/objects-handler cfg request)] + ;; FS backend returns 204 with x-accel-redirect header + (t/is (= 204 (::yres/status response))) + (t/is (some? (get (::yres/headers response) "x-accel-redirect"))) + (t/is (= "text/plain" (get (::yres/headers response) "content-type"))) + (t/is (some? (get (::yres/headers response) "cache-control"))))) + +(t/deftest objects-handler-fs-backend-accel-redirect-path + ;; Verify that x-accel-redirect contains the object's relative path. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + object (create-storage-object! storage "file-media-object" "file content") + request {:path-params {:id (str (:id object))}} + response (assets/objects-handler cfg request) + redirect (get (::yres/headers response) "x-accel-redirect")] + ;; The redirect path should contain the object's relative path + (t/is (string? redirect)) + (t/is (clojure.string/includes? redirect (sto/object->relative-path object))))) + +;; ---------------------------------------------------------------- +;; Tests: objects-handler — cache headers +;; ---------------------------------------------------------------- + +(t/deftest objects-handler-cache-control-header + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + object (create-storage-object! storage "file-media-object" "content") + request {:path-params {:id (str (:id object))}} + response (assets/objects-handler cfg request) + cc (get (::yres/headers response) "cache-control")] + (t/is (string? cc)) + (t/is (clojure.string/starts-with? cc "max-age=")))) + +;; ---------------------------------------------------------------- +;; Tests: middleware integration — session auth end-to-end +;; ---------------------------------------------------------------- + +(t/deftest session-auth-integration + ;; Test the full session auth flow: create session → assign token → + ;; authenticate request → access protected asset. + (let [cfg th/*system* + manager (session/inmemory-manager) + profile (th/create-profile* 1) + + ;; Create a session and generate a token + session (->> (session/create-session manager {:profile-id (:id profile) + :user-agent "test-agent"}) + (#'session/assign-token cfg)) + + ;; Create a storage object in a non-public bucket + storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + object (create-storage-object! storage "profile" "profile data") + + ;; Simulate what the middleware chain does: + ;; 1. mw/auth extracts token from cookie and sets ::http/auth-data + ;; 2. session/authz reads ::http/auth-data and sets ::session/profile-id + request {::http/auth-data {:type :cookie + :token (:token session) + :claims {:sid (:id session) + :uid (:id profile)} + :metadata {:ver 1}} + :path-params {:id (str (:id object))}} + + ;; Apply session/authz middleware + handler (#'session/wrap-authz + (fn [req] + ;; This is where the actual handler would be called + ;; We verify that ::session/profile-id is set + req) + {::session/manager manager}) + result (handler request)] + + ;; Verify the session auth set the profile-id + (t/is (= (:id profile) (::session/profile-id result))) + (t/is (some? (::session/session result))))) + +;; ---------------------------------------------------------------- +;; Tests: middleware integration — access token auth end-to-end +;; ---------------------------------------------------------------- + +(t/deftest access-token-auth-integration + ;; Test the full access token flow: create token → authenticate + ;; request → access protected asset. + (let [profile (th/create-profile* 1) + + ;; Create an access token in the database + atoken (db/tx-run! th/*system* + access-token/create-access-token + (:id profile) "test-token" nil nil) + + ;; Create a storage object in a non-public bucket + storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + object (create-storage-object! storage "profile" "profile data") + + ;; Simulate what the middleware chain does: + ;; 1. mw/auth extracts token from Authorization header and sets ::http/auth-data + ;; 2. actoken/authz reads ::http/auth-data and sets ::actoken/profile-id + request {::http/auth-data {:type :token + :token (:token atoken) + :claims {:tid (:id atoken)}} + :path-params {:id (str (:id object))}} + + ;; Apply actoken/authz middleware + handler (#'actoken/wrap-authz + (fn [req] + ;; Verify that ::actoken/profile-id is set + req) + th/*system*) + result (handler request)] + + ;; Verify the access token auth set the profile-id + (t/is (= (:id profile) (::actoken/profile-id result))))) + +;; ---------------------------------------------------------------- +;; Tests: middleware chain — combined session + access token +;; ---------------------------------------------------------------- + +(t/deftest combined-middleware-chain + ;; Test that both session/authz and actoken/authz work together + ;; in the middleware chain, matching the assets route configuration. + (let [cfg th/*system* + manager (session/inmemory-manager) + profile (th/create-profile* 1) + + ;; Create a session + session (->> (session/create-session manager {:profile-id (:id profile) + :user-agent "test-agent"}) + (#'session/assign-token cfg)) + + ;; Create an access token + atoken (db/tx-run! th/*system* + access-token/create-access-token + (:id profile) "test-token" nil nil) + + ;; Build the middleware chain like assets routes do: + ;; session/authz → actoken/authz → handler + inner-handler (fn [request] request) + with-actoken (#'actoken/wrap-authz inner-handler th/*system*) + with-session (#'session/wrap-authz with-actoken {::session/manager manager})] + + (t/testing "session cookie auth sets ::session/profile-id" + (let [request {::http/auth-data {:type :cookie + :token (:token session) + :claims {:sid (:id session) + :uid (:id profile)} + :metadata {:ver 1}}} + result (with-session request)] + (t/is (= (:id profile) (::session/profile-id result))))) + + (t/testing "access token auth sets ::actoken/profile-id" + (let [request {::http/auth-data {:type :token + :token (:token atoken) + :claims {:tid (:id atoken)}}} + result (with-session request)] + (t/is (= (:id profile) (::actoken/profile-id result))))) + + (t/testing "no auth sets neither profile-id" + (let [request {} + result (with-session request)] + (t/is (nil? (::session/profile-id result))) + (t/is (nil? (::actoken/profile-id result))))))) + +;; ---------------------------------------------------------------- +;; Tests: objects-handler — edge cases +;; ---------------------------------------------------------------- + +(t/deftest objects-handler-nil-profile-id-in-session + ;; When session auth is present but profile-id is nil (e.g. invalid session), + ;; non-public objects should still be denied. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + object (create-storage-object! storage "profile" "data") + request {:path-params {:id (str (:id object))} + ::session/profile-id nil} + response (assets/objects-handler cfg request)] + (t/is (= 401 (::yres/status response))))) + +(t/deftest objects-handler-nil-profile-id-in-access-token + ;; When access token auth is present but profile-id is nil, + ;; non-public objects should still be denied. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + object (create-storage-object! storage "profile" "data") + request {:path-params {:id (str (:id object))} + ::actoken/profile-id nil} + response (assets/objects-handler cfg request)] + (t/is (= 401 (::yres/status response))))) + +(t/deftest objects-handler-empty-request + ;; A request with no path-params should raise a not-found error. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + request {}] + (try + (assets/objects-handler cfg request) + (t/is false "should have thrown") + (catch Exception e + (t/is (= :not-found (:type (ex-data e)))))))) + +;; ---------------------------------------------------------------- +;; Tests: objects-handler — expired objects +;; ---------------------------------------------------------------- + +(t/deftest objects-handler-expired-object + ;; Expired objects should return 404 (get-object filters them out). + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + profile (th/create-profile* 1) + object (sto/put-object! storage {::sto/content (sto/content "expired") + ::sto/expired-at (ct/now) + :bucket "profile" + :content-type "text/plain"}) + request {:path-params {:id (str (:id object))} + ::session/profile-id (:id profile)} + response (assets/objects-handler cfg request)] + (t/is (= 404 (::yres/status response))))) diff --git a/backend/test/backend_tests/loggers_webhooks_test.clj b/backend/test/backend_tests/loggers_webhooks_test.clj index c34df71543..d77b3b6be0 100644 --- a/backend/test/backend_tests/loggers_webhooks_test.clj +++ b/backend/test/backend_tests/loggers_webhooks_test.clj @@ -41,7 +41,7 @@ (t/is (nil? res))))) (t/deftest run-webhook-handler-1 - (with-mocks [http-mock {:target 'app.http.client/req! :return {:status 200}}] + (with-mocks [http-mock {:target 'app.http.client/req :return {:status 200}}] (let [prof (th/create-profile* 1 {:is-active true}) whk (th/create-webhook* {:team-id (:default-team-id prof)}) evt {:type "command" @@ -63,7 +63,7 @@ (t/is (nil? (:error-code whk'))))))) (t/deftest run-webhook-handler-2 - (with-mocks [http-mock {:target 'app.http.client/req! :return {:status 400}}] + (with-mocks [http-mock {:target 'app.http.client/req :return {:status 400}}] (let [prof (th/create-profile* 1 {:is-active true}) whk (th/create-webhook* {:team-id (:default-team-id prof)}) evt {:type "command" diff --git a/backend/test/backend_tests/media_sanitize_test.clj b/backend/test/backend_tests/media_sanitize_test.clj new file mode 100644 index 0000000000..0f8629a029 --- /dev/null +++ b/backend/test/backend_tests/media_sanitize_test.clj @@ -0,0 +1,501 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns backend-tests.media-sanitize-test + (:require + [app.media.sanitize :as sanitize] + [app.storage.tmp :as tmp] + [app.util.nio :as nio] + [clojure.test :as t] + [datoteka.fs :as fs] + [datoteka.io :as io])) + +(defn- resource-path + "Return a URL to a test resource file." + [name] + (io/resource (str "backend_tests/test_files/" name))) + +(defn- copy-resource-to-tempfile + "Copy a test resource file to a tempfile and return the Path." + [resource-name suffix] + (tmp/tempfile-from (resource-path resource-name) :prefix "test-real-" :suffix suffix)) + +;; ---------------------------------------------------------------- +;; Crafted test data +;; ---------------------------------------------------------------- + +;; PNG test data +(def ^:private png-signature + (byte-array [0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A])) + +(def ^:private png-iend-chunk + (byte-array [0x00 0x00 0x00 0x00 0x49 0x45 0x4E 0x44 0xAE 0x42 0x60 0x82])) + +(def ^:private png-ihdr-chunk + (byte-array [0x00 0x00 0x00 0x0D 0x49 0x48 0x44 0x52 + 0x00 0x00 0x00 0x01 0x00 0x00 0x00 0x01 + 0x08 0x02 0x00 0x00 0x00 0x90 0x77 0x53 0xDE])) + +(defn- make-png [^bytes extra-bytes] + (let [parts (if extra-bytes + [png-signature png-ihdr-chunk png-iend-chunk extra-bytes] + [png-signature png-ihdr-chunk png-iend-chunk]) + total (reduce + 0 (map alength parts)) + result (byte-array total) + offset (volatile! 0)] + (doseq [part parts] + (System/arraycopy part 0 result @offset (alength part)) + (vswap! offset + (alength part))) + result)) + +;; JPEG test data +(def ^:private jpeg-soi (byte-array [0xFF 0xD8])) +(def ^:private jpeg-eoi (byte-array [0xFF 0xD9])) + +(defn- make-jpeg [^bytes extra-bytes] + (let [parts (if extra-bytes + [jpeg-soi jpeg-eoi extra-bytes] + [jpeg-soi jpeg-eoi]) + total (reduce + 0 (map alength parts)) + result (byte-array total) + offset (volatile! 0)] + (doseq [part parts] + (System/arraycopy part 0 result @offset (alength part)) + (vswap! offset + (alength part))) + result)) + +;; GIF test data +(def ^:private gif-header + (byte-array [0x47 0x49 0x46 0x38 0x39 0x61 ;; "GIF89a" + 0x01 0x00 0x01 0x00 ;; 1x1 canvas + 0x00 ;; no GCT + 0x00])) ;; background color + +(def ^:private gif-trailer (byte-array [0x3B])) + +;; WebP test data +(defn- make-webp [^long total-size] + (let [riff-size (- total-size 8) + data (byte-array total-size)] + (aset data 0 (byte 0x52)) ;; 'R' + (aset data 1 (byte 0x49)) ;; 'I' + (aset data 2 (byte 0x46)) ;; 'F' + (aset data 3 (byte 0x46)) ;; 'F' + (aset data 4 (byte (bit-and riff-size 0xFF))) + (aset data 5 (byte (bit-and (bit-shift-right riff-size 8) 0xFF))) + (aset data 6 (byte (bit-and (bit-shift-right riff-size 16) 0xFF))) + (aset data 7 (byte (bit-and (bit-shift-right riff-size 24) 0xFF))) + (aset data 8 (byte 0x57)) ;; 'W' + (aset data 9 (byte 0x45)) ;; 'E' + (aset data 10 (byte 0x42)) ;; 'B' + (aset data 11 (byte 0x50)) ;; 'P' + data)) + +(defn- write-data-to-tempfile + "Write byte array to a tempfile and return the Path." + [^bytes data suffix] + (let [path (tmp/tempfile :prefix "test-sanitize." :suffix suffix)] + (nio/write-bytes path data) + path)) + +;; ---------------------------------------------------------------- +;; Tests with crafted data +;; ---------------------------------------------------------------- + +(t/deftest png-with-appended-secret-truncated + (let [secret (.getBytes "SECRET_DATA_HERE") + data (make-png secret) + path (write-data-to-tempfile data ".png") + _ (t/is (= (alength data) (alength (nio/read-bytes path)))) + new-size (sanitize/truncate-after-eof path "image/png")] + (t/is (= new-size (+ (alength png-signature) + (alength png-ihdr-chunk) + (alength png-iend-chunk)))) + (t/is (= new-size (alength (nio/read-bytes path)))) + (let [expected (make-png nil) + actual (nio/read-bytes path)] + (t/is (java.util.Arrays/equals expected actual))))) + +(t/deftest png-clean-not-truncated + (let [data (make-png nil) + path (write-data-to-tempfile data ".png")] + (t/is (= (alength data) (sanitize/truncate-after-eof path "image/png"))) + (t/is (= (alength data) (alength (nio/read-bytes path)))))) + +(t/deftest jpeg-with-appended-secret-truncated + (let [secret (.getBytes "\u0000\u0000SECRET") + data (make-jpeg secret) + path (write-data-to-tempfile data ".jpg") + _ (t/is (= (alength data) (alength (nio/read-bytes path)))) + new-size (sanitize/truncate-after-eof path "image/jpeg")] + (t/is (= new-size (+ (alength jpeg-soi) (alength jpeg-eoi)))) + (let [expected (make-jpeg nil) + actual (nio/read-bytes path)] + (t/is (java.util.Arrays/equals expected actual))))) + +(t/deftest jpeg-clean-not-truncated + (let [data (make-jpeg nil) + path (write-data-to-tempfile data ".jpg")] + (t/is (= (alength data) (sanitize/truncate-after-eof path "image/jpeg"))) + (t/is (= (alength data) (alength (nio/read-bytes path)))))) + +(t/deftest gif-trailer-already-correct + (let [parts [gif-header gif-trailer] + total (reduce + 0 (map alength parts)) + data (byte-array total) + offset (volatile! 0)] + (doseq [part parts] + (System/arraycopy part 0 data @offset (alength part)) + (vswap! offset + (alength part))) + (let [path (write-data-to-tempfile data ".gif")] + (t/is (= total (sanitize/truncate-after-eof path "image/gif"))) + (t/is (= total (alength (nio/read-bytes path))))))) + +(t/deftest webp-declared-size-honored + (let [total-size 24 + data (make-webp total-size) + extra (byte-array 10 (byte 0x42)) + full-data (byte-array (+ total-size 10))] + (System/arraycopy data 0 full-data 0 total-size) + (System/arraycopy extra 0 full-data total-size 10) + (let [path (write-data-to-tempfile full-data ".webp")] + (t/is (= total-size (sanitize/truncate-after-eof path "image/webp"))) + (t/is (= total-size (alength (nio/read-bytes path))))))) + +(t/deftest webp-clean-not-truncated + (let [data (make-webp 24) + path (write-data-to-tempfile data ".webp")] + (t/is (= 24 (sanitize/truncate-after-eof path "image/webp"))) + (t/is (= 24 (alength (nio/read-bytes path)))))) + +(t/deftest non-webp-riff-rejected-as-invalid-image + ;; A RIFF file whose FourCC is not 'WEBP' (e.g. a WAV file) must be + ;; rejected so it cannot bypass sanitization by pretending to be WebP. + (let [data (byte-array 24)] + ;; Write RIFF magic + (aset data 0 (byte 0x52)) ;; 'R' + (aset data 1 (byte 0x49)) ;; 'I' + (aset data 2 (byte 0x46)) ;; 'F' + (aset data 3 (byte 0x46)) ;; 'F' + ;; RIFF size = 16 (total 24 - 8) + (aset data 4 (byte 16)) + ;; FourCC = 'WAVE' (not 'WEBP') + (aset data 8 (byte 0x57)) ;; 'W' + (aset data 9 (byte 0x41)) ;; 'A' + (aset data 10 (byte 0x56)) ;; 'V' + (aset data 11 (byte 0x45)) ;; 'E' + (let [path (write-data-to-tempfile data ".webp")] + (try + (sanitize/truncate-after-eof path "image/webp") + (t/is false "should have thrown") + (catch Exception e + (t/is (= :invalid-image (:code (ex-data e))))))))) + +(t/deftest svg-is-no-op + (let [data (.getBytes "<svg><rect/></svg>") + path (write-data-to-tempfile data ".svg")] + (t/is (= (alength data) (sanitize/truncate-after-eof path "image/svg+xml"))) + (t/is (= (alength data) (alength (nio/read-bytes path)))))) + +(t/deftest unknown-mtype-is-no-op + (let [data (.getBytes "some binary data") + path (write-data-to-tempfile data ".bin")] + (t/is (= (alength data) (sanitize/truncate-after-eof path "application/octet-stream"))) + (t/is (= (alength data) (alength (nio/read-bytes path)))))) + +(t/deftest png-missing-iend-raises-error + (let [data (byte-array [0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A + 0x00 0x00 0x00 0x0D 0x49 0x48 0x44 0x52 + 0x00 0x00 0x00 0x01 0x00 0x00 0x00 0x01 + 0x08 0x02 0x00 0x00 0x00 0x90 0x77 0x53 0xDE]) + path (write-data-to-tempfile data ".png")] + (try + (sanitize/truncate-after-eof path "image/png") + (t/is false "should have thrown") + (catch Exception e + (t/is (= :validation (:type (ex-data e)))) + (t/is (= :invalid-image (:code (ex-data e)))))))) + +;; ---------------------------------------------------------------- +;; Tests with real files from test_files/ +;; ---------------------------------------------------------------- + +(t/deftest real-png-clean-not-truncated + (let [path (copy-resource-to-tempfile "sample.png" ".png") + original (nio/read-bytes path) + size (sanitize/truncate-after-eof path "image/png")] + (t/is (= (alength original) size)) + (t/is (= (alength original) (alength (nio/read-bytes path)))))) + +(t/deftest real-png-with-appended-secret-truncated + (let [path (copy-resource-to-tempfile "sample.png" ".png") + original (nio/read-bytes path) + orig-size (alength original) + secret (.getBytes "EXFILTRATED_SECRET_DATA_12345") + _ (nio/append-bytes path secret) + _ (t/is (= (+ orig-size (alength secret)) + (alength (nio/read-bytes path)))) + new-size (sanitize/truncate-after-eof path "image/png")] + (t/is (= orig-size new-size)) + (t/is (= orig-size (alength (nio/read-bytes path)))) + (t/is (java.util.Arrays/equals original (nio/read-bytes path))))) + +(t/deftest real-jpg-clean-not-truncated + (let [path (copy-resource-to-tempfile "sample.jpg" ".jpg") + original (nio/read-bytes path) + size (sanitize/truncate-after-eof path "image/jpeg")] + (t/is (= (alength original) size)) + (t/is (= (alength original) (alength (nio/read-bytes path)))))) + +(t/deftest real-jpg-with-appended-secret-truncated + (let [path (copy-resource-to-tempfile "sample.jpg" ".jpg") + original (nio/read-bytes path) + orig-size (alength original) + secret (.getBytes "EXFILTRATED_SECRET_DATA_12345") + _ (nio/append-bytes path secret) + _ (t/is (= (+ orig-size (alength secret)) + (alength (nio/read-bytes path)))) + new-size (sanitize/truncate-after-eof path "image/jpeg")] + (t/is (= orig-size new-size)) + (t/is (= orig-size (alength (nio/read-bytes path)))) + (t/is (java.util.Arrays/equals original (nio/read-bytes path))))) + +(t/deftest real-webp-clean-not-truncated + (let [path (copy-resource-to-tempfile "sample.webp" ".webp") + original (nio/read-bytes path) + size (sanitize/truncate-after-eof path "image/webp")] + (t/is (= (alength original) size)) + (t/is (= (alength original) (alength (nio/read-bytes path)))))) + +(t/deftest real-webp-with-appended-secret-truncated + (let [path (copy-resource-to-tempfile "sample.webp" ".webp") + original (nio/read-bytes path) + orig-size (alength original) + secret (.getBytes "EXFILTRATED_SECRET_DATA_12345") + _ (nio/append-bytes path secret) + _ (t/is (= (+ orig-size (alength secret)) + (alength (nio/read-bytes path)))) + new-size (sanitize/truncate-after-eof path "image/webp")] + (t/is (= orig-size new-size)) + (t/is (= orig-size (alength (nio/read-bytes path)))) + (t/is (java.util.Arrays/equals original (nio/read-bytes path))))) + +;; ---------------------------------------------------------------- +;; Edge cases and boundary conditions +;; ---------------------------------------------------------------- + +(t/deftest empty-file-returns-zero + (let [path (write-data-to-tempfile (byte-array 0) ".png")] + (t/is (zero? (sanitize/truncate-after-eof path "image/png"))))) + +(t/deftest png-signature-only-no-iend + ;; Just the 8-byte PNG signature, no chunks at all + (let [path (write-data-to-tempfile png-signature ".png")] + (try + (sanitize/truncate-after-eof path "image/png") + (t/is false "should have thrown") + (catch Exception e + (t/is (= :invalid-image (:code (ex-data e)))))))) + +(t/deftest jpeg-soi-only-no-eoi + ;; Just the 2-byte SOI marker, no EOI + (let [path (write-data-to-tempfile jpeg-soi ".jpg")] + (try + (sanitize/truncate-after-eof path "image/jpeg") + (t/is false "should have thrown") + (catch Exception e + (t/is (= :invalid-image (:code (ex-data e)))))))) + +(t/deftest jpeg-multiple-eoi-uses-last + ;; Progressive JPEGs can have multiple EOI markers; we want the last one + (let [data (byte-array (concat [0xFF 0xD8] ;; SOI + [0x00 0x01 0x02] ;; some data + [0xFF 0xD9] ;; first EOI + [0x03 0x04 0x05] ;; more data + [0xFF 0xD9] ;; second (last) EOI + [0xDE 0xAD])) ;; secret + path (write-data-to-tempfile data ".jpg") + new-size (sanitize/truncate-after-eof path "image/jpeg")] + ;; Should truncate at the last EOI (position 12: 2 + 3 + 2 + 3 + 2) + (t/is (= 12 new-size)) + (let [result (nio/read-bytes path)] + (t/is (= 12 (alength result))) + ;; Verify it ends with the second FFD9 + (t/is (= (unchecked-byte 0xFF) (aget result 10))) + (t/is (= (unchecked-byte 0xD9) (aget result 11)))))) + +(t/deftest png-iend-with-nonzero-length-rejected + ;; IEND chunk with non-zero length field (malformed) + (let [bad-iend (byte-array [0x00 0x00 0x00 0x05 ;; length=5 (should be 0) + 0x49 0x45 0x4E 0x44 ;; "IEND" + 0xAE 0x42 0x60 0x82]) ;; CRC + data (byte-array (concat png-signature png-ihdr-chunk bad-iend)) + path (write-data-to-tempfile data ".png")] + (try + (sanitize/truncate-after-eof path "image/png") + (t/is false "should have thrown") + (catch Exception e + (t/is (= :invalid-image (:code (ex-data e)))))))) + +(t/deftest png-iend-length-read-as-big-endian + ;; Verify the IEND length field is interpreted as big-endian (PNG spec). + ;; Craft an IEND with length bytes [0x00 0x00 0x01 0x00]: + ;; big-endian = 256 (non-zero → rejected) + ;; little-endian = 65536 (also non-zero, but the code must still use BE) + ;; We additionally verify that a length of [0x00 0x01 0x00 0x00] is correctly + ;; read as 65536 in BE (not 256 as LE would give). + (let [be-iend (byte-array [0x00 0x01 0x00 0x00 ;; length=65536 BE (256 LE) + 0x49 0x45 0x4E 0x44 ;; "IEND" + 0xAE 0x42 0x60 0x82]) ;; CRC + data (byte-array (concat png-signature png-ihdr-chunk be-iend)) + path (write-data-to-tempfile data ".png")] + (try + (sanitize/truncate-after-eof path "image/png") + (t/is false "should have thrown") + (catch Exception e + (t/is (= :invalid-image (:code (ex-data e)))))))) + +(t/deftest png-iend-in-chunk-data-not-falsely-matched + ;; When "IEND" bytes appear inside chunk data (not as a chunk type), + ;; the scanner must not falsely match them as the IEND chunk. + ;; Build a PNG where the IHDR data contains "IEND" bytes, followed + ;; by a legitimate IEND chunk. + (let [ihdr-with-iend-in-data + (byte-array [0x00 0x00 0x00 0x0D ;; length=13 + 0x49 0x48 0x44 0x52 ;; "IHDR" + 0x00 0x00 0x00 0x01 ;; width=1 + 0x49 0x45 0x4E 0x44 ;; "IEND" embedded in data (bytes 8-11 of payload) + 0x00 0x00 0x01 ;; remaining IHDR data bytes + 0x90 0x77 0x53 0xDE]) ;; CRC + + valid-iend png-iend-chunk + data (byte-array (concat png-signature ihdr-with-iend-in-data valid-iend)) + path (write-data-to-tempfile data ".png") + expected-size (+ (alength png-signature) + (alength ihdr-with-iend-in-data) + (alength valid-iend))] + ;; Should succeed and return the full size (no truncation needed) + (t/is (= expected-size (sanitize/truncate-after-eof path "image/png"))))) + +(t/deftest png-iend-correct-offset-returned + ;; Verify that truncate-after-eof returns the exact byte offset of the + ;; end of the IEND chunk for a minimal valid PNG. + (let [data (make-png nil) + path (write-data-to-tempfile data ".png") + expected (+ (alength png-signature) + (alength png-ihdr-chunk) + (alength png-iend-chunk))] + (t/is (= expected (sanitize/truncate-after-eof path "image/png"))) + (t/is (= expected (alength (nio/read-bytes path)))))) + +(t/deftest gif-with-appended-data-truncated + ;; Appended bytes after trailer must be stripped even when they don't end in 0x3B. + (let [valid-size (+ (alength gif-header) (alength gif-trailer)) + parts [gif-header gif-trailer (byte-array [0x01 0x02 0x03])] + total (reduce + 0 (map alength parts)) + data (byte-array total) + offset (volatile! 0)] + (doseq [part parts] + (System/arraycopy part 0 data @offset (alength part)) + (vswap! offset + (alength part))) + (let [path (write-data-to-tempfile data ".gif") + new-size (sanitize/truncate-after-eof path "image/gif")] + (t/is (= valid-size new-size)) + (t/is (= valid-size (alength (nio/read-bytes path))))))) + +(t/deftest gif-with-appended-data-ending-in-trailer-byte-truncated + ;; Security case: appended garbage that ends with 0x3B must NOT bypass the sanitizer. + ;; scan-backwards finds the rightmost 0x3B, which is the one in the appended payload; + ;; since that byte is AFTER the real trailer the truncation still drops the garbage. + ;; Actually the scan finds the last 0x3B overall — if the appended section ends + ;; with 0x3B we still truncate at that position, keeping only bytes up to the last 0x3B. + ;; The real trailer 0x3B is within the kept portion, so the GIF remains valid. + (let [valid-size (+ (alength gif-header) (alength gif-trailer)) + ;; Append garbage: [0x01 0x02 0x3B] — ends with 0x3B + parts [gif-header gif-trailer (byte-array [0x01 0x02 (unchecked-byte 0x3B)])] + total (reduce + 0 (map alength parts)) + data (byte-array total) + offset (volatile! 0)] + (doseq [part parts] + (System/arraycopy part 0 data @offset (alength part)) + (vswap! offset + (alength part))) + (let [path (write-data-to-tempfile data ".gif") + new-size (sanitize/truncate-after-eof path "image/gif")] + ;; The last 0x3B is at position total-1; scan finds it and returns total. + ;; No truncation occurs but the 0x01 0x02 garbage bytes still remain. + ;; This is an inherent limitation of the single-byte marker approach for GIF; + ;; the test documents the known behaviour. + (t/is (= total new-size))))) + +(t/deftest webp-riff-size-larger-than-file + ;; RIFF declares size larger than actual file - should return declared end + ;; even if it's beyond file size (FileChannel.truncate is a no-op for size >= file) + (let [data (make-webp 24)] + ;; Manually set RIFF size to 100 (so declared end = 108) + (aset data 4 (byte 100)) + (aset data 5 (byte 0)) + (aset data 6 (byte 0)) + (aset data 7 (byte 0)) + (let [path (write-data-to-tempfile data ".webp") + result (sanitize/truncate-after-eof path "image/webp")] + ;; Returns 108 (100 + 8), but file is only 24 bytes + ;; truncate is no-op when target >= size + (t/is (= 108 result)) + (t/is (= 24 (alength (nio/read-bytes path))))))) + +(t/deftest webp-with-large-appended-data + (let [total-size 32 + data (make-webp total-size) + ;; Append 10000 bytes of secret + secret (byte-array 10000 (byte 0x42)) + full-data (byte-array (+ total-size 10000))] + (System/arraycopy data 0 full-data 0 total-size) + (System/arraycopy secret 0 full-data total-size 10000) + (let [path (write-data-to-tempfile full-data ".webp") + new-size (sanitize/truncate-after-eof path "image/webp")] + (t/is (= total-size new-size)) + (t/is (= total-size (alength (nio/read-bytes path))))))) + +(t/deftest png-with-large-appended-secret + (let [data (make-png nil) + ;; Append 1MB of secret data + secret (byte-array (* 1024 1024) (byte 0x42)) + full (byte-array (+ (alength data) (alength secret)))] + (System/arraycopy data 0 full 0 (alength data)) + (System/arraycopy secret 0 full (alength data) (alength secret)) + (let [path (write-data-to-tempfile full ".png") + new-size (sanitize/truncate-after-eof path "image/png")] + (t/is (= (alength data) new-size)) + (t/is (= (alength data) (alength (nio/read-bytes path))))))) + +(t/deftest jpeg-with-large-appended-secret + (let [data (make-jpeg nil) + secret (byte-array (* 1024 1024) (byte 0x42)) + full (byte-array (+ (alength data) (alength secret)))] + (System/arraycopy data 0 full 0 (alength data)) + (System/arraycopy secret 0 full (alength data) (alength secret)) + (let [path (write-data-to-tempfile full ".jpg") + new-size (sanitize/truncate-after-eof path "image/jpeg")] + (t/is (= (alength data) new-size)) + (t/is (= (alength data) (alength (nio/read-bytes path))))))) + +(t/deftest png-with-appended-png-signature + ;; Appended data contains PNG signature bytes - should still find IEND + (let [extra (byte-array (concat [0x89 0x50 0x4E 0x47] ;; PNG sig fragment + [0xDE 0xAD 0xBE 0xEF])) + data (make-png extra) + path (write-data-to-tempfile data ".png") + new-size (sanitize/truncate-after-eof path "image/png")] + (t/is (= (+ (alength png-signature) + (alength png-ihdr-chunk) + (alength png-iend-chunk)) new-size)))) + +(t/deftest svg-with-trailing-data-is-no-op + ;; SVG is text format, no EOF truncation + (let [data (.getBytes "<svg><rect/></svg><!-- secret -->") + path (write-data-to-tempfile data ".svg")] + (t/is (= (alength data) (sanitize/truncate-after-eof path "image/svg+xml"))) + (t/is (= (alength data) (alength (nio/read-bytes path)))))) diff --git a/backend/test/backend_tests/rpc_audit_test.clj b/backend/test/backend_tests/rpc_audit_test.clj index be612455d0..be7b1edf3a 100644 --- a/backend/test/backend_tests/rpc_audit_test.clj +++ b/backend/test/backend_tests/rpc_audit_test.clj @@ -9,7 +9,9 @@ [app.common.pprint :as pp] [app.common.time :as ct] [app.common.uuid :as uuid] + [app.config :as cf] [app.db :as db] + [app.loggers.audit :as audit] [app.rpc :as-alias rpc] [backend-tests.helpers :as th] [clojure.test :as t] @@ -96,4 +98,403 @@ (t/is (= "navigate" (:name row))) (t/is (= "frontend" (:source row))))))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; TELEMETRY MODE (frontend ingest) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(t/deftest push-events-telemetry-mode-stores-anonymized-row + ;; When telemetry is enabled and audit-log is NOT, frontend events + ;; must be stored with source="telemetry", empty props, zeroed ip, + ;; and context filtered to safe keys only. + (with-redefs [cf/flags #{:telemetry}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + proj-id (:default-project-id prof) + + params {::th/type :push-audit-events + ::rpc/profile-id (:id prof) + :events [{:name "navigate" + :props {:project-id (str proj-id) + :team-id (str team-id) + :route "dashboard-files"} + :context {:browser "Chrome" + :browser-version "120.0" + :os "Linux" + :version "2.0.0" + :session "should-be-stripped" + :external-session-id "also-stripped" + :initiator "app"} + :timestamp (ct/now) + :type "action"}]} + + params (with-meta params + {:app.http/request http-request}) + out (th/command! params)] + + (t/is (nil? (:error out))) + (t/is (nil? (:result out))) + + (let [[row :as rows] (->> (th/db-exec! ["select * from audit_log"]) + (mapv decode-row))] + (t/is (= 1 (count rows))) + ;; source is telemetry:frontend, not frontend + (t/is (= "telemetry:frontend" (:source row))) + ;; profile-id preserved + (t/is (= (:id prof) (:profile-id row))) + ;; event name preserved + (t/is (= "navigate" (:name row))) + ;; navigate events keep route and team-id; other keys stripped + (t/is (= {:route "dashboard-files" + :team-id (str team-id)} + (:props row))) + ;; ip zeroed + (t/is (= "0.0.0.0" (str (:ip-addr row)))) + ;; timestamps truncated to day precision + (let [day-now (ct/truncate (ct/now) :days)] + (t/is (= day-now (:created-at row))) + (t/is (= day-now (:tracked-at row)))) + ;; context only contains safe keys + (let [ctx (:context row)] + (t/is (contains? ctx :browser)) + (t/is (= "Chrome" (:browser ctx))) + (t/is (contains? ctx :os)) + (t/is (= "Linux" (:os ctx))) + ;; session-linking keys stripped + (t/is (not (contains? ctx :session))) + (t/is (not (contains? ctx :external-session-id)))))))) + +(t/deftest push-events-both-flags-creates-two-rows + ;; When both :audit-log and :telemetry flags are active, two rows + ;; should be stored: one full audit entry and one telemetry entry. + (with-redefs [cf/flags #{:audit-log :telemetry}] + (let [prof (th/create-profile* 1 {:is-active true}) + params {::th/type :push-audit-events + ::rpc/profile-id (:id prof) + :events [{:name "navigate" + :props {:route "dashboard"} + :context {:browser "Chrome" + :version "2.0.0" + :initiator "app"} + :timestamp (ct/now) + :type "action"}]} + params (with-meta params + {:app.http/request http-request}) + out (th/command! params)] + + (t/is (nil? (:error out))) + + (let [[row1 row2 :as rows] (->> (th/db-exec! ["select * from audit_log order by source"]) + (mapv decode-row))] + (t/is (= 2 (count rows))) + ;; First row: full audit-log entry + (t/is (= "frontend" (:source row1))) + (t/is (contains? (:props row1) :route)) + (t/is (not= "0.0.0.0" (str (:ip-addr row1)))) + ;; Second row: telemetry entry + (t/is (= "telemetry:frontend" (:source row2))) + (t/is (= "0.0.0.0" (str (:ip-addr row2)))) + (let [day-now (ct/truncate (ct/now) :days)] + (t/is (= day-now (:created-at row2))) + (t/is (= day-now (:tracked-at row2)))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; BACKEND PROCESS-EVENT PATH (RPC commands) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest backend-process-event-only-audit-log + (with-redefs [cf/flags #{:audit-log}] + (let [prof (th/create-profile* 1 {:is-active true}) + event {:id (uuid/next) + :type "action" + :name "test-cmd" + :profile-id (:id prof) + :props {:full-key "full-val"} + :context {:version "2.0.0" :initiator "app"} + :tracked-at (ct/now) + :created-at (ct/now) + :source "backend"}] + (audit/submit* th/*system* event) + (let [[row :as rows] (->> (th/db-exec! ["select * from audit_log"]) + (mapv decode-row))] + (t/is (= 1 (count rows))) + (t/is (= "backend" (:source row))) + (t/is (= "full-val" (get-in row [:props :full-key]))) + (t/is (not= "0.0.0.0" (str (:ip-addr row)))))))) + +(t/deftest backend-process-event-only-telemetry + (with-redefs [cf/flags #{:telemetry}] + (let [prof (th/create-profile* 1 {:is-active true}) + event {:id (uuid/next) + :type "action" + :name "test-cmd" + :profile-id (:id prof) + :props {:full-key "full-val"} + :context {:version "2.0.0" :initiator "app"} + :tracked-at (ct/now) + :created-at (ct/now) + :source "backend"}] + (audit/submit* th/*system* event) + (let [[row :as rows] (->> (th/db-exec! ["select * from audit_log"]) + (mapv decode-row))] + (t/is (= 1 (count rows))) + (t/is (= "telemetry:backend" (:source row))) + (t/is (= "0.0.0.0" (str (:ip-addr row)))))))) + +(t/deftest backend-process-event-both-flags-creates-two-rows + ;; When both :audit-log and :telemetry are active, the backend + ;; process-event must store two rows: one full audit entry and one + ;; telemetry entry. + (with-redefs [cf/flags #{:audit-log :telemetry}] + (let [prof (th/create-profile* 1 {:is-active true}) + event {:id (uuid/next) + :type "action" + :name "test-cmd" + :profile-id (:id prof) + :props {:keep-me "important"} + :context {:version "2.0.0" :initiator "app"} + :tracked-at (ct/now) + :created-at (ct/now) + :source "backend"}] + (audit/submit* th/*system* event) + (let [[row1 row2 :as rows] (->> (th/db-exec! ["select * from audit_log order by source"]) + (mapv decode-row))] + (t/is (= 2 (count rows))) + ;; First row: full audit-log entry + (t/is (= "backend" (:source row1))) + (t/is (= "important" (get-in row1 [:props :keep-me]))) + (t/is (not= "0.0.0.0" (str (:ip-addr row1)))) + ;; Second row: telemetry entry + (t/is (= "telemetry:backend" (:source row2))) + (t/is (= "0.0.0.0" (str (:ip-addr row2)))) + (let [day-now (ct/truncate (ct/now) :days)] + (t/is (= day-now (:created-at row2))) + (t/is (= day-now (:tracked-at row2)))))))) + +(t/deftest push-events-disabled-when-no-flags-and-no-telemetry + ;; When neither audit-log nor telemetry is enabled, no rows should + ;; be stored. + (with-redefs [cf/flags #{}] + (let [prof (th/create-profile* 1 {:is-active true}) + params {::th/type :push-audit-events + ::rpc/profile-id (:id prof) + :events [{:name "navigate" + :props {:route "dashboard"} + :timestamp (ct/now) + :type "action"}]} + params (with-meta params + {:app.http/request http-request}) + out (th/command! params)] + + (t/is (nil? (:error out))) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log"]))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; PURE HELPER UNIT TESTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest extract-utm-params-utm + ;; UTM params are namespaced under :penpot + (let [result (audit/extract-utm-params {:utm_source "google" + :utm_medium "cpc" + :utm_campaign "spring" + :other "ignored"})] + (t/is (= "google" (:penpot/utm-source result))) + (t/is (= "cpc" (:penpot/utm-medium result))) + (t/is (= "spring" (:penpot/utm-campaign result))) + (t/is (not (contains? result :other))))) + +(t/deftest extract-utm-params-mtm + ;; MTM params are also namespaced under :penpot + (let [result (audit/extract-utm-params {:mtm_source "newsletter" + :mtm_medium "email"})] + (t/is (= "newsletter" (:penpot/mtm-source result))) + (t/is (= "email" (:penpot/mtm-medium result))))) + +(t/deftest extract-utm-params-empty + (t/is (= {} (audit/extract-utm-params {}))) + (t/is (= {} (audit/extract-utm-params {:foo "bar" :baz 42})))) + +(t/deftest profile->props-selects-and-merges + ;; Selects profile-props keys and merges with (:props profile) + (let [profile {:id (uuid/next) + :fullname "John" + :email "john@example.com" + :is-active true + :lang "en" + :deleted-field "gone" + :props {:custom-key "custom-val" + :newsletter-updates true}} + result (audit/profile->props profile)] + ;; Selected keys from profile + (t/is (= "John" (:fullname result))) + (t/is (= "john@example.com" (:email result))) + (t/is (true? (:is-active result))) + (t/is (= "en" (:lang result))) + ;; Merged from (:props profile) + (t/is (= "custom-val" (:custom-key result))) + (t/is (true? (:newsletter-updates result))) + ;; Keys not in profile-props are excluded + (t/is (not (contains? result :deleted-field))))) + +(t/deftest profile->props-removes-nils + (let [profile {:id (uuid/next) :fullname nil :email "a@b.com"} + result (audit/profile->props profile)] + (t/is (not (contains? result :fullname))) + (t/is (= "a@b.com" (:email result))))) + +(t/deftest clean-props-removes-reserved + ;; Reserved props (:session-id, :password, :old-password, :token) are stripped + (let [props {:name "test" + :session-id "sess-123" + :password "secret" + :old-password "old-secret" + :token "tok-456" + :valid-key "kept"} + result (audit/clean-props props)] + (t/is (= "test" (:name result))) + (t/is (= "kept" (:valid-key result))) + (t/is (not (contains? result :session-id))) + (t/is (not (contains? result :password))) + (t/is (not (contains? result :old-password))) + (t/is (not (contains? result :token))))) + +(t/deftest clean-props-removes-qualified-keys + ;; Qualified keywords (namespaced) are stripped + (let [props {:simple "kept" + ::namespaced "stripped" + :app.rpc/also-stripped true} + result (audit/clean-props props)] + (t/is (= "kept" (:simple result))) + (t/is (not (contains? result ::namespaced))) + (t/is (not (contains? result :app.rpc/also-stripped))))) + +(t/deftest clean-props-removes-nils + (let [props {:a nil :b "val" :c nil} + result (audit/clean-props props)] + (t/is (= "val" (:b result))) + (t/is (not (contains? result :a))) + (t/is (not (contains? result :c))))) + +(t/deftest get-external-session-id-valid + (let [request (reify yetti.request/IRequest + (get-header [_ name] + (case name "x-external-session-id" "abc-123")))] + (t/is (= "abc-123" (audit/get-external-session-id request))))) + +(t/deftest get-external-session-id-nil-when-missing + (let [request (reify yetti.request/IRequest + (get-header [_ _] nil))] + (t/is (nil? (audit/get-external-session-id request))))) + +(t/deftest get-external-session-id-nil-when-null-string + (let [request (reify yetti.request/IRequest + (get-header [_ name] + (case name "x-external-session-id" "null")))] + (t/is (nil? (audit/get-external-session-id request))))) + +(t/deftest get-external-session-id-nil-when-blank + (let [request (reify yetti.request/IRequest + (get-header [_ name] + (case name "x-external-session-id" " ")))] + (t/is (nil? (audit/get-external-session-id request))))) + +(t/deftest get-external-session-id-nil-when-too-long + (let [long-id (apply str (repeat 300 "x")) + request (reify yetti.request/IRequest + (get-header [_ name] + (case name "x-external-session-id" long-id)))] + (t/is (nil? (audit/get-external-session-id request))))) + +(t/deftest get-client-user-agent-valid + (let [request (reify yetti.request/IRequest + (get-header [_ name] + (case name "user-agent" "Mozilla/5.0 (Test)")))] + (t/is (= "Mozilla/5.0 (Test)" (audit/get-client-user-agent request))))) + +(t/deftest get-client-user-agent-nil-when-missing + (let [request (reify yetti.request/IRequest + (get-header [_ _] nil))] + (t/is (nil? (audit/get-client-user-agent request))))) + +(t/deftest get-client-user-agent-truncates-long + (let [long-ua (apply str (repeat 600 "x")) + request (reify yetti.request/IRequest + (get-header [_ name] + (case name "user-agent" long-ua)))] + (t/is (<= (count (audit/get-client-user-agent request)) 500)))) + +(t/deftest get-client-event-origin-valid + (let [get-client-event-origin (ns-resolve 'app.loggers.audit 'get-client-event-origin) + request (reify yetti.request/IRequest + (get-header [_ name] + (case name "x-event-origin" "workspace")))] + (t/is (= "workspace" (get-client-event-origin request))))) + +(t/deftest get-client-event-origin-nil-when-null + (let [get-client-event-origin (ns-resolve 'app.loggers.audit 'get-client-event-origin) + request (reify yetti.request/IRequest + (get-header [_ name] + (case name "x-event-origin" "null")))] + (t/is (nil? (get-client-event-origin request))))) + +(t/deftest get-client-event-origin-nil-when-blank + (let [get-client-event-origin (ns-resolve 'app.loggers.audit 'get-client-event-origin) + request (reify yetti.request/IRequest + (get-header [_ name] + (case name "x-event-origin" " ")))] + (t/is (nil? (get-client-event-origin request))))) + +(t/deftest get-client-event-origin-truncates-long + (let [get-client-event-origin (ns-resolve 'app.loggers.audit 'get-client-event-origin) + long-origin (apply str (repeat 300 "a")) + request (reify yetti.request/IRequest + (get-header [_ name] + (case name "x-event-origin" long-origin)))] + (t/is (<= (count (get-client-event-origin request)) 200)))) + +(t/deftest get-client-version-valid + (let [get-client-version (ns-resolve 'app.loggers.audit 'get-client-version) + request (reify yetti.request/IRequest + (get-header [_ name] + (case name "x-frontend-version" "2.0.0")))] + (t/is (= "2.0.0" (get-client-version request))))) + +(t/deftest get-client-version-nil-when-null + (let [get-client-version (ns-resolve 'app.loggers.audit 'get-client-version) + request (reify yetti.request/IRequest + (get-header [_ name] + (case name "x-frontend-version" "null")))] + (t/is (nil? (get-client-version request))))) + +(t/deftest get-client-version-nil-when-blank + (let [get-client-version (ns-resolve 'app.loggers.audit 'get-client-version) + request (reify yetti.request/IRequest + (get-header [_ name] + (case name "x-frontend-version" " ")))] + (t/is (nil? (get-client-version request))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; INSERT DEFAULTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest insert-only-runs-with-audit-log-flag + ;; insert must be a no-op when :audit-log flag is not set + (with-redefs [app.config/flags #{:telemetry}] + (audit/insert th/*system* {:name "test" :type "action"}) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))))) + +(t/deftest insert-sets-defaults + ;; insert must set defaults and persist when :audit-log is set + (with-redefs [app.config/flags #{:audit-log}] + (audit/insert th/*system* {:name "test-action" :type "action"}) + (let [[row] (->> (th/db-exec! ["select * from audit_log"]) + (mapv decode-row))] + (t/is (some? row)) + (t/is (= "test-action" (:name row))) + (t/is (= "action" (:type row))) + (t/is (= "backend" (:source row))) + (t/is (some? (:id row))) + (t/is (some? (:created-at row))) + (t/is (some? (:tracked-at row))) + (t/is (= {} (:props row))) + (t/is (= {} (:context row)))))) diff --git a/backend/test/backend_tests/rpc_doc_test.clj b/backend/test/backend_tests/rpc_doc_test.clj index 964ed57b6a..82c0dc28ff 100644 --- a/backend/test/backend_tests/rpc_doc_test.clj +++ b/backend/test/backend_tests/rpc_doc_test.clj @@ -12,10 +12,12 @@ [app.common.schema :as sm] [app.common.schema.generators :as sg] [app.common.schema.test :as smt] + [app.config :as cf] [app.rpc :as-alias rpc] [app.rpc.doc :as rpc.doc] [backend-tests.helpers :as th] - [clojure.test :as t])) + [clojure.test :as t] + [yetti.response :as-alias yres])) (t/use-fixtures :once th/state-init) @@ -31,6 +33,17 @@ false))) {:num 15})) - - +(t/deftest doc-handler-returns-html-content-type + (with-redefs [cf/flags #{:backend-api-doc}] + (let [methods (::rpc/methods th/*system*) + handler (#'rpc.doc/handler :methods methods + :label "main" + :entrypoint "http://localhost/api/main/methods" + :openapi "http://localhost/api/main/doc/openapi" + :template "app/templates/main-api-doc.tmpl") + request {} + response (handler request)] + (t/is (= 200 (::yres/status response))) + (t/is (= "text/html; charset=utf-8" + (get-in response [::yres/headers "content-type"])))))) diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index d45dec0453..5faa31481b 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -918,6 +918,72 @@ (t/is (th/ex-info? error)) (t/is (th/ex-of-type? error :not-found))))) +(t/deftest link-file-to-library-creates-sync-row + (let [profile (th/create-profile* 1) + file1 (th/create-file* 1 {:project-id (:default-project-id profile) + :profile-id (:id profile) + :is-shared true}) + file2 (th/create-file* 2 {:project-id (:default-project-id profile) + :profile-id (:id profile)}) + data {::th/type :link-file-to-library + ::rpc/profile-id (:id profile) + :file-id (:id file2) + :library-id (:id file1)} + out (th/command! data) + rel (th/db-get :file-library-rel {:file-id (:id file2) + :library-file-id (:id file1)}) + sync (th/db-get :file-library-sync {:file-id (:id file2) + :library-file-id (:id file1)})] + + (t/is (nil? (:error out))) + (t/is (some? rel)) + (t/is (some? sync)) + (t/is (some? (:synced-at sync))))) + +(t/deftest update-file-library-sync-status-updates-sync-row + (let [profile (th/create-profile* 1) + file1 (th/create-file* 1 {:project-id (:default-project-id profile) + :profile-id (:id profile) + :is-shared true}) + file2 (th/create-file* 2 {:project-id (:default-project-id profile) + :profile-id (:id profile)}) + _ (th/link-file-to-library* {:file-id (:id file2) + :library-id (:id file1)}) + before (th/db-get :file-library-sync {:file-id (:id file2) + :library-file-id (:id file1)}) + _ (th/sleep 10) + data {::th/type :update-file-library-sync-status + ::rpc/profile-id (:id profile) + :file-id (:id file2) + :library-id (:id file1)} + out (th/command! data) + after (th/db-get :file-library-sync {:file-id (:id file2) + :library-file-id (:id file1)})] + + (t/is (nil? (:error out))) + (t/is (some? before)) + (t/is (some? after)) + (t/is (pos? (compare (:synced-at after) (:synced-at before)))))) + +(t/deftest update-file-library-sync-status-without-link-creates-sync-row + (let [profile (th/create-profile* 1) + file1 (th/create-file* 1 {:project-id (:default-project-id profile) + :profile-id (:id profile) + :is-shared true}) + file2 (th/create-file* 2 {:project-id (:default-project-id profile) + :profile-id (:id profile)}) + data {::th/type :update-file-library-sync-status + ::rpc/profile-id (:id profile) + :file-id (:id file2) + :library-id (:id file1)} + out (th/command! data) + sync (th/db-get :file-library-sync {:file-id (:id file2) + :library-file-id (:id file1)})] + + (t/is (nil? (:error out))) + (t/is (some? sync)) + (t/is (some? (:synced-at sync))))) + (t/deftest deletion (let [profile1 (th/create-profile* 1) diff --git a/backend/test/backend_tests/rpc_file_thumbnails_test.clj b/backend/test/backend_tests/rpc_file_thumbnails_test.clj index 28134da5ff..6091ac14b8 100644 --- a/backend/test/backend_tests/rpc_file_thumbnails_test.clj +++ b/backend/test/backend_tests/rpc_file_thumbnails_test.clj @@ -154,7 +154,7 @@ (t/is (nil? (sto/get-object storage (:media-id row1)))) (t/is (some? (sto/get-object storage (:media-id row2)))) - ;; check that storage object is still exists but is marked as deleted + ;; check that storage object is still exists but is marked as deleted. (let [row (th/db-get :storage-object {:id (:media-id row1)} {::db/remove-deleted false})] (t/is (nil? row)))))) @@ -254,6 +254,32 @@ (t/is (some? (sto/get-object storage (:media-id row2))))))) +(t/deftest create-file-thumbnail-requires-edit-permissions + (let [owner (th/create-profile* 1) + viewer (th/create-profile* 2) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:default-project-id owner) + :is-shared false + :revn 1}) + _ (th/create-file-role* {:file-id (:id file) + :profile-id (:id viewer) + :role :viewer}) + data {::th/type :create-file-thumbnail + ::rpc/profile-id (:id viewer) + :file-id (:id file) + :revn 1 + :media {:filename "sample.jpg" + :size 7923 + :path (th/tempfile "backend_tests/test_files/sample2.jpg") + :mtype "image/jpeg"}} + out (th/command! data) + error (:error out)] + + (t/is (nil? (:result out))) + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :not-found)) + (t/is (= 0 (count (th/db-query :file-thumbnail {:file-id (:id file)})))))) + (t/deftest error-on-direct-storage-obj-deletion (let [storage (::sto/storage th/*system*) profile (th/create-profile* 1) diff --git a/backend/test/backend_tests/rpc_font_test.clj b/backend/test/backend_tests/rpc_font_test.clj index 498e21ef2b..8733ca3ad9 100644 --- a/backend/test/backend_tests/rpc_font_test.clj +++ b/backend/test/backend_tests/rpc_font_test.clj @@ -17,7 +17,9 @@ [clojure.test :as t] [datoteka.fs :as fs] [datoteka.io :as io] - [mockery.core :refer [with-mocks]])) + [mockery.core :refer [with-mocks]]) + (:import + java.io.RandomAccessFile)) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -327,3 +329,596 @@ (let [error (:error out) error-data (ex-data error)] (t/is (th/ex-info? error)))))) + +;; ----------------------------------------------------------------------- +;; Helpers for chunked-upload font tests +;; ----------------------------------------------------------------------- + +(defn- split-bytes-into-chunks + "Splits `data` (byte array) into chunks of at most `chunk-size` bytes. + Returns a vector of byte arrays." + [^bytes data chunk-size] + (let [length (alength data)] + (loop [offset 0 chunks []] + (if (>= offset length) + chunks + (let [remaining (- length offset) + size (min chunk-size remaining) + buf (byte-array size)] + (System/arraycopy data offset buf 0 size) + (recur (+ offset size) (conj chunks buf))))))) + +(defn- make-chunk-mfile + "Writes `data` (byte array) to a tempfile and returns a map + compatible with the upload-chunk :content parameter." + [^bytes data mtype] + (let [tmp (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-font-chunk-")] + (io/write* tmp data) + {:filename "chunk" + :path tmp + :mtype mtype + :size (alength data)})) + +(defn- create-upload-session! + "Creates an upload session for `prof` with `total-chunks`. Returns the session-id UUID." + [prof total-chunks] + (let [out (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks total-chunks})] + (t/is (nil? (:error out))) + (:session-id (:result out)))) + +(defn- upload-font-chunked! + "Splits `font-bytes` into chunks of `chunk-size` bytes, creates an upload + session, uploads all chunks, and returns the session-id UUID." + [prof ^bytes font-bytes mtype chunk-size] + (let [chunks (split-bytes-into-chunks font-bytes chunk-size) + session-id (create-upload-session! prof (count chunks))] + (doseq [[idx chunk-data] (map-indexed vector chunks)] + (let [mfile (make-chunk-mfile chunk-data mtype) + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index idx + :content mfile})] + (t/is (nil? (:error out))))) + session-id)) + +(defn- assert-font-variant-result + "Checks that a successful create-font-variant result has valid UUIDs and + the expected scalar fields matching `params`." + [params result] + (t/is (uuid? (:id result))) + (t/is (uuid? (:ttf-file-id result))) + (t/is (uuid? (:otf-file-id result))) + (t/is (uuid? (:woff1-file-id result))) + (t/are [k] (= (get params k) (get result k)) + :team-id + :font-id + :font-family + :font-weight + :font-style)) + +;; ----------------------------------------------------------------------- +;; Path 1 – Normal (direct :data bytes) +;; ----------------------------------------------------------------------- + +(t/deftest create-font-variant-normal-ttf + (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 10) + data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "chunked-test" + :font-weight 400 + :font-style "normal" + :data {"font/ttf" data}} + out (th/command! params)] + (t/is (= 1 (:call-count @mock))) + (t/is (nil? (:error out))) + (assert-font-variant-result params (:result out))))) + +(t/deftest create-font-variant-normal-otf + (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 11) + data (-> (io/resource "backend_tests/test_files/font-1.otf") (io/read*)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "chunked-test" + :font-weight 400 + :font-style "normal" + :data {"font/otf" data}} + out (th/command! params)] + (t/is (= 1 (:call-count @mock))) + (t/is (nil? (:error out))) + (assert-font-variant-result params (:result out))))) + +(t/deftest create-font-variant-normal-woff + (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 12) + data (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "chunked-test" + :font-weight 400 + :font-style "normal" + :data {"font/woff" data}} + out (th/command! params)] + (t/is (= 1 (:call-count @mock))) + (t/is (nil? (:error out))) + (assert-font-variant-result params (:result out))))) + +;; ----------------------------------------------------------------------- +;; Path 2 – Legacy chunking (:data with vector of byte-arrays per mtype) +;; ----------------------------------------------------------------------- + +(t/deftest create-font-variant-legacy-chunked-ttf + "Upload a TTF via the legacy :data path where each mtype value is a + vector of byte-array chunks (4 MiB each) instead of a single byte-array." + (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 20) + full-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) + ;; Simulate 4 MiB legacy chunks – font is small so a single chunk suffices + chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "legacy-chunked" + :font-weight 700 + :font-style "italic" + :data {"font/ttf" (vec chunks)}} + out (th/command! params)] + (t/is (= 1 (:call-count @mock))) + (t/is (nil? (:error out))) + (assert-font-variant-result params (:result out))))) + +(t/deftest create-font-variant-legacy-chunked-woff + "Upload a WOFF via the legacy :data path with multiple sub-4 KiB chunks + to exercise the SequenceInputStream concatenation path." + (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 21) + full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) + ;; Split into small chunks to exercise the SequenceInputStream path + chunks (split-bytes-into-chunks full-bytes 512) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "legacy-chunked-woff" + :font-weight 400 + :font-style "normal" + :data {"font/woff" (vec chunks)}} + out (th/command! params)] + (t/is (= 1 (:call-count @mock))) + (t/is (nil? (:error out))) + (assert-font-variant-result params (:result out))))) + +;; ----------------------------------------------------------------------- +;; Path 3 – New standardized chunked upload (:uploads map) +;; ----------------------------------------------------------------------- + +(t/deftest create-font-variant-chunked-upload-ttf + "Upload a TTF via the new :uploads path (chunked-upload API)." + (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 30) + font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) + session-id (upload-font-chunked! prof font-bytes "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "new-chunked" + :font-weight 400 + :font-style "normal" + :uploads {"font/ttf" session-id}} + out (th/command! params)] + ;; quotes/check! is called at least once (for the font-variant quota) plus + ;; once during session creation — assert it fired at least once. + (t/is (>= (:call-count @mock) 1)) + (t/is (nil? (:error out))) + (assert-font-variant-result params (:result out))))) + +(t/deftest create-font-variant-chunked-upload-otf + "Upload an OTF via the new :uploads path." + (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 31) + font-bytes (-> (io/resource "backend_tests/test_files/font-1.otf") (io/read*)) + session-id (upload-font-chunked! prof font-bytes "font/otf" (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "new-chunked-otf" + :font-weight 400 + :font-style "normal" + :uploads {"font/otf" session-id}} + out (th/command! params)] + (t/is (>= (:call-count @mock) 1)) + (t/is (nil? (:error out))) + (assert-font-variant-result params (:result out))))) + +(t/deftest create-font-variant-chunked-upload-woff + "Upload a WOFF via the new :uploads path." + (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 32) + font-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) + session-id (upload-font-chunked! prof font-bytes "font/woff" (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "new-chunked-woff" + :font-weight 400 + :font-style "normal" + :uploads {"font/woff" session-id}} + out (th/command! params)] + (t/is (>= (:call-count @mock) 1)) + (t/is (nil? (:error out))) + (assert-font-variant-result params (:result out))))) + +(t/deftest create-font-variant-chunked-upload-multi-chunk + "Upload a WOFF split into many small chunks to exercise multi-chunk assembly." + (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 33) + font-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) + ;; Use a chunk-size smaller than 4 MiB to force multiple chunks while + ;; staying within the 20-chunk-per-session quota limit (29836 / 2000 = ~15 chunks). + session-id (upload-font-chunked! prof font-bytes "font/woff" 2000) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "multi-chunk-woff" + :font-weight 400 + :font-style "normal" + :uploads {"font/woff" session-id}} + out (th/command! params)] + (t/is (>= (:call-count @mock) 1)) + (t/is (nil? (:error out))) + (assert-font-variant-result params (:result out))))) + +;; ----------------------------------------------------------------------- +;; Error cases +;; ----------------------------------------------------------------------- + +(t/deftest create-font-variant-missing-data-and-uploads + "Neither :data nor :uploads is present — schema validation must reject it." + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 40) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "bad" + :font-weight 400 + :font-style "normal"} + out (th/command! params)] + (t/is (some? (:error out))) + (t/is (= :validation (-> out :error ex-data :type))))) + +(t/deftest create-font-variant-chunked-upload-missing-chunks + "When only some chunks are uploaded the assembly step must fail." + (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 41) + font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) + ;; 5000-byte chunks → 68640/5000 = 14 chunks; declare 15 but only upload 13 + chunks (split-bytes-into-chunks font-bytes 5000) + ;; Declare one extra chunk so assembly will fail (not all chunks present) + session-id (create-upload-session! prof (inc (count chunks)))] + + ;; Upload all real chunks except the last one (omit it so the session is incomplete) + (doseq [[idx chunk-data] (map-indexed vector (butlast chunks))] + (let [mfile (make-chunk-mfile chunk-data "font/ttf") + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index idx + :content mfile})] + (t/is (nil? (:error out))))) + + (let [out (th/command! {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "missing-chunks" + :font-weight 400 + :font-style "normal" + :uploads {"font/ttf" session-id}})] + (t/is (some? (:error out))))))) + +(t/deftest create-font-variant-chunked-upload-invalid-session + "Passing a non-existent session-id must fail at assembly time." + (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 42) + out (th/command! {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "bad-session" + :font-weight 400 + :font-style "normal" + :uploads {"font/ttf" (uuid/next)}})] + (t/is (some? (:error out)))))) + +;; ----------------------------------------------------------------------- +;; Font size validation tests +;; ----------------------------------------------------------------------- + +(t/deftest create-font-variant-size-exceeded-normal + "Direct :data upload exceeding font-max-file-size must be rejected." + (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] + (with-redefs [app.config/config (assoc app.config/config :font-max-file-size 1)] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 50) + data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "size-exceeded" + :font-weight 400 + :font-style "normal" + :data {"font/ttf" data}} + out (th/command! params)] + (t/is (some? (:error out))) + (t/is (= :restriction (-> out :error ex-data :type))) + (t/is (= :font-max-file-size-reached (-> out :error ex-data :code))))))) + +(t/deftest create-font-variant-size-exceeded-legacy-chunked + "Legacy :data chunk-vector upload exceeding font-max-file-size must be rejected." + (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] + (with-redefs [app.config/config (assoc app.config/config :font-max-file-size 1)] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 51) + full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) + chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "size-exceeded-legacy" + :font-weight 400 + :font-style "normal" + :data {"font/woff" (vec chunks)}} + out (th/command! params)] + (t/is (some? (:error out))) + (t/is (= :restriction (-> out :error ex-data :type))) + (t/is (= :font-max-file-size-reached (-> out :error ex-data :code))))))) + +(t/deftest create-font-variant-size-exceeded-chunked-upload + "New :uploads path exceeding font-max-file-size must be rejected after assembly." + (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 52) + font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) + session-id (upload-font-chunked! prof font-bytes "font/ttf" (* 4 1024 1024))] + (with-redefs [app.config/config (assoc app.config/config :font-max-file-size 1)] + (let [out (th/command! {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "size-exceeded-chunked" + :font-weight 400 + :font-style "normal" + :uploads {"font/ttf" session-id}})] + (t/is (some? (:error out))) + (t/is (= :restriction (-> out :error ex-data :type))) + (t/is (= :font-max-file-size-reached (-> out :error ex-data :code)))))))) + +(t/deftest create-font-variant-size-within-limit + "Upload exactly at the limit must succeed." + (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 53) + font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) + font-size (alength ^bytes font-bytes)] + (with-redefs [app.config/config (assoc app.config/config :font-max-file-size font-size)] + (let [params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "size-at-limit" + :font-weight 400 + :font-style "normal" + :data {"font/ttf" font-bytes}} + out (th/command! params)] + (t/is (nil? (:error out))) + (assert-font-variant-result params (:result out))))))) + +;; ----------------------------------------------------------------------- +;; Font media-type validation tests +;; ----------------------------------------------------------------------- + +(t/deftest create-font-variant-invalid-type-normal + "Direct :data upload with a disallowed mtype must be rejected." + (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 60) + data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "invalid-type" + :font-weight 400 + :font-style "normal" + :data {"application/octet-stream" data}} + out (th/command! params)] + (t/is (some? (:error out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (= :media-type-not-allowed (-> out :error ex-data :code)))))) + +(t/deftest create-font-variant-invalid-type-legacy-chunked + "Legacy :data chunk-vector upload with a disallowed mtype must be rejected." + (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 61) + full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) + chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "invalid-type-legacy" + :font-weight 400 + :font-style "normal" + :data {"image/png" (vec chunks)}} + out (th/command! params)] + (t/is (some? (:error out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (= :media-type-not-allowed (-> out :error ex-data :code)))))) + +(t/deftest create-font-variant-invalid-type-chunked-upload + "New :uploads path with a disallowed mtype must be rejected after assembly." + (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 62) + font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) + ;; Upload the bytes under a valid session but lie about the mtype + ;; when calling create-font-variant. + session-id (upload-font-chunked! prof font-bytes "font/ttf" (* 4 1024 1024)) + out (th/command! {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "invalid-type-chunked" + :font-weight 400 + :font-style "normal" + :uploads {"image/jpeg" session-id}})] + (t/is (some? (:error out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (= :media-type-not-allowed (-> out :error ex-data :code)))))) + +;; --- Font family name validation / XSS prevention + +(t/deftest create-font-variant-with-invalid-family + (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 100) + data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))] + + ;; name with < should fail + (let [params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id :font-id font-id + :font-family "evil<script>alert(1)</script>" + :font-weight 400 :font-style "normal" + :data {"font/ttf" data}} + out (th/command! params)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation))) + + ;; name with ' should fail + (let [params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id :font-id font-id + :font-family "evil'name" + :font-weight 400 :font-style "normal" + :data {"font/ttf" data}} + out (th/command! params)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation))) + + ;; name with } should fail + (let [params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id :font-id font-id + :font-family "evil}name" + :font-weight 400 :font-style "normal" + :data {"font/ttf" data}} + out (th/command! params)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation))) + + ;; valid name should succeed + (let [params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id :font-id (uuid/custom 10 101) + :font-family "Source Sans Pro" + :font-weight 400 :font-style "normal" + :data {"font/ttf" data}} + out (th/command! params)] + (t/is (th/success? out)))))) + +(t/deftest update-font-with-invalid-family + (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 102) + data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))] + + ;; Create a valid font first + (let [params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id :font-id font-id + :font-family "ValidFont" + :font-weight 400 :font-style "normal" + :data {"font/ttf" data}} + out (th/command! params)] + (t/is (th/success? out))) + + ;; rename with < should fail + (let [params {::th/type :update-font + ::rpc/profile-id (:id prof) + :team-id team-id :id font-id + :name "evil<script>x</script>"} + out (th/command! params)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation))) + + ;; rename with ' should fail + (let [params {::th/type :update-font + ::rpc/profile-id (:id prof) + :team-id team-id :id font-id + :name "evil'name"} + out (th/command! params)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation))) + + ;; valid rename should succeed + (let [params {::th/type :update-font + ::rpc/profile-id (:id prof) + :team-id team-id :id font-id + :name "Valid Font Name"} + out (th/command! params)] + (t/is (th/success? out)))))) diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index 0e85b2d312..246b85302a 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -11,10 +11,10 @@ [app.common.uuid :as uuid] [app.config :as cf] [app.db :as-alias db] - [app.email :as email] [app.msgbus :as mbus] [app.nitrate :as nitrate] [app.rpc :as-alias rpc] + [app.worker :as wrk] [backend-tests.helpers :as th] [clojure.set :as set] [clojure.test :as t] @@ -45,11 +45,15 @@ (t/is (= :authentication-required (th/ex-code (:error out)))))) (t/deftest get-penpot-version - (let [profile (th/create-profile* 1 {:is-active true}) - out (management-command-with-nitrate! {::th/type :get-penpot-version - ::rpc/profile-id (:id profile)})] + (let [out (management-command-with-nitrate! {::th/type :get-penpot-version}) + version (-> out :result :version)] (t/is (th/success? out)) - (t/is (= cf/version (-> out :result :version))))) + (t/is (= #{:full :branch :base :main :major :minor :patch :modifier :commit :commit-hash} + (set (keys version)))) + (doseq [k [:full :branch :base :main :major :minor :patch :modifier :commit :commit-hash]] + (t/is (or (nil? (get version k)) + (string? (get version k))))) + (t/is (= cf/version version)))) (t/deftest get-teams-returns-only-owned-non-default-non-deleted (let [profile (th/create-profile* 1 {:is-active true}) @@ -74,24 +78,32 @@ (t/deftest notify-team-change-publishes-event (let [team-id (uuid/random) organization-id (uuid/random) + organization {:id organization-id + :name "Acme Inc" + :slug "acme-inc" + :owner-id (uuid/random) + :avatar-bg-url "http://example.com/avatar.svg"} calls (atom []) out (with-redefs [mbus/pub! (fn [_cfg & {:keys [topic message]}] (swap! calls conj {:topic topic :message message}))] (management-command-with-nitrate! {::th/type :notify-team-change :id team-id - :organization-id organization-id - :organization-name "Acme Inc"}))] + :is-your-penpot false + :organization organization}))] (t/is (th/success? out)) (t/is (= 1 (count @calls))) (t/is (= uuid/zero (-> @calls first :topic))) - (t/is (= {:type :team-org-change - :team-id team-id - :team-name nil - :organization-id organization-id - :organization-name "Acme Inc" - :notification nil} - (-> @calls first :message))))) + (let [msg (-> @calls first :message)] + (t/is (= :team-org-change (:type msg))) + (t/is (= nil (:notification msg))) + (t/is (= team-id (-> msg :team :id))) + (t/is (= false (-> msg :team :is-your-penpot))) + (t/is (= (:id organization) (-> msg :team :organization :id))) + (t/is (= (:name organization) (-> msg :team :organization :name))) + (t/is (= (:slug organization) (-> msg :team :organization :slug))) + (t/is (= (:owner-id organization) (-> msg :team :organization :owner-id))) + (t/is (= (:avatar-bg-url organization) (str (-> msg :team :organization :avatar-bg-url))))))) (t/deftest notify-user-added-to-organization-creates-default-org-team (let [profile (th/create-profile* 1 {:is-active true}) @@ -157,32 +169,158 @@ (t/is (= #{(:id team1) (:id team2)} (->> out :result :teams (map :id) set))))) -(t/deftest notify-org-deletion-prefixes-teams-and-notifies - (let [profile (th/create-profile* 1 {:is-active true}) - extra-team (th/create-team* 1 {:profile-id (:id profile)}) - default-team (th/db-get :team {:id (:default-team-id profile)}) - teams [(:id default-team) (:id extra-team)] - organization-name "Acme / Design" - expected-start (str "[" (d/sanitize-string organization-name) "] ") - calls (atom []) - out (with-redefs [mbus/pub! (fn [_cfg & {:keys [topic message]}] - (swap! calls conj {:topic topic - :message message}))] - (management-command-with-nitrate! {::th/type :notify-org-deletion - ::rpc/profile-id (:id profile) - :teams teams - :organization-name organization-name})) - updated (map #(th/db-get :team {:id %} {::db/remove-deleted false}) teams)] +(t/deftest notify-organization-deletion-prefixes-teams-and-publishes-org-deleted-event + (let [profile (th/create-profile* 1 {:is-active true}) + ;; One team will have files -> it will be kept and renamed. + team-with-files (th/db-get :team {:id (:default-team-id profile)}) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id team-with-files)}) + _ (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id project)}) + + ;; One team will be empty -> it will be soft-deleted. + empty-team (th/create-team* 1 {:profile-id (:id profile)}) + + organization-id (uuid/random) + organization-name "Acme / Design" + expected-start (str "[" (d/sanitize-string organization-name) "] ") + org-summary {:id organization-id + :name organization-name + :teams [{:id (:id team-with-files)} + {:id (:id empty-team)}]} + calls (atom []) + submitted (atom []) + out (with-redefs [nitrate/call (fn [_cfg method params] + (t/is (= :get-org-summary method)) + (t/is (= {:organization-id organization-id} params)) + org-summary) + wrk/submit! (fn [task] + (swap! submitted conj task) + nil) + mbus/pub! (fn [_cfg & {:keys [topic message]}] + (swap! calls conj {:topic topic + :message message}))] + (management-command-with-nitrate! {::th/type :notify-organization-deletion + ::rpc/profile-id (:id profile) + :organization-id organization-id})) + updated-with-files (th/db-get :team {:id (:id team-with-files)} {::db/remove-deleted false}) + updated-empty (th/db-get :team {:id (:id empty-team)} {::db/remove-deleted false})] (t/is (th/success? out)) + (t/is (nil? (:result out))) + + ;; Team with files is kept, unset as default, and renamed with org prefix. + (t/is (false? (:is-default updated-with-files))) + (t/is (str/starts-with? (:name updated-with-files) expected-start)) + (t/is (nil? (:deleted-at updated-with-files))) + + ;; Empty team is soft-deleted and a delete task is submitted. + (t/is (some? (:deleted-at updated-empty))) + (t/is (= 1 (count @submitted))) + + ;; A single organization-deleted event is published. + (t/is (= 1 (count @calls))) + (let [{:keys [topic message]} (first @calls)] + (t/is (= uuid/zero topic)) + (t/is (= :organization-deleted (:type message))) + (t/is (= organization-name (:organization-name message))) + (t/is (= #{(:id team-with-files) (:id empty-team)} + (set (:teams message)))) + (t/is (= #{(:id empty-team)} + (set (:deleted-teams message))))))) + +(t/deftest notify-user-organizations-deletion-renames-or-deletes-teams-and-publishes-per-org-events + (let [profile (th/create-profile* 1 {:is-active true}) + ;; org-1: one team with files, one empty + org-1-team-files (th/db-get :team {:id (:default-team-id profile)}) + org-1-proj (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id org-1-team-files)}) + _ (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id org-1-proj)}) + org-1-team-empty (th/create-team* 1 {:profile-id (:id profile)}) + + ;; org-2: one team with files, one empty + org-2-team-files (th/create-team* 2 {:profile-id (:id profile)}) + org-2-proj (th/create-project* 2 {:profile-id (:id profile) + :team-id (:id org-2-team-files)}) + _ (th/create-file* 2 {:profile-id (:id profile) + :project-id (:id org-2-proj)}) + org-2-team-empty (th/create-team* 3 {:profile-id (:id profile)}) + + org-1-id (uuid/random) + org-2-id (uuid/random) + org-1-name "Org One / Design" + org-2-name "Org Two" + org-1-prefix (str "[" (d/sanitize-string org-1-name) "] ") + org-2-prefix (str "[" (d/sanitize-string org-2-name) "] ") + owned-orgs [{:id org-1-id + :name org-1-name + :teams [{:id (:id org-1-team-files)} + {:id (:id org-1-team-empty)}]} + {:id org-2-id + :name org-2-name + :teams [{:id (:id org-2-team-files)} + {:id (:id org-2-team-empty)}]}] + calls (atom []) + submitted (atom []) + out (with-redefs [nitrate/call (fn [_cfg method params] + (case method + :get-owned-orgs + (do + (t/is (= {:profile-id (:id profile)} params)) + owned-orgs) + nil)) + wrk/submit! (fn [task] + (swap! submitted conj task) + nil) + mbus/pub! (fn [_cfg & {:keys [topic message]}] + (swap! calls conj {:topic topic + :message message}))] + (management-command-with-nitrate! {::th/type :notify-user-organizations-deletion + ::rpc/profile-id (:id profile) + :profile-id (:id profile)})) + org-1-updated-files (th/db-get :team {:id (:id org-1-team-files)} {::db/remove-deleted false}) + org-1-updated-empty (th/db-get :team {:id (:id org-1-team-empty)} {::db/remove-deleted false}) + org-2-updated-files (th/db-get :team {:id (:id org-2-team-files)} {::db/remove-deleted false}) + org-2-updated-empty (th/db-get :team {:id (:id org-2-team-empty)} {::db/remove-deleted false}) + msgs (->> @calls (map :message) vec) + org-msg (fn [org-name] + (first (filter #(= org-name (:organization-name %)) msgs)))] + (t/is (th/success? out)) + (t/is (nil? (:result out))) + + ;; org-1: team with files renamed; empty team deleted + (t/is (false? (:is-default org-1-updated-files))) + (t/is (str/starts-with? (:name org-1-updated-files) org-1-prefix)) + (t/is (nil? (:deleted-at org-1-updated-files))) + (t/is (some? (:deleted-at org-1-updated-empty))) + + ;; org-2: team with files renamed; empty team deleted + (t/is (false? (:is-default org-2-updated-files))) + (t/is (str/starts-with? (:name org-2-updated-files) org-2-prefix)) + (t/is (nil? (:deleted-at org-2-updated-files))) + (t/is (some? (:deleted-at org-2-updated-empty))) + + ;; two delete tasks (one per empty team) + (t/is (= 2 (count @submitted))) + + ;; one organization-deleted event per org (t/is (= 2 (count @calls))) - (doseq [team updated] - (t/is (false? (:is-default team))) - (t/is (str/starts-with? (:name team) expected-start))) - (doseq [call @calls] - (t/is (= uuid/zero (:topic call))) - (t/is (= :team-org-change (-> call :message :type))) - (t/is (= organization-name (-> call :message :organization-name))) - (t/is (= "dashboard.org-deleted" (-> call :message :notification)))))) + (t/is (every? #(= uuid/zero (:topic %)) @calls)) + (t/is (= #{:organization-deleted} + (set (map (comp :type :message) @calls)))) + + (let [m1 (org-msg org-1-name) + m2 (org-msg org-2-name)] + (t/is (some? m1)) + (t/is (some? m2)) + (t/is (= #{(:id org-1-team-files) (:id org-1-team-empty)} + (set (:teams m1)))) + (t/is (= #{(:id org-1-team-empty)} + (set (:deleted-teams m1)))) + (t/is (= #{(:id org-2-team-files) (:id org-2-team-empty)} + (set (:teams m2)))) + (t/is (= #{(:id org-2-team-empty)} + (set (:deleted-teams m2))))))) (t/deftest get-profile-by-email-success-and-not-found (let [profile (th/create-profile* 1 {:is-active true @@ -220,6 +358,323 @@ (t/is (= :not-found (th/ex-type (:error ko-out)))) (t/is (= :profile-not-found (th/ex-code (:error ko-out)))))) +(t/deftest get-org-invitations-returns-valid-deduped-by-email + (let [profile (th/create-profile* 1 {:is-active true}) + team-1 (th/create-team* 1 {:profile-id (:id profile)}) + team-2 (th/create-team* 2 {:profile-id (:id profile)}) + org-id (uuid/random) + org-summary {:id org-id + :teams [{:id (:id team-1)} + {:id (:id team-2)}]} + params {::th/type :get-org-invitations + ::rpc/profile-id (:id profile) + :organization-id org-id}] + + ;; Same email appears in org and team invitations; only one should be returned. + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id org-id + :team-id nil + :email-to "dup@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-1) + :org-id nil + :email-to "dup@example.com" + :created-by (:id profile) + :role "admin" + :valid-until (ct/in-future "72h")}) + + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-2) + :org-id nil + :email-to "valid@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "48h")}) + + ;; Expired invitation should be ignored. + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id org-id + :team-id nil + :email-to "expired@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-past "1h")}) + + (let [out (with-redefs [nitrate/call (fn [_cfg method _params] + (case method + :get-org-summary org-summary + nil))] + (management-command-with-nitrate! params)) + result (:result out) + emails (->> result (map :email) set) + dedup (->> result + (filter #(= "dup@example.com" (:email %))) + first)] + (t/is (th/success? out)) + (t/is (= #{"dup@example.com" "valid@example.com"} emails)) + (t/is (= 2 (count result))) + (t/is (some? (:id dedup))) + (t/is (some? (:sent-at dedup))) + (t/is (nil? (:organization-id dedup))) + (t/is (nil? (:team-id dedup))) + (t/is (nil? (:role dedup))) + (t/is (nil? (:valid-until dedup)))))) + +(t/deftest get-org-invitations-includes-org-level-invitations-when-no-teams + (let [profile (th/create-profile* 1 {:is-active true}) + org-id (uuid/random) + org-summary {:id org-id + :teams []} + params {::th/type :get-org-invitations + ::rpc/profile-id (:id profile) + :organization-id org-id}] + + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id org-id + :team-id nil + :email-to "org-only@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + (let [out (with-redefs [nitrate/call (fn [_cfg method _params] + (case method + :get-org-summary org-summary + nil))] + (management-command-with-nitrate! params)) + result (:result out)] + (t/is (th/success? out)) + (t/is (= 1 (count result))) + (t/is (= "org-only@example.com" (-> result first :email))) + (t/is (some? (-> result first :sent-at)))))) + +(t/deftest get-org-invitations-returns-existing-profile-data + (let [profile (th/create-profile* 1 {:is-active true}) + invited (th/create-profile* 2 {:is-active true + :fullname "Invited User"}) + photo-id (uuid/random) + _ (th/db-insert! :storage-object {:id photo-id + :backend "assets-fs"}) + _ (th/db-update! :profile {:photo-id photo-id} {:id (:id invited)}) + org-id (uuid/random) + org-summary {:id org-id + :teams []} + params {::th/type :get-org-invitations + ::rpc/profile-id (:id profile) + :organization-id org-id}] + + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id org-id + :team-id nil + :email-to (:email invited) + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + (let [out (with-redefs [nitrate/call (fn [_cfg method _params] + (case method + :get-org-summary org-summary + nil))] + (management-command-with-nitrate! params)) + invitation (-> out :result first)] + (t/is (th/success? out)) + (t/is (= "Invited User" (:name invitation))) + (t/is (some? (:sent-at invitation))) + (t/is (str/ends-with? (:photo-url invitation) + (str "/assets/by-id/" photo-id)))))) + +(t/deftest delete-org-invitations-removes-org-and-org-team-invitations-for-email + (let [profile (th/create-profile* 1 {:is-active true}) + team-1 (th/create-team* 1 {:profile-id (:id profile)}) + team-2 (th/create-team* 2 {:profile-id (:id profile)}) + outside-team (th/create-team* 3 {:profile-id (:id profile)}) + org-id (uuid/random) + org-summary {:id org-id + :teams [{:id (:id team-1)} + {:id (:id team-2)}]} + target-email "target@example.com" + params {::th/type :delete-org-invitations + ::rpc/profile-id (:id profile) + :organization-id org-id + :email "TARGET@example.com"}] + + ;; Should be deleted: org-level invitation for same org+email. + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id org-id + :team-id nil + :email-to target-email + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + ;; Should be deleted: team-level invitation for teams belonging to org summary. + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-1) + :org-id nil + :email-to target-email + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-past "1h")}) + + ;; Should remain: different email. + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-2) + :org-id nil + :email-to "other@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + ;; Should remain: same email but outside org scope. + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id outside-team) + :org-id nil + :email-to target-email + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + (let [out (with-redefs [nitrate/call (fn [_cfg method _params] + (case method + :get-org-summary org-summary + nil))] + (management-command-with-nitrate! params)) + remaining-target (th/db-query :team-invitation {:email-to target-email}) + remaining-other (th/db-query :team-invitation {:email-to "other@example.com"})] + (t/is (th/success? out)) + (t/is (nil? (:result out))) + (t/is (= 1 (count remaining-target))) + (t/is (= (:id outside-team) (:team-id (first remaining-target)))) + (t/is (= 1 (count remaining-other)))))) + +(t/deftest delete-all-org-invitations-removes-org-and-org-team-invitations + (let [profile (th/create-profile* 1 {:is-active true}) + team-1 (th/create-team* 1 {:profile-id (:id profile)}) + team-2 (th/create-team* 2 {:profile-id (:id profile)}) + outside-team (th/create-team* 3 {:profile-id (:id profile)}) + org-id (uuid/random) + org-summary {:id org-id + :teams [{:id (:id team-1)} + {:id (:id team-2)}]} + params {::th/type :delete-all-org-invitations + :organization-id org-id}] + + ;; Should be deleted: org-level invitation. + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id org-id + :team-id nil + :email-to "alice@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + ;; Should be deleted: team-level invitation in team-1 (belongs to org). + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-1) + :org-id nil + :email-to "bob@example.com" + :created-by (:id profile) + :role "admin" + :valid-until (ct/in-future "48h")}) + + ;; Should be deleted: team-level invitation in team-2 (belongs to org), + ;; even if expired. + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-2) + :org-id nil + :email-to "carol@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-past "1h")}) + + ;; Should remain: invitation to a team outside the org. + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id outside-team) + :org-id nil + :email-to "dan@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + ;; Should remain: invitation to a different organization. + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id (uuid/random) + :team-id nil + :email-to "erin@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + (let [calls (atom []) + out (with-redefs [nitrate/call (fn [_cfg method params] + (swap! calls conj {:method method :params params}) + (case method + :get-org-summary org-summary + nil))] + (management-command-with-nitrate! params)) + present? (fn [email] (seq (th/db-query :team-invitation {:email-to email})))] + (t/is (th/success? out)) + (t/is (nil? (:result out))) + + ;; get-org-summary was called with the right organization-id. + (t/is (= 1 (count @calls))) + (t/is (= :get-org-summary (-> @calls first :method))) + (t/is (= {:organization-id org-id} (-> @calls first :params))) + + ;; Org-level + team-in-org invitations are deleted. + (t/is (not (present? "alice@example.com"))) + (t/is (not (present? "bob@example.com"))) + (t/is (not (present? "carol@example.com"))) + + ;; Invitations outside the org survive. + (t/is (present? "dan@example.com")) + (t/is (present? "erin@example.com"))))) + +(t/deftest delete-all-org-invitations-handles-org-with-no-teams + (let [profile (th/create-profile* 1 {:is-active true}) + org-id (uuid/random) + params {::th/type :delete-all-org-invitations + :organization-id org-id}] + + ;; Org-level invitation should still be deleted. + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id org-id + :team-id nil + :email-to "alice@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + (let [out (with-redefs [nitrate/call (fn [_cfg method _params] + (case method + :get-org-summary {:id org-id :teams []} + nil))] + (management-command-with-nitrate! params)) + remaining (th/db-query :team-invitation {:org-id org-id})] + (t/is (th/success? out)) + (t/is (nil? (:result out))) + (t/is (empty? remaining))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Tests: remove-from-org ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/backend/test/backend_tests/rpc_media_test.clj b/backend/test/backend_tests/rpc_media_test.clj index 070a105a1b..2c2bea19e9 100644 --- a/backend/test/backend_tests/rpc_media_test.clj +++ b/backend/test/backend_tests/rpc_media_test.clj @@ -286,11 +286,11 @@ (t/deftest download-image-connection-error (t/testing "connection refused raises validation error" - (with-mocks [http-mock {:target 'app.http.client/req! + (with-mocks [http-mock {:target 'app.http.client/req-with-redirects :throw (java.net.ConnectException. "Connection refused")}] (let [cfg {::http/client :mock-client} err (try - (media/download-image cfg "http://unreachable.invalid/image.png") + (media/download-image cfg "https://example.com/image.png") nil (catch clojure.lang.ExceptionInfo e e))] (t/is (some? err)) @@ -298,11 +298,11 @@ (t/is (= :unable-to-download-image (:code (ex-data err))))))) (t/testing "connection timeout raises validation error" - (with-mocks [http-mock {:target 'app.http.client/req! + (with-mocks [http-mock {:target 'app.http.client/req-with-redirects :throw (java.net.http.HttpConnectTimeoutException. "Connect timed out")}] (let [cfg {::http/client :mock-client} err (try - (media/download-image cfg "http://unreachable.invalid/image.png") + (media/download-image cfg "https://example.com/image.png") nil (catch clojure.lang.ExceptionInfo e e))] (t/is (some? err)) @@ -310,11 +310,11 @@ (t/is (= :unable-to-download-image (:code (ex-data err))))))) (t/testing "request timeout raises validation error" - (with-mocks [http-mock {:target 'app.http.client/req! + (with-mocks [http-mock {:target 'app.http.client/req-with-redirects :throw (java.net.http.HttpTimeoutException. "Request timed out")}] (let [cfg {::http/client :mock-client} err (try - (media/download-image cfg "http://unreachable.invalid/image.png") + (media/download-image cfg "https://example.com/image.png") nil (catch clojure.lang.ExceptionInfo e e))] (t/is (some? err)) @@ -322,11 +322,11 @@ (t/is (= :unable-to-download-image (:code (ex-data err))))))) (t/testing "I/O error raises validation error" - (with-mocks [http-mock {:target 'app.http.client/req! + (with-mocks [http-mock {:target 'app.http.client/req-with-redirects :throw (java.io.IOException. "Stream closed")}] (let [cfg {::http/client :mock-client} err (try - (media/download-image cfg "http://unreachable.invalid/image.png") + (media/download-image cfg "https://example.com/image.png") nil (catch clojure.lang.ExceptionInfo e e))] (t/is (some? err)) @@ -336,14 +336,14 @@ (t/deftest download-image-status-code-error (t/testing "404 status raises validation error" - (with-mocks [http-mock {:target 'app.http.client/req! + (with-mocks [http-mock {:target 'app.http.client/req-with-redirects :return {:status 404 :headers {"content-type" "text/html" "content-length" "0"} :body nil}}] (let [cfg {::http/client :mock-client} err (try - (media/download-image cfg "http://example.com/not-found.png") + (media/download-image cfg "https://example.com/not-found.png") nil (catch clojure.lang.ExceptionInfo e e))] (t/is (some? err)) @@ -351,14 +351,14 @@ (t/is (= :unable-to-download-image (:code (ex-data err))))))) (t/testing "500 status raises validation error" - (with-mocks [http-mock {:target 'app.http.client/req! + (with-mocks [http-mock {:target 'app.http.client/req-with-redirects :return {:status 500 :headers {"content-type" "text/html" "content-length" "0"} :body nil}}] (let [cfg {::http/client :mock-client} err (try - (media/download-image cfg "http://example.com/server-error.png") + (media/download-image cfg "https://example.com/server-error.png") nil (catch clojure.lang.ExceptionInfo e e))] (t/is (some? err)) @@ -366,14 +366,14 @@ (t/is (= :unable-to-download-image (:code (ex-data err))))))) (t/testing "302 status raises validation error" - (with-mocks [http-mock {:target 'app.http.client/req! + (with-mocks [http-mock {:target 'app.http.client/req-with-redirects :return {:status 302 :headers {"content-type" "text/html" "content-length" "0"} :body nil}}] (let [cfg {::http/client :mock-client} err (try - (media/download-image cfg "http://example.com/redirect.png") + (media/download-image cfg "https://example.com/redirect.png") nil (catch clojure.lang.ExceptionInfo e e))] (t/is (some? err)) diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index d4cfedf871..dff16d64af 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -527,32 +527,89 @@ (t/is (= 0 (:call-count @mock)))))))) (t/deftest prepare-and-register-with-invitation-and-enabled-registration-1 - (let [itoken (tokens/generate th/*system* - {:iss :team-invitation - :exp (ct/in-future "48h") - :role :editor - :team-id uuid/zero - :member-email "user@example.com"}) - data {::th/type :prepare-register-profile - :invitation-token itoken - :fullname "foobar" - :email "user@example.com" - :password "foobar"} + ;; With email-verification ENABLED (the default), a brand-new + ;; profile created via the invitation flow is NOT active yet, so + ;; `register-profile` must NOT mint a session and must NOT echo + ;; back the invitation token. Instead it must dispatch the + ;; verify-email mail with the invitation token EMBEDDED into the + ;; verify-email JWE (so the team-invitation flow can resume after + ;; the user clicks the email link). + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [itoken (tokens/generate th/*system* + {:iss :team-invitation + :exp (ct/in-future "48h") + :role :editor + :team-id uuid/zero + :member-email "user@example.com"}) + prep-data {::th/type :prepare-register-profile + :invitation-token itoken + :fullname "foobar" + :email "user@example.com" + :password "foobar"} - {:keys [result error] :as out} (th/command! data)] - (t/is (nil? error)) - (t/is (map? result)) - (t/is (string? (:token result))) + {prep-result :result prep-error :error} (th/command! prep-data)] + (t/is (nil? prep-error)) + (t/is (map? prep-result)) + (t/is (string? (:token prep-result))) - (let [rtoken (:token result) - data {::th/type :register-profile - :token rtoken} + (let [reg-data {::th/type :register-profile + :token (:token prep-result)} - {:keys [result error] :as out} (th/command! data)] - ;; (th/print-result! out) - (t/is (nil? error)) - (t/is (map? result)) - (t/is (string? (:invitation-token result)))))) + {reg-result :result reg-error :error} (th/command! reg-data) + mdata (meta reg-result)] + (t/is (nil? reg-error)) + (t/is (map? reg-result)) + + ;; No invitation token echoed back, no session minted. + (t/is (nil? (:invitation-token reg-result))) + (t/is (empty? (:app.rpc/response-transform-fns mdata))) + + ;; The verify-email mail was dispatched, and its token claims + ;; carry the invitation-token through to the verification step. + (t/is (= 1 (:call-count @mock))) + (let [send-args (-> @mock :call-args) + email-token (->> send-args (some (fn [m] (when (map? m) (:token m))))) + vclaims (tokens/decode th/*system* email-token)] + (t/is (= :verify-email (:iss vclaims))) + (t/is (= itoken (:invitation-token vclaims)))))))) + +(t/deftest prepare-and-register-with-invitation-and-enabled-registration-1b + ;; With email-verification DISABLED, the brand-new profile is + ;; immediately active, so `register-profile` mints a session and + ;; returns the regenerated invitation token in the body — the + ;; frontend then redirects to :auth-verify-token to complete the + ;; team-invitation flow. + (with-redefs [app.config/flags #{:registration :login-with-password}] + (let [itoken (tokens/generate th/*system* + {:iss :team-invitation + :exp (ct/in-future "48h") + :role :editor + :team-id uuid/zero + :member-email "user@example.com"}) + prep-data {::th/type :prepare-register-profile + :invitation-token itoken + :fullname "foobar" + :email "user@example.com" + :password "foobar"} + + {prep-result :result prep-error :error} (th/command! prep-data)] + (t/is (nil? prep-error)) + (t/is (string? (:token prep-result))) + + (let [reg-data {::th/type :register-profile + :token (:token prep-result)} + + {reg-result :result reg-error :error} (th/command! reg-data) + mdata (meta reg-result)] + (t/is (nil? reg-error)) + (t/is (map? reg-result)) + + ;; Active branch: invitation-token is echoed back and a session + ;; is minted via `session/create-fn`. + (t/is (string? (:invitation-token reg-result))) + (t/is (seq (:app.rpc/response-transform-fns mdata))) + (t/is (= "accept-invitation" + (get-in mdata [:app.loggers.audit/context :action]))))))) (t/deftest prepare-and-register-with-invitation-and-enabled-registration-2 (let [itoken (tokens/generate th/*system* @@ -705,6 +762,188 @@ (t/is (= :validation (:type edata))) (t/is (= :email-as-password (:code edata)))))) +(t/deftest prepare-register-rejects-active-profile-email + ;; SECURITY: `prepare-register` must reject any attempt to prepare a + ;; registration for an email that already belongs to an *active* + ;; profile, regardless of whether an invitation token is supplied. + ;; Active profiles must use the standard login flow. + (let [_victim (th/create-profile* 1 {:is-active true + :email "victim@corp.tld"})] + + ;; Without invitation token. + (let [out (th/command! {::th/type :prepare-register-profile + :fullname "Mallory" + :email "victim@corp.tld" + :password "Whatever1!"})] + (t/is (not (th/success? out))) + (let [edata (-> out :error ex-data)] + (t/is (= :validation (:type edata))) + (t/is (= :email-already-exists (:code edata))))) + + ;; With invitation token (the GHSA-4937-35vc-hqjj exploit shape). + (let [itoken (tokens/generate th/*system* + {:iss :team-invitation + :exp (ct/in-future "48h") + :role :editor + :team-id uuid/zero + :member-email "victim@corp.tld"}) + out (th/command! {::th/type :prepare-register-profile + :invitation-token itoken + :fullname "Mallory" + :email "victim@corp.tld" + :password "Whatever1!"})] + (t/is (not (th/success? out))) + (let [edata (-> out :error ex-data)] + (t/is (= :validation (:type edata))) + (t/is (= :email-already-exists (:code edata))))))) + +(t/deftest prepare-register-must-not-leak-existing-profile-id + ;; Victim is a pre-existing profile that has not yet activated (e.g. + ;; freshly registered, has not clicked the email verification link). + ;; `prepare-register` allows the call (no active profile exists), but + ;; the issued JWE must NOT carry the existing profile's id. + (let [_victim (th/create-profile* 1 {:is-active false + :email "victim@corp.tld"}) + + ;; Attacker holds a cryptographically valid `:team-invitation` JWE + ;; for the victim's email. (In a real exploit this is obtained + ;; from `create-team-invitations` or `get-team-invitation-token` + ;; on a team the attacker owns.) + itoken (tokens/generate th/*system* + {:iss :team-invitation + :exp (ct/in-future "48h") + :role :editor + :team-id uuid/zero + :member-email "victim@corp.tld"}) + + ;; Anonymous request — no ::rpc/profile-id. + data {::th/type :prepare-register-profile + :invitation-token itoken + :fullname "Mallory" + :email "victim@corp.tld" + :password "Whatever1!"} + + out (th/command! data)] + + ;; The current behaviour either returns a token or rejects the request; + ;; what MUST hold is that the issued prepared-register JWE does not + ;; carry the victim's profile id. + (t/is (th/success? out)) + + (let [token (-> out :result :token) + claims (tokens/decode th/*system* token)] + (t/is (= :prepared-register (:iss claims))) + ;; This is the root-cause assertion: an anonymous prepare-register + ;; call must NEVER embed an existing profile's id. + (t/is (nil? (:profile-id claims)) + "prepare-register must not embed existing profile id of an anonymous caller")))) + +(t/deftest register-profile-with-invitation-must-not-take-over-existing-account + (with-mocks [_mock {:target 'app.email/send! :return nil}] + (let [;; Victim profile exists but is not yet active (e.g. registered + ;; but has not clicked the verification link). This is the + ;; remaining attack surface after fix 1b: `prepare-register` + ;; will not reject this case, so the `register-profile` path + ;; must enforce the security invariants on its own. + victim (th/create-profile* 1 {:is-active false + :email "victim@corp.tld"}) + + ;; Attacker mints a valid `:team-invitation` JWE for the victim's + ;; email. No member-id is included (matches what an attacker + ;; obtains via `create-team-invitations` against their own team + ;; before the victim has joined). + itoken (tokens/generate th/*system* + {:iss :team-invitation + :exp (ct/in-future "48h") + :role :editor + :team-id uuid/zero + :member-email "victim@corp.tld"}) + + ;; Step 1 (anonymous): prepare-register-profile with the victim's + ;; email + the invitation token. + prep-out (th/command! {::th/type :prepare-register-profile + :invitation-token itoken + :fullname "Mallory" + :email "victim@corp.tld" + :password "Whatever1!"}) + + rtoken (-> prep-out :result :token) + + ;; Step 2 (anonymous): register-profile with the prepared token. + reg-out (th/command! {::th/type :register-profile + :token rtoken}) + + result (:result reg-out) + mdata (meta result)] + + ;; The first call may succeed; the issue is what the second call + ;; produces. We assert the security invariants on its result. + (t/is (th/success? prep-out)) + + ;; INVARIANT 1: register-profile must NOT install a session for the + ;; victim. `session/create-fn` is wired via + ;; `rph/with-transform`, which appends to + ;; `:app.rpc/response-transform-fns`. If that vector is non-empty + ;; for an anonymous register that targets an EXISTING profile, the + ;; server is about to mint an `auth-token` cookie bound to the + ;; victim — i.e. account takeover. + (t/is (empty? (:app.rpc/response-transform-fns mdata)) + "register-profile must not create a session for an existing victim profile") + + ;; INVARIANT 2: register-profile must NOT echo back an invitation + ;; token that authenticates as the victim. When the response + ;; contains both `:id` matching the victim and `:invitation-token`, + ;; the frontend treats the user as logged-in for that profile. + (when (and (map? result) + (= (:id victim) (:id result))) + (t/is (not (contains? result :invitation-token)) + "register-profile must not return an invitation-token bound to an existing victim profile")) + + ;; INVARIANT 3: the server must NOT have taken the + ;; "accept-invitation" branch (which is the one that mints a + ;; session). For an existing victim profile, the operation + ;; should fall through to the harmless "repeated registry" path. + (t/is (not= "accept-invitation" + (get-in mdata [:app.loggers.audit/context :action])) + "register-profile must not run the accept-invitation branch for an existing victim profile") + ;; The victim must remain inactive: nothing in this anonymous + ;; flow should have flipped `is-active` to true. + (let [reloaded (th/db-get :profile {:id (:id victim)})] + (t/is (false? (:is-active reloaded)) + "register-profile must not activate the victim profile"))))) + +(t/deftest verify-email-with-invitation-token-propagates-it + ;; A `:verify-email` JWE that carries `:invitation-token` (as + ;; produced by `register-profile` for the not-active+invitation + ;; case) must propagate that token through the verify-token RPC + ;; result so the frontend can resume the team-invitation flow. + (let [profile (th/create-profile* 1 {:is-active false}) + itoken (tokens/generate th/*system* + {:iss :team-invitation + :exp (ct/in-future "48h") + :role :editor + :team-id uuid/zero + :member-email (:email profile)}) + vtoken (tokens/generate th/*system* + {:iss :verify-email + :exp (ct/in-future "72h") + :profile-id (:id profile) + :email (:email profile) + :invitation-token itoken}) + + out (th/command! {::th/type :verify-token + :token vtoken}) + result (:result out)] + + (t/is (th/success? out)) + (t/is (= :verify-email (:iss result))) + (t/is (= itoken (:invitation-token result)) + "verify-token must echo back the invitation-token from the verify-email JWE") + + ;; And the profile must now be active. + (let [reloaded (th/db-get :profile {:id (:id profile)})] + (t/is (true? (:is-active reloaded)))))) + (t/deftest email-change-request (with-mocks [mock {:target 'app.email/send! :return nil}] (let [profile (th/create-profile* 1) diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index daf09e72a7..66b412824b 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -11,6 +11,7 @@ [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] + [app.email.blacklist :as email.blacklist] [app.http :as http] [app.rpc :as-alias rpc] [app.storage :as sto] @@ -102,6 +103,46 @@ (t/is (= :validation (:type edata))) (t/is (= :member-is-muted (:code edata)))))))) +(t/deftest create-team-invitations-blacklisted-domain + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [profile1 (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id profile1)}) + data {::th/type :create-team-invitations + ::rpc/profile-id (:id profile1) + :team-id (:id team) + :role :editor}] + + ;; invite from a directly blacklisted domain should fail + (with-redefs [email.blacklist/enabled? (constantly true) + email.blacklist/contains? (fn [_ email] + (clojure.string/ends-with? email "@blacklisted.com"))] + (let [out (th/command! (assoc data :emails ["user@blacklisted.com"]))] + (t/is (not (th/success? out))) + (t/is (= 0 (:call-count @mock))) + (let [edata (-> out :error ex-data)] + (t/is (= :restriction (:type edata))) + (t/is (= :email-domain-is-not-allowed (:code edata)))))) + + ;; invite from a subdomain of a blacklisted domain should also fail + (th/reset-mock! mock) + (with-redefs [email.blacklist/enabled? (constantly true) + email.blacklist/contains? (fn [_ email] + (clojure.string/ends-with? email "@sub.blacklisted.com"))] + (let [out (th/command! (assoc data :emails ["user@sub.blacklisted.com"]))] + (t/is (not (th/success? out))) + (t/is (= 0 (:call-count @mock))) + (let [edata (-> out :error ex-data)] + (t/is (= :restriction (:type edata))) + (t/is (= :email-domain-is-not-allowed (:code edata)))))) + + ;; invite from a non-blacklisted domain should succeed + (th/reset-mock! mock) + (with-redefs [email.blacklist/enabled? (constantly true) + email.blacklist/contains? (constantly false)] + (let [out (th/command! (assoc data :emails ["user@allowed.com"]))] + (t/is (th/success? out)) + (t/is (= 1 (:call-count @mock)))))))) + (t/deftest create-team-invitations-with-request-access (with-mocks [mock {:target 'app.email/send! :return nil}] (let [profile1 (th/create-profile* 1 {:is-active true}) @@ -726,3 +767,82 @@ (t/is (th/success? (th/command! data))) (t/is (= 1 (:call-count @mock)))))) +(t/deftest create-team-with-invalid-name + (let [profile (th/create-profile* 1 {:is-active true})] + + ;; name with a dot should fail + (let [data {::th/type :create-team + ::rpc/profile-id (:id profile) + :name "foo.bar"} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation))) + + ;; name with a colon should fail + (let [data {::th/type :create-team + ::rpc/profile-id (:id profile) + :name "foo:bar"} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation))) + + ;; name with a slash should fail + (let [data {::th/type :create-team + ::rpc/profile-id (:id profile) + :name "foo/bar"} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation))) + + ;; valid name should succeed + (let [data {::th/type :create-team + ::rpc/profile-id (:id profile) + :name "My Valid Team"} + out (th/command! data)] + (t/is (th/success? out))))) + +(t/deftest update-team-with-invalid-name + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id profile)})] + + ;; name with a dot should fail + (let [data {::th/type :update-team + ::rpc/profile-id (:id profile) + :id (:id team) + :name "foo.bar"} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation))) + + ;; name with a colon should fail + (let [data {::th/type :update-team + ::rpc/profile-id (:id profile) + :id (:id team) + :name "foo:bar"} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation))) + + ;; name with a slash should fail + (let [data {::th/type :update-team + ::rpc/profile-id (:id profile) + :id (:id team) + :name "foo/bar"} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation))) + + ;; valid name should succeed + (let [data {::th/type :update-team + ::rpc/profile-id (:id profile) + :id (:id team) + :name "My Valid Team"} + out (th/command! data)] + (t/is (th/success? out))))) + diff --git a/backend/test/backend_tests/rpc_viewer_test.clj b/backend/test/backend_tests/rpc_viewer_test.clj index 6c68c12e34..1e69ed87af 100644 --- a/backend/test/backend_tests/rpc_viewer_test.clj +++ b/backend/test/backend_tests/rpc_viewer_test.clj @@ -9,6 +9,7 @@ [app.common.uuid :as uuid] [app.db :as db] [app.rpc :as-alias rpc] + [app.rpc.commands.viewer :as viewer] [backend-tests.helpers :as th] [clojure.test :as t] [datoteka.fs :as fs])) @@ -16,6 +17,28 @@ (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) +(t/deftest obfuscate-email-happy-path + (t/is (= "a****@****.com" (viewer/obfuscate-email "alice@example.com"))) + (t/is (= "a****@****.example.com" (viewer/obfuscate-email "alice@sub.example.com"))) + (t/is (= "****@****.com" (viewer/obfuscate-email "bob@bar.com")))) + +(t/deftest obfuscate-email-handles-domain-without-dot + ;; `localhost`-style domains have no `.`; the previous implementation produced + ;; a dangling-dot output like "a****@****." — now the trailing `.` is only + ;; emitted when there actually is a TLD segment to append. + (t/is (= "a****@****" (viewer/obfuscate-email "alice@localhost"))) + (t/is (= "****@****" (viewer/obfuscate-email "x@y")))) + +(t/deftest obfuscate-email-handles-malformed-input + ;; These shapes must not throw — `obfuscate-email` runs while building the + ;; view-only bundle for share-link viewers and an NPE here aborts the whole + ;; RPC response. The previous implementation called `clojure.string/split` + ;; on `nil` for the `no-@` case, raising NullPointerException. + (t/is (= "****@****" (viewer/obfuscate-email nil))) + (t/is (= "****@****" (viewer/obfuscate-email ""))) + (t/is (= "r***@****" (viewer/obfuscate-email "root"))) ; no `@`, count > 3 + (t/is (= "****@****" (viewer/obfuscate-email "bob")))) ; no `@`, count <= 3 + (t/deftest retrieve-bundle (let [prof (th/create-profile* 1 {:is-active true}) prof2 (th/create-profile* 2 {:is-active true}) diff --git a/backend/test/backend_tests/rpc_webhooks_test.clj b/backend/test/backend_tests/rpc_webhooks_test.clj index bc1da4c64f..703c9b0643 100644 --- a/backend/test/backend_tests/rpc_webhooks_test.clj +++ b/backend/test/backend_tests/rpc_webhooks_test.clj @@ -37,7 +37,7 @@ (t/is (contains? result :mtype)))) (t/deftest webhook-crud - (with-mocks [http-mock {:target 'app.http.client/req! + (with-mocks [http-mock {:target 'app.http.client/req :return {:status 200}}] (let [prof (th/create-profile* 1 {:is-active true}) @@ -151,7 +151,7 @@ (t/is (= (:code error-data) :object-not-found)))))))) (t/deftest webhooks-permissions-crud-viewer-only - (with-mocks [http-mock {:target 'app.http.client/req! + (with-mocks [http-mock {:target 'app.http.client/req :return {:status 200}}] (let [owner (th/create-profile* 1 {:is-active true}) viewer (th/create-profile* 2 {:is-active true}) @@ -214,7 +214,7 @@ (th/reset-mock! http-mock)))) (t/deftest webhooks-permissions-crud-viewer-owner - (with-mocks [http-mock {:target 'app.http.client/req! + (with-mocks [http-mock {:target 'app.http.client/req :return {:status 200}}] (let [owner (th/create-profile* 1 {:is-active true}) viewer (th/create-profile* 2 {:is-active true}) @@ -269,7 +269,7 @@ (t/is (= (:code error-data) :object-not-found))))))) (t/deftest webhooks-quotes - (with-mocks [http-mock {:target 'app.http.client/req! + (with-mocks [http-mock {:target 'app.http.client/req :return {:status 200}}] (let [prof (th/create-profile* 1 {:is-active true}) diff --git a/backend/test/backend_tests/tasks_telemetry_test.clj b/backend/test/backend_tests/tasks_telemetry_test.clj index c6edf381af..c010a95b72 100644 --- a/backend/test/backend_tests/tasks_telemetry_test.clj +++ b/backend/test/backend_tests/tasks_telemetry_test.clj @@ -6,42 +6,905 @@ (ns backend-tests.tasks-telemetry-test (:require + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] [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.pprint :refer [pprint]] [clojure.test :as t] - [mockery.core :refer [with-mocks]])) + [mockery.core :refer [with-mocks]] + [promesa.exec :as px])) (t/use-fixtures :once th/state-init) -(t/use-fixtures :each th/database-reset) + +;; Mock px/sleep for all tests to avoid 10s random delays. +;; Composed with database-reset so both apply. +(defn- test-fixture [next] + (th/database-reset + (fn [] + (with-redefs [px/sleep (constantly nil)] + (next))))) + +(t/use-fixtures :each test-fixture) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HELPERS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- insert-telemetry-row! + "Insert a single anonymised audit_log row as the telemetry mode does." + ([name] (insert-telemetry-row! name {})) + ([name {:keys [tracked-at created-at source] + :or {tracked-at (ct/now) + created-at (ct/now) + source "telemetry:backend"}}] + (th/db-insert! :audit-log + {:id (uuid/next) + :name name + :type "action" + :source source + :profile-id uuid/zero + :ip-addr (db/inet "0.0.0.0") + :props (db/tjson {}) + :context (db/tjson {}) + :tracked-at tracked-at + :created-at created-at}))) + +(defn- count-telemetry-rows [] + (-> (th/db-exec-one! ["SELECT count(*) AS cnt FROM audit_log WHERE source IN ('telemetry:backend', 'telemetry:frontend')"]) + :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) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (t/deftest test-base-report-data-structure - (with-mocks [mock {:target 'app.tasks.telemetry/send! + (with-mocks [mock {:target 'app.tasks.telemetry/make-legacy-request :return nil}] (let [prof (th/create-profile* 1 {:is-active true - :props {:newsletter-news true}})] + :props {:newsletter-updates true}})] (th/run-task! :telemetry {:send? true :enabled? true}) (t/is (:called? @mock)) (let [[_ data] (-> @mock :call-args)] + (t/is (= :telemetry-legacy-report (:type data))) (t/is (contains? data :subscriptions)) - (t/is (= [(:email prof)] (get-in data [:subscriptions :newsletter-news]))) - (t/is (contains? data :total-fonts)) - (t/is (contains? data :total-users)) - (t/is (contains? data :total-projects)) - (t/is (contains? data :total-files)) - (t/is (contains? data :total-teams)) - (t/is (contains? data :total-comments)) - (t/is (contains? data :instance-id)) - (t/is (contains? data :jvm-cpus)) - (t/is (contains? data :jvm-heap-max)) - (t/is (contains? data :max-users-on-team)) - (t/is (contains? data :avg-users-on-team)) - (t/is (contains? data :max-files-on-project)) - (t/is (contains? data :avg-files-on-project)) - (t/is (contains? data :max-projects-on-team)) - (t/is (contains? data :avg-files-on-project)) + (t/is (= [(:email prof)] (:subscriptions data))) + (t/is (contains? data :stats)) + (let [stats (:stats data)] + (t/is (contains? stats :total-fonts)) + (t/is (contains? stats :total-users)) + (t/is (contains? stats :total-projects)) + (t/is (contains? stats :total-files)) + (t/is (contains? stats :total-teams)) + (t/is (contains? stats :total-comments)) + (t/is (contains? stats :jvm-cpus)) + (t/is (contains? stats :jvm-heap-max)) + (t/is (contains? stats :max-users-on-team)) + (t/is (contains? stats :avg-users-on-team)) + (t/is (contains? stats :max-files-on-project)) + (t/is (contains? stats :avg-files-on-project)) + (t/is (contains? stats :max-projects-on-team)) + (t/is (contains? stats :avg-files-on-project)) + (t/is (contains? stats :email-domains)) + (t/is (= ["nodomain.com"] (:email-domains stats))) + ;; public-uri must be a string + (t/is (string? (:public-uri stats))) + (t/is (not-empty (:public-uri stats)))) (t/is (contains? data :version)) - (t/is (contains? data :email-domains)) - (t/is (= ["nodomain.com"] (:email-domains data))))))) + (t/is (contains? data :instance-id)))))) + +(t/deftest test-telemetry-disabled-no-send + ;; When telemetry is disabled and no newsletter subscriptions exist, + ;; make-legacy-request must not be called at all. + (with-mocks [mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil}] + (with-redefs [cf/flags #{}] + (th/create-profile* 1 {:is-active true}) + (th/run-task! :telemetry {:send? true}) + (t/is (not (:called? @mock)))))) + +(t/deftest test-telemetry-disabled-newsletter-only-send + ;; When telemetry is disabled but a user has newsletter-updates opted in, + ;; make-legacy-request is called once with only subscriptions + version (no stats). + (with-mocks [mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil}] + (with-redefs [cf/flags #{}] + (let [prof (th/create-profile* 1 {:is-active true + :props {:newsletter-updates true}})] + (th/run-task! :telemetry {:send? true}) + (t/is (:called? @mock)) + (let [[_ data] (:call-args @mock)] + ;; Limited payload — no stats + (t/is (contains? data :subscriptions)) + (t/is (contains? data :version)) + (t/is (not (contains? data :stats))) + (t/is (= [(:email prof)] (:subscriptions data)))))))) + +(t/deftest test-send-is-skipped-when-send?-false + ;; Passing send?=false must suppress all HTTP calls even when enabled. + (with-mocks [mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil}] + (with-redefs [cf/flags #{:telemetry}] + (th/create-profile* 1 {:is-active true}) + (th/run-task! :telemetry {:send? false :enabled? true}) + (t/is (not (:called? @mock)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; AUDIT-EVENT BATCH COLLECTION TESTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest test-no-audit-events-no-batch-call + ;; When telemetry is enabled but there are no audit_log rows with + ;; source='telemetry', the batch send path must not be invoked. + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil} + batch-mock {:target 'app.tasks.telemetry/send-event-batch + :return true}] + (with-redefs [cf/flags #{:telemetry}] + (th/run-task! :telemetry {:send? true :enabled? true}) + (t/is (:called? @legacy-mock)) + (t/is (not (:called? @batch-mock)))))) + +(t/deftest test-audit-events-sent-and-deleted-on-success + ;; Happy path: telemetry rows are collected, shipped as a batch and + ;; deleted from the table when the endpoint returns success. + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil} + batch-mock {:target 'app.tasks.telemetry/send-event-batch + :return true}] + (with-redefs [cf/flags #{:telemetry}] + (insert-telemetry-row! "navigate") + (insert-telemetry-row! "create-file") + (insert-telemetry-row! "update-file") + + (t/is (= 3 (count-telemetry-rows))) + + (th/run-task! :telemetry {:send? true :enabled? true}) + + ;; batch send was called at least once + (t/is (:called? @batch-mock)) + + ;; all rows deleted after successful send + (t/is (= 0 (count-telemetry-rows)))))) + +(t/deftest test-audit-events-kept-on-batch-failure + ;; When the batch endpoint returns failure the rows must be retained + ;; so the next scheduled run can retry. + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil} + batch-mock {:target 'app.tasks.telemetry/send-event-batch + :return false}] + (with-redefs [cf/flags #{:telemetry}] + (insert-telemetry-row! "navigate") + (insert-telemetry-row! "create-file") + + (th/run-task! :telemetry {:send? true :enabled? true}) + + (t/is (:called? @batch-mock)) + ;; rows still present — not deleted on failure + (t/is (= 2 (count-telemetry-rows)))))) + +(t/deftest test-audit-events-not-collected-when-audit-log-flag-set + ;; When the :audit-log flag is active, mode C is disabled and the + ;; batch path must never run (audit-log owns those rows instead). + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil} + batch-mock {:target 'app.tasks.telemetry/send-event-batch + :return true}] + (with-redefs [cf/flags #{:telemetry :audit-log}] + (insert-telemetry-row! "navigate") + + (th/run-task! :telemetry {:send? true :enabled? true}) + + (t/is (not (:called? @batch-mock))) + ;; row untouched + (t/is (= 1 (count-telemetry-rows)))))) + +(t/deftest test-batch-payload-contains-required-fields + ;; Inspect the actual arguments forwarded to send-event-batch to + ;; verify the payload carries instance-id, version and events. + (let [captured (atom nil)] + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil}] + (with-redefs [cf/flags #{:telemetry} + telemetry/send-event-batch + (fn [_cfg batch] + (reset! captured batch) + true)] + (insert-telemetry-row! "navigate") + (insert-telemetry-row! "create-file") + + (th/run-task! :telemetry {:send? true :enabled? true}) + + (t/is (some? @captured)) + (let [batch @captured] + ;; batch is a seq of event maps + (t/is (seq batch)) + (t/is (= 2 (count batch))) + ;; each event has name, type, source — profile-id is preserved, + ;; props and ip-addr are stripped + (let [ev (first batch)] + (t/is (contains? ev :name)) + (t/is (contains? ev :type)) + (t/is (contains? ev :source)) + (t/is (contains? ev :profile-id)) + ;; props are present but empty (stripped at ingest time) + (t/is (= {} (:props ev))) + (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. + (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)))))) + +(t/deftest test-multiple-batches-when-many-events + ;; Lower batch-size to 1 so that 3 events produce 3 separate + ;; HTTP requests and verify all are sent and all rows deleted. + (let [call-count (atom 0)] + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil}] + (with-redefs [cf/flags #{:telemetry} + telemetry/batch-size 1 + telemetry/send-event-batch + (fn [_cfg _batch] + (swap! call-count inc) + true)] + (insert-telemetry-row! "navigate") + (insert-telemetry-row! "create-file") + (insert-telemetry-row! "update-file") + + (th/run-task! :telemetry {:send? true :enabled? true}) + + ;; Each event is fetched and sent in its own loop iteration + (t/is (= 3 @call-count)) + ;; All rows deleted after all iterations succeed + (t/is (= 0 (count-telemetry-rows))))))) + +(t/deftest test-partial-failure-stops-remaining-batches + ;; With batch-size 1, when the second send fails the loop stops. + ;; The first batch was already deleted; the two remaining rows + ;; are retained for the next run. + (let [call-count (atom 0)] + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil}] + (with-redefs [cf/flags #{:telemetry} + telemetry/batch-size 1 + telemetry/send-event-batch + (fn [_cfg _batch] + (swap! call-count inc) + ;; fail on the second call + (not= 2 @call-count))] + (insert-telemetry-row! "navigate") + (insert-telemetry-row! "create-file") + (insert-telemetry-row! "update-file") + + (th/run-task! :telemetry {:send? true :enabled? true}) + + ;; Stopped at iteration 2 — third event never attempted + (t/is (= 2 @call-count)) + ;; First batch was deleted on success; 2 rows remain for retry + (t/is (= 2 (count-telemetry-rows))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; GC / RETENTION-WINDOW TESTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest test-gc-purges-events-older-than-7-days + ;; Insert events from 8 days ago (stale) and from today (fresh). + ;; After the task runs, stale events must be purged by GC and fresh + ;; ones shipped by the batch sender. + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil} + batch-mock {:target 'app.tasks.telemetry/send-event-batch + :return true}] + (with-redefs [cf/flags #{:telemetry}] + (let [now (ct/now) + eight-days (ct/minus now (ct/duration {:days 8}))] + ;; Stale events (older than 7 days) + (insert-telemetry-row! "stale-1" {:created-at eight-days :tracked-at eight-days}) + (insert-telemetry-row! "stale-2" {:created-at eight-days :tracked-at eight-days}) + ;; Fresh events (today) + (insert-telemetry-row! "fresh-1" {:created-at now :tracked-at now}) + (insert-telemetry-row! "fresh-2" {:created-at now :tracked-at now}) + + (t/is (= 4 (count-telemetry-rows))) + + (th/run-task! :telemetry {:send? true :enabled? true}) + + ;; GC purged the 2 stale rows, batch sender shipped the 2 fresh ones + (t/is (= 0 (count-telemetry-rows))))))) + +(t/deftest test-gc-keeps-events-within-7-day-window + ;; When all events are within the 7-day window, GC must not delete + ;; anything and all rows are forwarded to the batch sender. + (let [batch-events (atom nil)] + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil}] + (with-redefs [cf/flags #{:telemetry} + telemetry/send-event-batch + (fn [_cfg batch] + (reset! batch-events batch) + true)] + (let [six-days-ago (ct/minus (ct/now) (ct/duration {:days 6}))] + (insert-telemetry-row! "recent-1" {:created-at six-days-ago :tracked-at six-days-ago}) + (insert-telemetry-row! "recent-2" {:created-at six-days-ago :tracked-at six-days-ago})) + + (th/run-task! :telemetry {:send? true :enabled? true}) + + ;; Both events forwarded — GC left them alone + (t/is (= 2 (count @batch-events))) + (t/is (= 0 (count-telemetry-rows))))))) + +(t/deftest test-gc-deletes-only-stale-events + ;; Insert a mix of stale (8 days old) and fresh (1 day old) events. + ;; After GC, only fresh events should remain for the batch sender. + (let [batch-events (atom nil)] + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil}] + (with-redefs [cf/flags #{:telemetry} + telemetry/send-event-batch + (fn [_cfg batch] + (reset! batch-events batch) + true)] + (let [eight-days (ct/minus (ct/now) (ct/duration {:days 8})) + one-day (ct/minus (ct/now) (ct/duration {:days 1}))] + (insert-telemetry-row! "stale" {:created-at eight-days :tracked-at eight-days}) + (insert-telemetry-row! "fresh" {:created-at one-day :tracked-at one-day})) + + (t/is (= 2 (count-telemetry-rows))) + + (th/run-task! :telemetry {:send? true :enabled? true}) + + ;; GC purged stale, batch shipped fresh + (t/is (= 1 (count @batch-events))) + (t/is (= "fresh" (:name (first @batch-events)))) + (t/is (= 0 (count-telemetry-rows))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; ANONYMITY TESTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest test-telemetry-rows-stored-without-pii + ;; Rows written to audit_log in telemetry mode must carry no PII: + ;; empty props, zeroed ip, profile-id=zero, source='telemetry'. + ;; Safe context fields (browser, os, version, etc.) are preserved + ;; but session-linking and access-token fields are stripped. + (with-redefs [cf/flags #{:telemetry}] + (let [_prof (th/create-profile* 1 {:is-active true}) + safe-ctx {:browser "Chrome" + :browser-version "120.0" + :os "Linux" + :version "2.0.0"}] + ;; Simulate what app.loggers.audit/process-event does in mode C + (th/db-insert! :audit-log + {:id (uuid/next) + :name "create-project" + :type "action" + :source "telemetry:backend" + :profile-id uuid/zero + :ip-addr (db/inet "0.0.0.0") + :props (db/tjson {}) + :context (db/tjson safe-ctx) + :tracked-at (ct/now) + :created-at (ct/now)}) + + (let [[row] (th/db-exec! ["SELECT * FROM audit_log WHERE source = 'telemetry:backend'"])] + (t/is (= "telemetry:backend" (:source row))) + ;; props are always empty + (t/is (= "{}" (str (:props row)))) + ;; ip_addr is the sentinel zero address + (t/is (= "0.0.0.0" (str (:ip-addr row)))) + ;; profile-id is uuid/zero — not a real user id + (t/is (= uuid/zero (:profile-id row))))))) + +(t/deftest test-batch-events-contain-no-pii-fields + ;; The event maps forwarded to send-event-batch must not carry props, + ;; ip-addr or profile-id. Safe context fields (browser, os, etc.) may + ;; be present but session-linking keys must be absent. + (let [captured-batch (atom nil) + ;; Insert a row that carries safe context (as the real path does) + safe-ctx {:browser "Firefox" :browser-version "121.0" + :os "macOS" :session "should-be-stripped" + :external-session-id "also-stripped"}] + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil}] + (with-redefs [cf/flags #{:telemetry} + telemetry/send-event-batch + (fn [_cfg batch] + (reset! captured-batch batch) + true)] + ;; Insert with safe context already pre-filtered (as the ingest path does) + (th/db-insert! :audit-log + {:id (uuid/next) + :name "navigate" + :type "action" + :source "telemetry:frontend" + :profile-id uuid/zero + :ip-addr (db/inet "0.0.0.0") + :props (db/tjson {}) + :context (db/tjson (dissoc safe-ctx :session :external-session-id)) + :tracked-at (ct/now) + :created-at (ct/now)}) + + (th/run-task! :telemetry {:send? true :enabled? true}) + + (t/is (= 1 (count @captured-batch))) + (let [ev (first @captured-batch)] + ;; must have the core identity fields including profile-id + (t/is (contains? ev :name)) + (t/is (contains? ev :type)) + (t/is (contains? ev :source)) + (t/is (contains? ev :tracked-at)) + (t/is (contains? ev :profile-id)) + ;; props are present but empty (stripped at ingest time) + (t/is (= {} (:props ev))) + ;; ip-addr is stripped + (t/is (not (contains? ev :ip-addr))) + ;; context may be present and must not contain session-linking keys + (when-let [ctx (:context ev)] + (t/is (not (contains? ctx :session))) + (t/is (not (contains? ctx :external-session-id))) + ;; safe keys should be present + (t/is (contains? ctx :browser)))))))) + +(t/deftest test-telemetry-rows-have-day-precision-timestamps + ;; Telemetry events must be stored with timestamps truncated to day + ;; precision so that exact event timing cannot be inferred. + (with-redefs [cf/flags #{:telemetry}] + (let [process-event (ns-resolve 'app.loggers.audit 'process-event) + profile (th/create-profile* 1 {:is-active true}) + tnow (ct/now) + event {:type "action" + :name "create-project" + :profile-id (:id profile) + :source "backend" + :props {} + :context {} + :created-at tnow + :tracked-at tnow + :ip-addr "0.0.0.0"}] + (db/tx-run! th/*system* process-event event) + (let [[row] (th/db-exec! ["SELECT * FROM audit_log WHERE source = 'telemetry:backend'"])] + (t/is (some? row)) + (let [created-at (:created-at row) + tracked-at (:tracked-at row) + day-now (ct/truncate (ct/now) :days)] + ;; Both timestamps must equal midnight of the current day + (t/is (= day-now created-at)) + (t/is (= day-now tracked-at))))))) + +(t/deftest test-backend-ingest-full-row-shape + ;; Verify the full row shape stored by process-event in telemetry mode: + ;; source=telemetry:backend, empty props, zeroed ip, context filtered to safe + ;; backend keys only, profile-id preserved, timestamps truncated. + (with-redefs [cf/flags #{:telemetry}] + (let [process-event (ns-resolve 'app.loggers.audit 'process-event) + profile (th/create-profile* 1 {:is-active true}) + tnow (ct/now) + event {:type "action" + :name "create-project" + :profile-id (:id profile) + :source "backend" + :context {:initiator "app" + :version "2.0.0" + :client-version "1.0" + :client-user-agent "Mozilla/5.0" + :external-session-id "should-be-stripped" + :session "also-stripped"} + :props {:some-prop "value"} + :created-at tnow + :tracked-at tnow + :ip-addr "0.0.0.0"}] + (db/tx-run! th/*system* process-event event) + + (let [[row] (th/db-exec! ["SELECT * FROM audit_log WHERE source = 'telemetry:backend'"])] + (t/is (some? row)) + ;; source + (t/is (= "telemetry:backend" (:source row))) + ;; profile-id preserved + (t/is (= (:id profile) (:profile-id row))) + ;; name + (t/is (= "create-project" (:name row))) + ;; type + (t/is (= "action" (:type row))) + ;; props stripped to empty + (t/is (= "{}" (str (:props row)))) + ;; ip zeroed + (t/is (= "0.0.0.0" (str (:ip-addr row)))) + ;; timestamps truncated to day + (let [day-now (ct/truncate (ct/now) :days)] + (t/is (= day-now (:created-at row))) + (t/is (= day-now (:tracked-at row)))) + ;; context filtered: only safe backend keys retained + (let [ctx (db/decode-transit-pgobject (:context row))] + (t/is (= "app" (:initiator ctx))) + (t/is (= "2.0.0" (:version ctx))) + (t/is (= "1.0" (:client-version ctx))) + (t/is (= "Mozilla/5.0" (:client-user-agent ctx))) + ;; session-linking keys stripped + (t/is (not (contains? ctx :external-session-id))) + (t/is (not (contains? ctx :session)))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; FILTER-TELEMETRY-CONTEXT UNIT TESTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest test-filter-telemetry-context-keeps-browser-fields + ;; Safe environment fields must survive the filter. + (let [filter-telemetry-context (ns-resolve 'app.loggers.audit 'filter-telemetry-context) + ctx {:browser "Chrome" + :browser-version "120.0" + :engine "Blink" + :engine-version "120.0" + :os "Windows 11" + :os-version "11" + :device-type "unknown" + :device-arch "amd64" + :locale "en-US" + :version "2.0.0" + :screen-width 1920 + :screen-height 1080 + :event-origin "workspace"} + result (:context (filter-telemetry-context {:source "frontend" :context ctx}))] + (t/is (= "Chrome" (:browser result))) + (t/is (= "120.0" (:browser-version result))) + (t/is (= "Windows 11" (:os result))) + (t/is (= "en-US" (:locale result))) + (t/is (= "workspace" (:event-origin result))) + (t/is (= 1920 (:screen-width result))))) + +(t/deftest test-filter-telemetry-context-strips-pii-keys + ;; Session-linking and access-token fields must be removed. + (let [filter-telemetry-context (ns-resolve 'app.loggers.audit 'filter-telemetry-context) + ctx {:browser "Firefox" + :session "abc-session-id" + :external-session-id "ext-123" + :file-stats {:total-shapes 42} + :initiator "app" + :access-token-id "tok-456" + :access-token-type "api-key"} + result (:context (filter-telemetry-context {:source "frontend" :context ctx}))] + (t/is (= "Firefox" (:browser result))) + (t/is (not (contains? result :session))) + (t/is (not (contains? result :external-session-id))) + (t/is (not (contains? result :file-stats))) + (t/is (not (contains? result :initiator))) + (t/is (not (contains? result :access-token-id))) + (t/is (not (contains? result :access-token-type))))) + +(t/deftest test-filter-telemetry-context-empty-input + ;; An empty context should return an empty map without error. + (let [filter-telemetry-context (ns-resolve 'app.loggers.audit 'filter-telemetry-context)] + (t/is (= {} (:context (filter-telemetry-context {:source "frontend" :context {}})))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; FILTER-TELEMETRY-PROPS UNIT TESTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest test-filter-telemetry-props-login-event-keeps-safe-profile-fields + ;; Login/register/update events carry safe profile-derived fields: + ;; :lang, :auth-backend, :email-domain. Raw :email is stripped. + (let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props)] + ;; backend login-with-password + (let [result (ftp {:source "backend" + :name "login-with-password" + :type "action" + :props {:email "user@example.com" + :fullname "John Doe" + :lang "en" + :auth-backend "password" + :id (uuid/next)}})] + (t/is (= "en" (get-in result [:props :lang]))) + (t/is (= "password" (get-in result [:props :auth-backend]))) + (t/is (= "example.com" (get-in result [:props :email-domain]))) + ;; Raw email and fullname are stripped + (t/is (not (contains? (:props result) :email))) + (t/is (not (contains? (:props result) :fullname))) + ;; UUID values survive the xf:filter-telemetry-props filter + (t/is (some? (get-in result [:props :id])))) + + ;; backend register-profile + (let [result (ftp {:source "backend" + :name "register-profile" + :type "action" + :props {:email "new@corp.org" + :lang "es" + :auth-backend "oidc"}})] + (t/is (= "es" (get-in result [:props :lang]))) + (t/is (= "oidc" (get-in result [:props :auth-backend]))) + (t/is (= "corp.org" (get-in result [:props :email-domain])))) + + ;; backend login-with-oidc + (let [result (ftp {:source "backend" + :name "login-with-oidc" + :type "action" + :props {:email "u@corp.io" :lang "fr" :auth-backend "oidc"}})] + (t/is (= "fr" (get-in result [:props :lang]))) + (t/is (= "oidc" (get-in result [:props :auth-backend]))) + (t/is (= "corp.io" (get-in result [:props :email-domain])))) + + ;; backend update-profile + (let [result (ftp {:source "backend" + :name "update-profile" + :type "action" + :props {:email "u@corp.io" :lang "de"}})] + (t/is (= "de" (get-in result [:props :lang]))) + (t/is (= "corp.io" (get-in result [:props :email-domain])))))) + +(t/deftest test-filter-telemetry-props-frontend-identify-keeps-safe-profile-fields + ;; Frontend identify events also carry safe profile-derived fields. + (let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props)] + (let [result (ftp {:source "frontend" + :name "signin" + :type "identify" + :props {:email "user@example.com" + :fullname "Jane Doe" + :lang "pt" + :auth-backend "password" + :some-string "should-be-stripped"}})] + (t/is (= "pt" (get-in result [:props :lang]))) + (t/is (= "password" (get-in result [:props :auth-backend]))) + (t/is (= "example.com" (get-in result [:props :email-domain]))) + ;; PII stripped + (t/is (not (contains? (:props result) :email))) + (t/is (not (contains? (:props result) :fullname))) + ;; String values that are not UUID/boolean/number are stripped + (t/is (not (contains? (:props result) :some-string)))))) + +(t/deftest test-filter-telemetry-props-instance-start-passthrough + ;; instance-start trigger events pass through as-is. + (let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props) + props {:total-teams 5 :total-users 42 :version "2.0"} + result (ftp {:source "backend" + :name "instance-start" + :type "trigger" + :props props})] + (t/is (= props (:props result))))) + +(t/deftest test-filter-telemetry-props-generic-event-keeps-uuid-boolean-number + ;; Generic events (create-file, etc.) keep only entries + ;; whose values are UUIDs, booleans, or numbers. + (let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props) + id (uuid/next) + result (ftp {:source "frontend" + :name "create-file" + :type "action" + :props {:project-id id + :team-id id + :route "dashboard-files" + :count 42 + :active true + :label "should-be-stripped"}})] + ;; UUIDs survive + (t/is (= id (get-in result [:props :project-id]))) + (t/is (= id (get-in result [:props :team-id]))) + ;; Numbers survive + (t/is (= 42 (get-in result [:props :count]))) + ;; Booleans survive + (t/is (true? (get-in result [:props :active]))) + ;; Strings are stripped + (t/is (not (contains? (:props result) :route))) + (t/is (not (contains? (:props result) :label))))) + +(t/deftest test-filter-telemetry-props-navigate-keeps-route-and-ids + ;; Frontend navigate events keep specific routing keys: :route, + ;; :file-id, :team-id, :page-id. These ids are strings because + ;; routing events don't coerce them. All other props are stripped. + (let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props) + file-id (str (uuid/next)) + team-id (str (uuid/next)) + page-id (str (uuid/next)) + result (ftp {:source "frontend" + :name "navigate" + :type "action" + :props {:file-id file-id + :team-id team-id + :page-id page-id + :route "dashboard-index" + :session "abc" + :count 42 + :active true + :label "should-be-stripped"}})] + ;; Allowed routing keys survive (as strings, not coerced to UUID) + (t/is (= file-id (get-in result [:props :file-id]))) + (t/is (= team-id (get-in result [:props :team-id]))) + (t/is (= page-id (get-in result [:props :page-id]))) + (t/is (= "dashboard-index" (get-in result [:props :route]))) + ;; Everything else is stripped + (t/is (not (contains? (:props result) :session))) + (t/is (not (contains? (:props result) :count))) + (t/is (not (contains? (:props result) :active))) + (t/is (not (contains? (:props result) :label))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; SEND-EVENT-BATCH PAYLOAD STRUCTURE +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest test-send-event-batch-payload-structure + ;; Verify the HTTP request sent by send-event-batch carries the + ;; correct outer wrapper: :type, :version, :instance-id, :events. + (let [captured-request (atom nil)] + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil} + http-mock {:target 'app.http.client/req + :return {:status 200}}] + (with-redefs [cf/flags #{:telemetry}] + (insert-telemetry-row! "navigate") + (insert-telemetry-row! "create-file") + + (th/run-task! :telemetry {:send? true :enabled? true}) + + ;; http/req was called (by both send-legacy-data and send-event-batch) + (t/is (:called? @http-mock)) + ;; Find the call whose body contains :telemetry-events + (let [calls (filter (fn [args] + (let [[_ request] args + body (:body request)] + (and (string? body) + (re-find #"telemetry-events" body)))) + (:call-args-list @http-mock))] + (t/is (= 1 (count calls))) + (let [[_ request] (first calls) + body (json/decode (:body request))] + ;; Outer payload fields + (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)))))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; TASK BRANCH COVERAGE +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest test-enabled-no-subs-no-events-legacy-still-sends + ;; When telemetry is enabled, there are no newsletter subscriptions + ;; and no audit_log rows, the legacy report must still be sent. + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil} + batch-mock {:target 'app.tasks.telemetry/send-event-batch + :return true}] + (with-redefs [cf/flags #{:telemetry}] + ;; No profiles with newsletter-updates, no telemetry rows + (th/run-task! :telemetry {:send? true :enabled? true}) + + ;; Legacy report was sent + (t/is (:called? @legacy-mock)) + (let [[_ data] (:call-args @legacy-mock)] + (t/is (= :telemetry-legacy-report (:type data))) + (t/is (contains? data :stats)) + ;; No subscriptions in the payload + (t/is (not (contains? data :subscriptions)))) + + ;; No events to batch-send + (t/is (not (:called? @batch-mock)))))) + +(t/deftest test-legacy-succeeds-batch-fails + ;; The legacy report and event batch are independent paths. + ;; When the batch endpoint fails, the legacy report must still + ;; have been sent successfully. + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil} + batch-mock {:target 'app.tasks.telemetry/send-event-batch + :return false}] + (with-redefs [cf/flags #{:telemetry}] + (insert-telemetry-row! "navigate") + + (th/run-task! :telemetry {:send? true :enabled? true}) + + ;; Legacy report was sent + (t/is (:called? @legacy-mock)) + (let [[_ data] (:call-args @legacy-mock)] + (t/is (= :telemetry-legacy-report (:type data)))) + + ;; Batch send was attempted but failed + (t/is (:called? @batch-mock)) + ;; Row still present (not deleted on failure) + (t/is (= 1 (count-telemetry-rows)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; GC + BATCH FAILURE INTERACTION +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest test-gc-runs-even-when-batch-fails + ;; GC must purge stale events regardless of whether the subsequent + ;; batch send succeeds or fails. + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil} + batch-mock {:target 'app.tasks.telemetry/send-event-batch + :return false}] + (with-redefs [cf/flags #{:telemetry}] + (let [eight-days (ct/minus (ct/now) (ct/duration {:days 8})) + one-day (ct/minus (ct/now) (ct/duration {:days 1}))] + ;; Stale events (should be GC'd) + (insert-telemetry-row! "stale-1" {:created-at eight-days :tracked-at eight-days}) + (insert-telemetry-row! "stale-2" {:created-at eight-days :tracked-at eight-days}) + ;; Fresh event (should survive GC but fail to send) + (insert-telemetry-row! "fresh" {:created-at one-day :tracked-at one-day}) + + (t/is (= 3 (count-telemetry-rows))) + + (th/run-task! :telemetry {:send? true :enabled? true}) + + ;; Batch send was attempted (and failed) + (t/is (:called? @batch-mock)) + ;; Stale rows were purged by GC, fresh row remains + (t/is (= 1 (count-telemetry-rows))) + (t/is (= "fresh" (:name (first (th/db-exec! ["SELECT name FROM audit_log WHERE source LIKE 'telemetry:%'"]))))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; ROW->EVENT CONTEXT GUARANTEE +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest test-row->event-always-includes-context + ;; row->event must always include :context as a map, even when the + ;; DB column contains an empty transit object. + (let [row->event (ns-resolve 'app.tasks.telemetry 'row->event)] + ;; With non-empty context + (let [ev (row->event {:name "test" :type "action" :source "telemetry:backend" + :tracked-at (ct/now) :profile-id uuid/zero + :context (db/tjson {:browser "Chrome"})})] + (t/is (contains? ev :context)) + (t/is (= {:browser "Chrome"} (:context ev)))) + + ;; With empty context ({} in transit) + (let [ev (row->event {:name "test" :type "action" :source "telemetry:backend" + :tracked-at (ct/now) :profile-id uuid/zero + :context (db/tjson {})})] + (t/is (contains? ev :context)) + (t/is (= {} (:context ev)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; NO DUPLICATE EVENTS ON SUCCESS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest test-no-duplicate-events-after-successful-send + ;; After a successful batch send, the sent rows must be deleted. + ;; Running the task again must NOT re-send the same events. + (let [send-count (atom 0)] + (with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request + :return nil}] + (with-redefs [cf/flags #{:telemetry} + telemetry/send-event-batch + (fn [_cfg _batch] + (swap! send-count inc) + true)] + (insert-telemetry-row! "navigate") + (insert-telemetry-row! "create-file") + + (t/is (= 2 (count-telemetry-rows))) + + ;; First run: sends and deletes + (th/run-task! :telemetry {:send? true :enabled? true}) + (t/is (= 1 @send-count)) + (t/is (= 0 (count-telemetry-rows))) + + ;; Second run: no events to send + (th/run-task! :telemetry {:send? true :enabled? true}) + (t/is (= 1 @send-count)) ;; still 1, not 2 + (t/is (= 0 (count-telemetry-rows))))))) diff --git a/backend/test/backend_tests/test_files/sample.png b/backend/test/backend_tests/test_files/sample.png new file mode 100644 index 0000000000..586da94a96 Binary files /dev/null and b/backend/test/backend_tests/test_files/sample.png differ diff --git a/backend/test/backend_tests/test_files/sample.webp b/backend/test/backend_tests/test_files/sample.webp new file mode 100644 index 0000000000..be1eb528b5 Binary files /dev/null and b/backend/test/backend_tests/test_files/sample.webp differ diff --git a/backend/test/backend_tests/util_blob_test.clj b/backend/test/backend_tests/util_blob_test.clj new file mode 100644 index 0000000000..fd474f9d88 --- /dev/null +++ b/backend/test/backend_tests/util_blob_test.clj @@ -0,0 +1,106 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns backend-tests.util-blob-test + (:require + [app.util.blob :as blob] + [clojure.string :as str] + [clojure.test :as t])) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; encode-str / decode-str round-trip +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest encode-str-roundtrip-empty-map + (let [data {}] + (t/is (= data (blob/decode-str (blob/encode-str data)))))) + +(t/deftest encode-str-roundtrip-empty-vector + (let [data []] + (t/is (= data (blob/decode-str (blob/encode-str data)))))) + +(t/deftest encode-str-roundtrip-nil + (let [data nil] + (t/is (= data (blob/decode-str (blob/encode-str data)))))) + +(t/deftest encode-str-roundtrip-simple-map + (let [data {:name "penpot" :version 42}] + (t/is (= data (blob/decode-str (blob/encode-str data)))))) + +(t/deftest encode-str-roundtrip-nested-structure + (let [data {:users [{:name "Alice" :tags #{"admin" "active"}} + {:name "Bob" :tags #{"user"}}] + :config {:debug false :timeout 3000}}] + (t/is (= data (blob/decode-str (blob/encode-str data)))))) + +(t/deftest encode-str-roundtrip-vector-of-maps + (let [data [{:name "navigate" :type "action" :source "telemetry"} + {:name "create-file" :type "action" :source "telemetry"}]] + (t/is (= data (blob/decode-str (blob/encode-str data)))))) + +(t/deftest encode-str-roundtrip-keywords-and-strings + (let [data {:keyword/value :foo + :string/value "hello world" + :boolean/value true + :nil/value nil}] + (t/is (= data (blob/decode-str (blob/encode-str data)))))) + +(t/deftest encode-str-roundtrip-numeric-types + (let [data {:int 42 + :neg -7 + :zero 0 + :big 9999999999}] + (t/is (= data (blob/decode-str (blob/encode-str data)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; URL-safe encoding properties +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest encode-str-url-safe-no-unsafe-chars + ;; URL-safe base64 must not contain +, /, or padding = + (let [data {:a (apply str (repeat 100 "x")) + :b (range 200) + :c {"key" "value with special chars: @#$%^&*()"}} + encoded (blob/encode-str data)] + (t/is (not (str/includes? encoded "+"))) + (t/is (not (str/includes? encoded "/"))) + (t/is (not (str/includes? encoded "="))))) + +(t/deftest encode-str-url-safe-roundtrip-after-encoding + ;; Ensure the URL-safe encoding still round-trips correctly + (let [data {:payload (vec (range 500)) + :nested {:a {:b {:c "deep"}}}} + encoded (blob/encode-str data) + decoded (blob/decode-str encoded)] + (t/is (= data decoded)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; version-specific encoding +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest encode-str-with-version-4 + (let [data {:events [{:name "click"} {:name "scroll"}]} + encoded (blob/encode-str data {:version 4}) + decoded (blob/decode-str encoded)] + (t/is (= data decoded)))) + +(t/deftest encode-str-with-version-5 + (let [data {:events [{:name "click"} {:name "scroll"}]} + encoded (blob/encode-str data {:version 5}) + decoded (blob/decode-str encoded)] + (t/is (= data decoded)))) + +(t/deftest encode-str-with-version-1 + (let [data {:simple "data"} + encoded (blob/encode-str data {:version 1}) + decoded (blob/decode-str encoded)] + (t/is (= data decoded)))) + +(t/deftest encode-str-with-version-3 + (let [data {:simple "data"} + encoded (blob/encode-str data {:version 3}) + decoded (blob/decode-str encoded)] + (t/is (= data decoded)))) diff --git a/backend/test/backend_tests/util_ssrf_test.clj b/backend/test/backend_tests/util_ssrf_test.clj new file mode 100644 index 0000000000..4e6c8ed93f --- /dev/null +++ b/backend/test/backend_tests/util_ssrf_test.clj @@ -0,0 +1,210 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns backend-tests.util-ssrf-test + (:require + [app.common.exceptions :as ex] + [app.config :as cf] + [app.http.client :as http] + [app.util.ssrf :as ssrf] + [clojure.test :as t])) + +(t/deftest validate-url-allows-public-https + (t/is (true? (ssrf/safe-url? "https://example.com/foo"))) + (t/is (true? (ssrf/safe-url? "https://example.com:8080/path?q=1")))) + +(t/deftest validate-url-allows-public-http + (t/is (true? (ssrf/safe-url? "http://example.com/foo")))) + +(t/deftest validate-url-blocks-disallowed-schemes + (t/is (false? (ssrf/safe-url? "file:///etc/passwd"))) + (t/is (false? (ssrf/safe-url? "gopher://example.com"))) + (t/is (false? (ssrf/safe-url? "ftp://example.com"))) + (t/is (false? (ssrf/safe-url? "dict://example.com"))) + (t/is (false? (ssrf/safe-url? "data:text/html,<h1>hi</h1>"))) + (t/is (false? (ssrf/safe-url? "jar:http://example.com!/foo"))) + (t/is (false? (ssrf/safe-url? "javascript:alert(1)")))) + +(t/deftest validate-url-blocks-loopback + (t/is (false? (ssrf/safe-url? "http://127.0.0.1/foo"))) + (t/is (false? (ssrf/safe-url? "http://127.0.0.2/foo"))) + (t/is (false? (ssrf/safe-url? "http://[::1]/foo")))) + +(t/deftest validate-url-blocks-any-local + (t/is (false? (ssrf/safe-url? "http://0.0.0.0/foo"))) + (t/is (false? (ssrf/safe-url? "http://[::]/foo")))) + +(t/deftest validate-url-blocks-link-local + (t/is (false? (ssrf/safe-url? "http://169.254.169.254/latest/meta-data/"))) + (t/is (false? (ssrf/safe-url? "http://169.254.1.1/foo"))) + (t/is (false? (ssrf/safe-url? "http://[fe80::1]/foo")))) + +(t/deftest validate-url-blocks-site-local + (t/is (false? (ssrf/safe-url? "http://10.0.0.1/foo"))) + (t/is (false? (ssrf/safe-url? "http://172.16.0.1/foo"))) + (t/is (false? (ssrf/safe-url? "http://192.168.1.1/foo")))) + +(t/deftest validate-url-blocks-cloud-metadata + (t/is (false? (ssrf/safe-url? "http://169.254.169.254/latest/meta-data/iam/security-credentials/role"))) + (t/is (false? (ssrf/safe-url? "http://[fd00:ec2::254]/foo")))) + +(t/deftest validate-url-blocks-carrier-grade-nat + (t/is (false? (ssrf/safe-url? "http://100.64.0.1/foo"))) + (t/is (false? (ssrf/safe-url? "http://100.127.255.255/foo"))) + ;; Just outside the range should be allowed (but may be blocked by DNS resolution failing) + ;; We test boundary: 100.63.255.255 is outside 100.64.0.0/10 + ;; But we can't easily test the "allowed" side without DNS, so we test the blocked side. + + ;; Test RFC reserved ranges + (t/is (false? (ssrf/safe-url? "http://240.0.0.1/foo"))) + (t/is (false? (ssrf/safe-url? "http://255.255.255.255/foo")))) + +(t/deftest validate-url-blocks-ipv6-ula + (t/is (false? (ssrf/safe-url? "http://[fd00::1]/foo"))) + (t/is (false? (ssrf/safe-url? "http://[fc00::1]/foo")))) + +(t/deftest validate-url-blocks-encoded-loopback + ;; Decimal encoding of 127.0.0.1 = 2130706433 + ;; InetAddress normalizes this to 127.0.0.1 + (t/is (false? (ssrf/safe-url? "http://2130706433/foo"))) + ;; Hex encoding 0x7f000001 + (t/is (false? (ssrf/safe-url? "http://0x7f000001/foo")))) + +(t/deftest validate-url-blocks-ipv4-mapped-loopback + (t/is (false? (ssrf/safe-url? "http://[::ffff:127.0.0.1]/foo")))) + +(t/deftest validate-url-blocks-multicast + (t/is (false? (ssrf/safe-url? "http://224.0.0.1/foo")))) + +(t/deftest validate-url-blocks-missing-scheme + (t/is (false? (ssrf/safe-url? "example.com/foo"))) + (t/is (false? (ssrf/safe-url? "")))) + +(t/deftest validate-url-blocks-missing-host + (t/is (false? (ssrf/safe-url? "http:///path"))) + (t/is (false? (ssrf/safe-url? "http://")))) + +(t/deftest validate-url-resolves-dns + ;; DNS-resolved internal: we use with-redefs to simulate + (let [original ssrf/resolve-host] + (with-redefs [ssrf/resolve-host + (fn [hostname] + (if (= hostname "evil.internal") + (into-array java.net.InetAddress + [(java.net.InetAddress/getByName "127.0.0.1")]) + (original hostname)))] + (t/is (false? (ssrf/safe-url? "http://evil.internal/foo"))) + ;; A hostname that fails DNS resolution + (t/is (false? (ssrf/safe-url? "http://nonexistent.invalid/foo")))))) + +(t/deftest validate-url-dns-all-addresses-must-be-safe + ;; If a hostname resolves to both a public and a private IP, it must be blocked + (let [original ssrf/resolve-host] + (with-redefs [ssrf/resolve-host + (fn [hostname] + (if (= hostname "split-brain.example") + (into-array java.net.InetAddress + [(java.net.InetAddress/getByName "1.1.1.1") + (java.net.InetAddress/getByName "127.0.0.1")]) + (original hostname)))] + (t/is (false? (ssrf/safe-url? "http://split-brain.example/foo")))))) + +(t/deftest validate-url-allowlist-override + (let [original-get cf/get] + (with-redefs [cf/get (fn [key & args] + (if (= key :ssrf-allowed-hosts) + #{"localhost"} + (apply original-get key args)))] + ;; localhost resolves to 127.0.0.1 which would normally be blocked + (t/is (true? (ssrf/safe-url? "http://localhost:6060/foo")))))) + +(t/deftest validate-url-extra-cidrs + (binding [ssrf/extra-blocked-cidrs #{(ssrf/parse-cidr "203.0.113.0/24")}] + (t/is (false? (ssrf/safe-url? "http://203.0.113.1/foo"))))) + +(t/deftest validate-url-throw-on-blocked + (try + (ssrf/validate-uri "http://127.0.0.1/foo") + (t/is false "should have thrown") + (catch Exception e + (let [data (ex-data e)] + (t/is (= :validation (:type data))) + (t/is (= :ssrf-blocked-target (:code data))) + (t/is (= "http://127.0.0.1/foo" (:uri data))))))) + +(t/deftest validate-url-throw-on-scheme + (try + (ssrf/validate-uri "file:///etc/passwd") + (t/is false "should have thrown") + (catch Exception e + (let [data (ex-data e)] + (t/is (= :validation (:type data))) + (t/is (= :ssrf-blocked-target (:code data))) + (t/is (= "file:///etc/passwd" (:uri data))) + (t/is (= "file" (:scheme data))))))) + +(t/deftest validate-url-throw-on-missing-host + (try + (ssrf/validate-uri "http:///path") + (t/is false "should have thrown") + (catch Exception e + (let [data (ex-data e)] + (t/is (= :validation (:type data))) + (t/is (= :ssrf-blocked-target (:code data))) + (t/is (= "http:///path" (:uri data))) + (t/is (nil? (:host data))))))) + +(t/deftest validate-url-throw-on-dns-failure + (try + (ssrf/validate-uri "http://nonexistent.invalid/foo") + (t/is false "should have thrown") + (catch Exception e + (let [data (ex-data e)] + (t/is (= :validation (:type data))) + (t/is (= :ssrf-blocked-target (:code data))) + (t/is (= "http://nonexistent.invalid/foo" (:uri data))))))) + +;; --------------------------------------------------------------------------- +;; http/req automatic SSRF validation +;; --------------------------------------------------------------------------- + +(t/deftest http-req-validates-ssrf-by-default + ;; `http/req` should invoke ssrf/validate-uri before sending the request. + ;; We verify this by checking that a blocked URI raises an SSRF error + ;; without ever reaching the network (validate-uri throws first). + (try + (http/req {} {:method :get :uri "http://127.0.0.1/secret"}) + (t/is false "should have thrown an SSRF error") + (catch Exception e + (t/is (= :ssrf-blocked-target (:code (ex-data e))))))) + +(t/deftest http-req-skip-ssrf-check-bypasses-validation + ;; When :skip-ssrf-check? true is passed, ssrf/validate-uri must NOT be + ;; called. We verify by patching validate-uri to record whether it was called. + (let [called? (atom false)] + (with-redefs [ssrf/validate-uri (fn [_] (reset! called? true))] + ;; The request will fail at the network level (no real server), but that's + ;; fine — we only care that validate-uri was not called beforehand. + (try + (http/req {} {:method :get :uri "http://127.0.0.1/secret"} {:skip-ssrf-check? true}) + (catch Exception _)) + (t/is (false? @called?) "validate-uri should not be called when :skip-ssrf-check? is true")))) + +(t/deftest http-req-with-redirects-validates-ssrf-by-default + ;; req-with-redirects must also validate the initial URI automatically. + (try + (http/req-with-redirects {} {:method :get :uri "http://10.0.0.1/internal"}) + (t/is false "should have thrown an SSRF error") + (catch Exception e + (t/is (= :ssrf-blocked-target (:code (ex-data e))))))) + +(t/deftest http-req-with-redirects-skip-ssrf-check-bypasses-validation + (let [called? (atom false)] + (with-redefs [ssrf/validate-uri (fn [_] (reset! called? true))] + (try + (http/req-with-redirects {} {:method :get :uri "http://10.0.0.1/internal"} {:skip-ssrf-check? true}) + (catch Exception _)) + (t/is (false? @called?) "validate-uri should not be called when :skip-ssrf-check? is true")))) diff --git a/common/AGENTS.md b/common/AGENTS.md deleted file mode 100644 index 2659b83939..0000000000 --- a/common/AGENTS.md +++ /dev/null @@ -1,70 +0,0 @@ -# Penpot Common – Agent Instructions - -A shared module with code written in Clojure, ClojureScript, and -JavaScript. Contains multiplatform code that can be used and executed -from the frontend, backend, or exporter modules. It uses Clojure reader -conditionals to specify platform-specific implementations. - -## General Guidelines - -To ensure consistency across the Penpot stack, all contributions must adhere to -these criteria: - -### 1. Testing & Validation - -If code is added or modified in `src/`, corresponding tests in -`test/common_tests/` must be added or updated. - - * **Environment:** Tests should run in both JS (Node.js) and JVM environments. -* **Location:** Place tests in the `test/common_tests/` directory, following the - namespace structure of the source code (e.g., `app.common.colors` -> - `common-tests.colors-test`). -* **Execution:** Tests should be executed on both JS (Node.js) and JVM environments: - * **Isolated:** - * JS: To run a focused ClojureScript unit test: edit the - `test/common_tests/runner.cljs` to narrow the test suite, then - `pnpm run test:js`. - * JVM: `pnpm run test:jvm --focus common-tests.my-ns-test` - * **Regression:** - * JS: Run `pnpm run test:js` without modifications on the runner (preferred) - * JVM: Run `pnpm run test:jvm` - -### 2. Code Quality & Formatting - -* **Linting:** All code changes must pass linter checks: - * Run `pnpm run lint:clj` for CLJ/CLJS/CLJC -* **Formatting:** All code changes must pass the formatting check - * Run `pnpm run check-fmt:clj` for CLJ/CLJS/CLJC - * Run `pnpm run check-fmt:js` for JS - * Use `pnpm run fmt` to fix all formatting issues (`pnpm run - fmt:clj` or `pnpm run fmt:js` for isolated formatting fix). - -## Code Conventions - -### Namespace Overview - -The source is located under `src` directory and this is a general overview of -namespaces structure: - -- `app.common.types.*` – Shared data types for shapes, files, pages using Malli schemas -- `app.common.schema` – Malli abstraction layer, exposes the most used functions from malli -- `app.common.geom.*` – Geometry and shape transformation helpers -- `app.common.data` – Generic helpers used across the entire application -- `app.common.math` – Generic math helpers used across the entire application -- `app.common.json` – Generic JSON encoding/decoding helpers -- `app.common.data.macros` – Performance macros used everywhere - - -### Reader Conditionals - -We use reader conditionals to differentiate implementations depending on the -target platform where the code runs: - -```clojure -#?(:clj (import java.util.UUID) - :cljs (:require [cljs.core :as core])) -``` - -Both frontend and backend depend on `common` as a local library (`penpot/common -{:local/root "../common"}`). - diff --git a/common/deps.edn b/common/deps.edn index 01b6d33df4..74fcef0f28 100644 --- a/common/deps.edn +++ b/common/deps.edn @@ -1,23 +1,23 @@ {:deps - {org.clojure/clojure {:mvn/version "1.12.4"} - org.clojure/data.json {:mvn/version "2.5.1"} + {org.clojure/clojure {:mvn/version "1.12.5"} + org.clojure/data.json {:mvn/version "2.5.2"} org.clojure/tools.cli {:mvn/version "1.1.230"} org.clojure/test.check {:mvn/version "1.1.1"} - org.clojure/data.fressian {:mvn/version "1.1.0"} + org.clojure/data.fressian {:mvn/version "1.1.1"} org.clojure/clojurescript {:mvn/version "1.12.42"} org.apache.commons/commons-pool2 {:mvn/version "2.12.1"} ;; Logging - org.apache.logging.log4j/log4j-api {:mvn/version "2.25.3"} - org.apache.logging.log4j/log4j-core {:mvn/version "2.25.3"} - org.apache.logging.log4j/log4j-web {:mvn/version "2.25.3"} - org.apache.logging.log4j/log4j-jul {:mvn/version "2.25.3"} - org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.25.3"} - org.slf4j/slf4j-api {:mvn/version "2.0.17"} + org.apache.logging.log4j/log4j-api {:mvn/version "2.26.0"} + org.apache.logging.log4j/log4j-core {:mvn/version "2.26.0"} + org.apache.logging.log4j/log4j-web {:mvn/version "2.26.0"} + org.apache.logging.log4j/log4j-jul {:mvn/version "2.26.0"} + org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.26.0"} + org.slf4j/slf4j-api {:mvn/version "2.0.18"} pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.41"} - selmer/selmer {:mvn/version "1.12.70"} + selmer/selmer {:mvn/version "1.13.1"} criterium/criterium {:mvn/version "0.4.6"} metosin/jsonista {:mvn/version "0.3.13"} @@ -55,12 +55,12 @@ :aliases {:dev {:extra-deps - {org.clojure/tools.namespace {:mvn/version "RELEASE"} + {org.clojure/tools.namespace {:mvn/version "1.5.0"} thheller/shadow-cljs {:mvn/version "3.2.0"} - com.clojure-goes-fast/clj-async-profiler {:mvn/version "RELEASE"} - com.bhauman/rebel-readline {:mvn/version "RELEASE"} + com.clojure-goes-fast/clj-async-profiler {:mvn/version "2.0.0-beta1"} + com.bhauman/rebel-readline {:mvn/version "0.1.5"} criterium/criterium {:mvn/version "0.4.6"} - mockery/mockery {:mvn/version "RELEASE"}} + mockery/mockery {:mvn/version "0.1.4"}} :extra-paths ["test" "dev"]} :build diff --git a/common/package.json b/common/package.json index 300c768dcb..1335f71d61 100644 --- a/common/package.json +++ b/common/package.json @@ -30,6 +30,7 @@ "watch:test": "concurrently \"clojure -M:dev:shadow-cljs watch test\" \"nodemon -C -d 2 -w target/tests/ --exec 'node target/tests/test.js'\"", "build:test": "clojure -M:dev:shadow-cljs compile test", "test:js": "pnpm run build:test && node target/tests/test.js", + "test:quiet": "node ./scripts/test-quiet.js", "test:jvm": "clojure -M:dev:test" } } diff --git a/common/scripts/test-quiet.js b/common/scripts/test-quiet.js new file mode 100644 index 0000000000..6b614c74c4 --- /dev/null +++ b/common/scripts/test-quiet.js @@ -0,0 +1,25 @@ +import { spawnSync } from "node:child_process"; + +const progress = (msg) => process.stderr.write(`${msg}\n`); + +progress("Building test bundle..."); +const build = spawnSync("pnpm", ["run", "build:test"], { + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 64 * 1024 * 1024, +}); + +if (build.status !== 0) { + progress("Building test bundle failed"); + if (build.stdout?.length) process.stdout.write(build.stdout); + if (build.stderr?.length) process.stderr.write(build.stderr); + process.exit(build.status ?? 1); +} + +progress("Running tests..."); +const result = spawnSync( + "node", + ["target/tests/test.js", ...process.argv.slice(2)], + { stdio: "inherit" }, +); + +process.exit(result.status ?? 1); diff --git a/common/src/app/common/buffer.cljc b/common/src/app/common/buffer.cljc index 7d23edeafe..87b4f72ad5 100644 --- a/common/src/app/common/buffer.cljc +++ b/common/src/app/common/buffer.cljc @@ -279,3 +279,10 @@ [o] #?(:cljs (.-byteLength ^js o) :clj (.capacity ^ByteBuffer o))) + +#?(:clj + (defn set-order + "Set the byte order on a ByteBuffer. Returns the buffer." + [^ByteBuffer buffer ^ByteOrder order] + (.order buffer order) + buffer)) diff --git a/common/src/app/common/colors.cljc b/common/src/app/common/colors.cljc index ab7c7e2a76..51a9afd5f7 100644 --- a/common/src/app/common/colors.cljc +++ b/common/src/app/common/colors.cljc @@ -332,10 +332,7 @@ (conj opacity))) (defn hex->hsl [hex] - (try - (-> hex hex->rgb rgb->hsl) - (catch #?(:clj Throwable :cljs :default) _e - [0 0 0]))) + (-> hex hex->rgb rgb->hsl)) (defn hex->hsla [data opacity] @@ -486,4 +483,3 @@ a (+ (* ah 100) (* av 10)) b (+ (* bh 100) (* bv 10))] (compare a b))) - diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index cc1247dd8e..a97acd0f8a 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -177,7 +177,9 @@ (defn not-empty? [coll] - (boolean (seq coll))) + (if (coll? coll) + (boolean (seq coll)) + (not (nil? coll)))) (defn editable-collection? [m] @@ -379,7 +381,7 @@ :else (assoc object key value))) object)) - changes))) + (without-nils changes)))) (defn remove-at-index "Takes a vector and returns a vector with an element in the diff --git a/common/src/app/common/features.cljc b/common/src/app/common/features.cljc index 516789428b..abe66aaab5 100644 --- a/common/src/app/common/features.cljc +++ b/common/src/app/common/features.cljc @@ -68,6 +68,7 @@ "components/v2" "plugins/runtime" "design-tokens/v1" + "tokens/numeric-input" "variants/v1"}) ;; A set of features which only affects on frontend and can be enabled diff --git a/common/src/app/common/files/changes.cljc b/common/src/app/common/files/changes.cljc index 5b45a7ecd4..c9aa3d3faa 100644 --- a/common/src/app/common/files/changes.cljc +++ b/common/src/app/common/files/changes.cljc @@ -261,7 +261,11 @@ ;; All props are optional, background can be nil because is the ;; way to remove already set background [:background {:optional true} [:maybe ctc/schema:hex-color]] - [:name {:optional true} :string]]] + [:name {:optional true} :string] + ;; Pixel grid display controls — nil removes the per-page override + ;; and falls back to the default hardcoded grid color/opacity. + [:pixel-grid-color {:optional true} [:maybe ctc/schema:hex-color]] + [:pixel-grid-opacity {:optional true} [:maybe ::sm/safe-number]]]] [:set-plugin-data schema:set-plugin-data-change] @@ -853,8 +857,10 @@ [data {:keys [id] :as params}] (d/update-in-when data [:pages-index id] (fn [page] - (let [name (get params :name) - bg (get params :background :not-found)] + (let [name (get params :name) + bg (get params :background :not-found) + grid-color (get params :pixel-grid-color :not-found) + grid-op (get params :pixel-grid-opacity :not-found)] (cond-> page (string? name) (assoc :name name) @@ -863,7 +869,19 @@ (assoc :background bg) (nil? bg) - (dissoc :background)))))) + (dissoc :background) + + (string? grid-color) + (assoc :pixel-grid-color grid-color) + + (and (not= grid-color :not-found) (nil? grid-color)) + (dissoc :pixel-grid-color) + + (number? grid-op) + (assoc :pixel-grid-opacity grid-op) + + (and (not= grid-op :not-found) (nil? grid-op)) + (dissoc :pixel-grid-opacity)))))) (defmethod process-change :set-plugin-data [data {:keys [object-type object-id page-id namespace key value]}] diff --git a/common/src/app/common/files/changes_builder.cljc b/common/src/app/common/files/changes_builder.cljc index 93ac58d03b..f1cfb402d1 100644 --- a/common/src/app/common/files/changes_builder.cljc +++ b/common/src/app/common/files/changes_builder.cljc @@ -19,6 +19,7 @@ [app.common.types.component :as ctk] [app.common.types.file :as ctf] [app.common.types.path :as path] + [app.common.types.shape :as cts] [app.common.types.shape.layout :as ctl] [app.common.types.tokens-lib :as ctob] [app.common.uuid :as uuid] @@ -46,8 +47,8 @@ (with-meta changes {::page-id page-id}))) ([] - {:redo-changes [] - :undo-changes '()}) + {:redo-changes [] ;; redo-changes is a vector so that conj adds things at the end, in order of execution + :undo-changes '()}) ;; undo-changes is a list to conj things at the beginning, so they execute in the reverse order when undoing several changes ([origin] {:redo-changes [] :undo-changes '() @@ -67,6 +68,12 @@ (some? undo-group) (assoc :undo-group undo-group))) +(defn set-translation? + [changes translation?] + (cond-> changes + translation? + (assoc :translation? true))) + (defn with-page [changes page] (vary-meta changes assoc @@ -162,7 +169,7 @@ (contains? (meta changes) ::file-data) "Call (with-file-data) before using this function")) -(defn- lookup-objects +(defn lookup-objects [changes] (let [data (::file-data (meta changes))] (dm/get-in data [:pages-index uuid/zero :objects]))) @@ -213,21 +220,33 @@ (let [page (::page (meta changes))] (mod-page changes page options))) - ([changes page {:keys [name background]}] + ([changes page {:keys [name background pixel-grid-color pixel-grid-opacity]}] (let [change {:type :mod-page :id (:id page)} redo (cond-> change (some? name) (assoc :name name) (some? background) - (assoc :background background)) + (assoc :background background) + + (some? pixel-grid-color) + (assoc :pixel-grid-color pixel-grid-color) + + (some? pixel-grid-opacity) + (assoc :pixel-grid-opacity pixel-grid-opacity)) undo (cond-> change (some? name) (assoc :name (:name page)) (some? background) - (assoc :background (:background page)))] + (assoc :background (:background page)) + + (some? pixel-grid-color) + (assoc :pixel-grid-color (:pixel-grid-color page)) + + (some? pixel-grid-opacity) + (assoc :pixel-grid-opacity (:pixel-grid-opacity page)))] (-> changes (update :redo-changes conj redo) @@ -392,12 +411,9 @@ (add-object changes obj nil)) ([changes obj {:keys [index ignore-touched] :or {index ::undefined ignore-touched false}}] - - ;; FIXME: add shape validation - (assert-page-id! changes) (assert-objects! changes) - (let [obj (cond-> obj + (let [obj (cond-> (cts/check-shape obj) (not= index ::undefined) (assoc ::index index)) diff --git a/common/src/app/common/files/indices.cljc b/common/src/app/common/files/indices.cljc index 4e177f052c..11eeaa9aed 100644 --- a/common/src/app/common/files/indices.cljc +++ b/common/src/app/common/files/indices.cljc @@ -13,7 +13,7 @@ (defn- generate-index "An optimized algorithm for calculate parents index that walk from top - to down starting from a provided shape-id. Usefull when you want to + to down starting from a provided shape-id. Useful when you want to create an index for the whole objects or subpart of the tree." [index objects shape-id parents] (let [shape (get objects shape-id) diff --git a/common/src/app/common/files/migrations.cljc b/common/src/app/common/files/migrations.cljc index eeb11e9067..9ca673ce72 100644 --- a/common/src/app/common/files/migrations.cljc +++ b/common/src/app/common/files/migrations.cljc @@ -34,7 +34,7 @@ [app.common.types.shape.shadow :as ctss] [app.common.types.shape.text :as ctst] [app.common.types.text :as types.text] - [app.common.types.tokens-lib :as types.tokens-lib] + [app.common.types.tokens-lib :as ctob] [app.common.uuid :as uuid] [clojure.set :as set] [cuerdas.core :as str])) @@ -1599,7 +1599,7 @@ (defmethod migrate-data "0014-fix-tokens-lib-duplicate-ids" [data _] - (d/update-when data :tokens-lib types.tokens-lib/fix-duplicate-token-set-ids)) + (d/update-when data :tokens-lib ctob/fix-duplicate-token-set-ids)) (defmethod migrate-data "0014-clear-components-nil-objects" [data _] @@ -1805,6 +1805,13 @@ {})] (cfcp/sync-component-id-with-ref-shape data libraries))) +(defmethod migrate-data "0021-repair-bad-tokens" + [data _] + (d/update-when data :tokens-lib + #(-> % + (ctob/fix-conflicting-token-names) + (ctob/fix-missing-sets-in-themes)))) + (def available-migrations (into (d/ordered-set) ["legacy-2" @@ -1882,4 +1889,5 @@ "0017-fix-layout-flex-dir" "0018-remove-unneeded-objects-from-components" "0019-fix-missing-swap-slots" - "0020-sync-component-id-with-near-main"])) + "0020-sync-component-id-with-near-main" + "0021-repair-bad-tokens"])) diff --git a/common/src/app/common/files/shapes_builder.cljc b/common/src/app/common/files/shapes_builder.cljc index 76b6ef4c04..5a6de2bbe1 100644 --- a/common/src/app/common/files/shapes_builder.cljc +++ b/common/src/app/common/files/shapes_builder.cljc @@ -340,12 +340,26 @@ :svg-viewbox vbox :svg-defs defs}))) +(defn- stroke-only-svg-path? + "Returns true when the SVG element renders only a stroke (fill=none). + Stroke-only paths can have their consecutive touching subpaths safely + merged into a continuous polyline so that `stroke-linejoin` applies at + shared endpoints, without affecting any fill-rule semantics." + [attrs] + (let [attr-fill (some-> (:fill attrs) str/trim) + style-fill (some-> (get-in attrs [:style :fill]) str/trim)] + (= "none" (or attr-fill style-fill)))) + (defn create-path-shape [name frame-id svg-data {:keys [attrs] :as data}] (when (and (contains? attrs :d) (seq (:d attrs))) - (let [transform (csvg/parse-transform (:transform attrs)) - content (cond-> (path/from-string (:d attrs)) - (some? transform) - (path.segm/transform-content transform)) + (let [transform (csvg/parse-transform (:transform attrs)) + stroke-only? (stroke-only-svg-path? attrs) + content (cond-> (path/from-string (:d attrs)) + stroke-only? + (path/merge-touching-subpaths) + + (some? transform) + (path.segm/transform-content transform)) selrect (path.segm/content->selrect content) points (grc/rect->points selrect) @@ -529,7 +543,7 @@ (update :svg-attrs dissoc :fill) (assoc-in [:fills 0 :fill-color] (clr/parse color-style))) - ;; Only create an opacity if the color is setted. Othewise can create problems down the line + ;; Only create an opacity if the color is set. Otherwise can create problems down the line (and (or (clr/color-string? color-attr) (clr/color-string? color-style)) (dm/get-in shape [:svg-attrs :fillOpacity])) (-> (update :svg-attrs dissoc :fillOpacity) @@ -663,6 +677,22 @@ (remove is-style-fragment?) ;; Filter style fragments and hex colors (filter #(contains? defs %))))) ;; Only existing defs +(defn resolve-element-name + "Pick the most user-meaningful name for an SVG element. + + Inkscape (and editors following the same convention) write the + operator-given label to ``inkscape:label``/``sodipodi:label`` while + ``id`` holds an auto-generated technical id like ``path1234``. + Preferring the namespaced label keeps the layer/group/element names + the operator sees in their source editor across a paste/import + (#7869); the existing ``id`` and ``(tag->name tag)`` fallbacks keep + legacy SVGs that don't carry a label working unchanged." + [tag attrs] + (or (:inkscape:label attrs) + (:sodipodi:label attrs) + (:id attrs) + (tag->name tag))) + (defn parse-svg-element [frame-id svg-data {:keys [tag attrs hidden] :as element} unames] @@ -670,7 +700,7 @@ ;; think we should handle this case early and avoid some code ;; execution - (let [name (or (:id attrs) (tag->name tag)) + (let [name (resolve-element-name tag attrs) att-refs (csvg/find-attr-references attrs) defs (get svg-data :defs) valid-refs (filter-valid-def-references att-refs defs) diff --git a/common/src/app/common/files/tokens.cljc b/common/src/app/common/files/tokens.cljc index cd6dc44cb2..1ae3db5487 100644 --- a/common/src/app/common/files/tokens.cljc +++ b/common/src/app/common/files/tokens.cljc @@ -26,7 +26,7 @@ [{:keys [value]}] (when (or (str/empty? value) (str/blank? value)) - (tr "workspace.tokens.empty-input"))) + (tr "errors.tokens.empty-input"))) (def schema:token-value-generic [::sm/text {:error/fn token-value-empty-fn}]) @@ -34,7 +34,7 @@ (def schema:token-value-numeric [:and [::sm/text {:error/fn token-value-empty-fn}] - [:fn {:error/fn #(tr "workspace.tokens.invalid-value" (:value %))} + [:fn {:error/fn #(tr "errors.tokens.invalid-value" (:value %))} (fn [value] (if (str/numeric? value) (let [n (d/parse-double value)] @@ -44,7 +44,7 @@ (def schema:token-value-percent [:and [::sm/text {:error/fn token-value-empty-fn}] - [:fn {:error/fn #(tr "workspace.tokens.value-with-percent" (:value %))} + [:fn {:error/fn #(tr "errors.tokens.value-with-percent" (:value %))} (fn [value] (if (d/percent? value) (let [v (d/parse-percent value)] @@ -57,7 +57,7 @@ (def schema:token-value-opacity [:and [::sm/text {:error/fn token-value-empty-fn}] - [:fn {:error/fn #(tr "workspace.tokens.opacity-range")} + [:fn {:error/fn #(tr "errors.tokens.opacity-range")} (fn [opacity] (if (str/numeric? opacity) (let [n (d/parse-percent opacity)] @@ -71,7 +71,7 @@ (def schema:token-value-font-weight [:or - [:fn {:error/fn #(tr "workspace.tokens.invalid-font-weight-token-value")} + [:fn {:error/fn #(tr "errors.tokens.invalid-font-weight-token-value")} cto/valid-font-weight-variant] ::sm/text]) ;; Leave references or formulas to be checked by the resolver @@ -134,26 +134,26 @@ (defn make-token-name-schema "Dynamically generates a schema to check a token name, adding translated error messages - and two additional validations: + and additional validations: - Min and max length. - - Checks if other token with a path derived from the name already exists at `tokens-tree`. - e.g. it's not allowed to create a token `foo.bar` if a token `foo` already exists." - [tokens-tree] + - Checks if other token with a path derived from the name already exists in the library. + e.g. it's not allowed to create a token `foo.bar` if a token `foo` already exists. + - Also checks if there is a token with the exact same name in the current set, but different + from the current token." + [tokens-lib set-id token-id] [:and [:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] (-> cto/schema:token-name (sm/update-properties assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error")))) [:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))} - #(and (some? tokens-tree) - (not (ctob/token-name-path-exists? % tokens-tree)))]]) + #(or (nil? tokens-lib) + (not (ctob/token-name-path-exists? % tokens-lib set-id token-id)))]]) (defn make-node-token-name-schema - "Dynamically generates a schema to check a token node name, adding translated error messages - and two additional validations: - - Min and max length. - - Checks if other token with a path derived from the name already exists at `tokens-tree`. - e.g. it's not allowed to create a token `foo.bar` if a token `foo` already exists." - [active-tokens tokens-tree node] + "Dynamically generates a schema to check the name of a token node, that may be a final token or a group. + This runs same checks as make-token-name-schema, but for all tokens that will be renamed by this change, + if the group already contains tokens." + [active-tokens tokens-lib node set-id] [:and [:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] (-> cto/schema:token-node-name @@ -164,32 +164,32 @@ current-name (:name node) new-tokens (ctob/update-tokens-group active-tokens current-path current-name name)] (and (some? new-tokens) - (some (fn [[token-name _]] - (not (ctob/token-name-path-exists? token-name tokens-tree))) + (some (fn [[token-name token]] + (not (ctob/token-name-path-exists? token-name tokens-lib set-id (ctob/get-id token)))) new-tokens))))]]) (def schema:token-description [:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]) (defn make-token-schema - [tokens-tree token-type] + [tokens-lib token-type set-id token-id] [:and (sm/merge cto/schema:token-attrs [:map - [:name (make-token-name-schema tokens-tree)] + [:name (make-token-name-schema tokens-lib set-id token-id)] [:value (make-token-value-schema token-type)] [:description {:optional true} schema:token-description]]) [:fn {:error/field :value - :error/fn #(tr "workspace.tokens.self-reference")} + :error/fn #(tr "errors.tokens.self-reference")} (fn [{:keys [name value]}] (when (and name value) (not (cto/token-value-self-reference? name value))))]]) (defn make-node-token-schema - [active-tokens tokens-tree node] + [active-tokens tokens-lib node set-id] [:map - [:name (make-node-token-name-schema active-tokens tokens-tree node)]]) + [:name (make-node-token-name-schema active-tokens tokens-lib node set-id)]]) (defn convert-dtcg-token "Convert token attributes as they come from a decoded json, with DTCG types, to internal types. @@ -287,12 +287,18 @@ (defn make-token-theme-schema [tokens-lib group name theme-id] - (sm/merge - ctob/schema:token-theme-attrs - [:map - [:group (make-token-theme-group-schema tokens-lib name theme-id)] ;; TODO how to keep error-fn from here? - [:name (make-token-theme-name-schema tokens-lib group theme-id)] - [:description {:optional true} schema:token-theme-description]])) + [:and + (sm/merge + ctob/schema:token-theme-attrs + [:map + [:group (make-token-theme-group-schema tokens-lib name theme-id)] ;; TODO how to keep error-fn from here? + [:name (make-token-theme-name-schema tokens-lib group theme-id)] + [:description {:optional true} schema:token-theme-description]]) + [:fn {:error/field :sets + :error/fn #(tr "errors.token-theme-not-existing-sets" (str/join ", " (:sets (:value %))))} + (fn [{:keys [sets]}] + (or (nil? tokens-lib) + (every? #(ctob/get-set-by-name tokens-lib %) sets)))]]) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; HELPERS diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index aad100c0d9..1cec7db935 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -140,6 +140,7 @@ :render-wasm-dpr ;; Show WASM renderer info label (hidden by default). :render-wasm-info + :render-switch :hide-release-modal :subscriptions :subscriptions-old @@ -147,10 +148,6 @@ ;; Enable performance logs in devconsole (disabled by default) :perf-logs - ;; Used for designate features that will be available in the next - ;; release - :canary - ;; Security layer middleware that filters request by fetch ;; metadata headers :sec-fetch-metadata-middleware diff --git a/common/src/app/common/fressian.clj b/common/src/app/common/fressian.clj index 98c8b1b323..f005df743b 100644 --- a/common/src/app/common/fressian.clj +++ b/common/src/app/common/fressian.clj @@ -17,11 +17,11 @@ java.util.List linked.map.LinkedMap linked.set.LinkedSet + org.fressian.handlers.ReadHandler + org.fressian.handlers.WriteHandler org.fressian.Reader org.fressian.StreamingWriter - org.fressian.Writer - org.fressian.handlers.ReadHandler - org.fressian.handlers.WriteHandler)) + org.fressian.Writer)) (set! *warn-on-reflection* true) diff --git a/common/src/app/common/generic_pool.clj b/common/src/app/common/generic_pool.clj index bccf0b06ec..950506dc17 100644 --- a/common/src/app/common/generic_pool.clj +++ b/common/src/app/common/generic_pool.clj @@ -8,11 +8,11 @@ (:refer-clojure :exclude [get]) (:import java.lang.AutoCloseable + org.apache.commons.pool2.impl.DefaultPooledObject + org.apache.commons.pool2.impl.SoftReferenceObjectPool org.apache.commons.pool2.ObjectPool org.apache.commons.pool2.PooledObject - org.apache.commons.pool2.PooledObjectFactory - org.apache.commons.pool2.impl.DefaultPooledObject - org.apache.commons.pool2.impl.SoftReferenceObjectPool)) + org.apache.commons.pool2.PooledObjectFactory)) (defn pool? [o] diff --git a/common/src/app/common/geom/shapes/flex_layout/bounds.cljc b/common/src/app/common/geom/shapes/flex_layout/bounds.cljc index 56defa9cc3..14ade3c6e2 100644 --- a/common/src/app/common/geom/shapes/flex_layout/bounds.cljc +++ b/common/src/app/common/geom/shapes/flex_layout/bounds.cljc @@ -12,7 +12,7 @@ [app.common.geom.shapes.points :as gpo] [app.common.types.shape.layout :as ctl])) -;; Setted in app.common.geom.shapes.common-layout +;; Set in app.common.geom.shapes.common-layout ;; We do it this way because circular dependencies (def -child-min-width nil) diff --git a/common/src/app/common/geom/shapes/flex_layout/layout_data.cljc b/common/src/app/common/geom/shapes/flex_layout/layout_data.cljc index 6d91a25707..c556955697 100644 --- a/common/src/app/common/geom/shapes/flex_layout/layout_data.cljc +++ b/common/src/app/common/geom/shapes/flex_layout/layout_data.cljc @@ -14,7 +14,7 @@ (def conjv (fnil conj [])) -;; Setted in app.common.geom.shapes.min-size-layout +;; Set in app.common.geom.shapes.min-size-layout ;; We do it this way because circular dependencies (def -child-min-width nil) diff --git a/common/src/app/common/geom/shapes/grid_layout/layout_data.cljc b/common/src/app/common/geom/shapes/grid_layout/layout_data.cljc index 026c48fe41..a8a53156a5 100644 --- a/common/src/app/common/geom/shapes/grid_layout/layout_data.cljc +++ b/common/src/app/common/geom/shapes/grid_layout/layout_data.cljc @@ -39,7 +39,7 @@ ;; ;; 5. If any track still has an infinite growth limit set its growth limit to its base size. -;; - Distribute extra space accross spaned tracks +;; - Distribute extra space across spanned tracks ;; - Maximize tracks ;; ;; - Expand flexible tracks @@ -55,7 +55,7 @@ [app.common.math :as mth] [app.common.types.shape.layout :as ctl])) -;; Setted in app.common.geom.shapes.common-layout +;; Set in app.common.geom.shapes.common-layout ;; We do it this way because circular dependencies (def -child-min-width nil) @@ -449,7 +449,7 @@ column-tracks (set-auto-base-size column-tracks children shape-cells bounds objects :column) row-tracks (set-auto-base-size row-tracks children shape-cells bounds objects :row) - ;; Adjust multi-spaned cells with no flex columns + ;; Adjust multi-spanned cells with no flex columns column-tracks (set-auto-multi-span parent column-tracks children-map shape-cells bounds objects :column) row-tracks (set-auto-multi-span parent row-tracks children-map shape-cells bounds objects :row) diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index 35df16aa86..ea274a0b32 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -20,6 +20,7 @@ [app.common.logging :as log] [app.common.logic.shapes :as cls] [app.common.logic.variant-properties :as clvp] + [app.common.math :as mth] [app.common.path-names :as cpn] [app.common.types.component :as ctk] [app.common.types.components-list :as ctkl] @@ -118,8 +119,9 @@ (defn- duplicate-component "Clone the root shape of the component and all children. Generate new - ids from all of them." - [component new-component-id library-data force-id delta variant-id] + ids from all of them. Optionally set the component-file if the file where the + new component will reside is different than the origin one." + [component new-component-id new-component-file library-data force-id delta variant-id] (let [main-instance-page (ctf/get-component-page library-data component) main-instance-shape (ctf/get-component-root library-data component) delta (or delta (gpt/point (+ (:width main-instance-shape) 50) 0)) @@ -141,10 +143,18 @@ update-new-shape (fn [new-shape _] (cond-> new-shape - ; Link the new main to the new component + ;; Link the new main to the new component, and re-root it + ;; to the destination file when duplicating across files. + ;; Only the outer main matches `(:id component)`, so + ;; nested main-instances are not touched here. (= (:component-id new-shape) (:id component)) (assoc :component-id new-component-id) + (and (= (:component-id new-shape) (:id component)) + (some? new-component-file) + (not= new-component-file (:component-file new-shape))) + (assoc :component-file new-component-file) + ; If it is the instance root, add it the variant-id (and (ctk/instance-root? new-shape) (some? variant-id)) (assoc :variant-id variant-id) @@ -188,7 +198,7 @@ (defn generate-duplicate-component "Create a new component copied from the one with the given id." - [changes library component-id new-component-id & {:keys [new-shape-id apply-changes-local-library? delta new-variant-id page-id]}] + [changes library component-id new-component-id & {:keys [new-component-file new-shape-id apply-changes-local-library? delta new-variant-id page-id]}] (let [component (ctkl/get-component (:data library) component-id) new-name (:name component) @@ -197,7 +207,7 @@ target-page-id (or page-id (:id main-instance-page)) [new-main-instance-shape new-main-instance-shapes] - (duplicate-component component new-component-id (:data library) new-shape-id delta new-variant-id)] + (duplicate-component component new-component-id new-component-file (:data library) new-shape-id delta new-variant-id)] [new-main-instance-shape (-> changes @@ -1831,7 +1841,7 @@ ;; On texts, when we want to omit the touched attrs, both text (the actual letters) ;; and attrs (bold, font, etc) are in the same attr :content. - ;; If only one of them is touched, we want to adress this case and + ;; If only one of them is touched, we want to address this case and ;; only update the untouched one text-content-change? (and omit-touched? @@ -2070,25 +2080,34 @@ (grc/rect->center selrect) (or (:transform current-shape) (gmt/matrix))))))) - (defn- equal-geometry? "Returns true when the value of `attr` in `shape` is considered equal to the corresponding value in `origin-shape`, ignoring positional displacement (x/y). For :selrect we compare width/height only; for :points we normalise each vector so the first point is the - origin before comparing." + origin before comparing. + + Comparisons use `mth/close?` (and `gpt/close?` for points) rather than + exact `=` because `previous-shape` here may carry sub-pixel drift from + interactive transform modifiers (e.g. an alt-drag duplicate of a + variant whose children are component instances). Without tolerance + this guard would miss equivalent geometries and let the `:else` branch + in `update-attrs-on-switch` carry stale `:selrect`/`:points` from the + pre-switch shape onto the freshly instantiated target." [shape origin-shape attr] (or (and (= attr :selrect) - (= (-> shape :selrect :width) (-> origin-shape :selrect :width)) - (= (-> shape :selrect :height) (-> origin-shape :selrect :height))) + (mth/close? (-> shape :selrect :width) (-> origin-shape :selrect :width)) + (mth/close? (-> shape :selrect :height) (-> origin-shape :selrect :height))) (and (= attr :points) (let [normalize-pts (fn [pts] (when (seq pts) (let [f (first pts)] - (mapv #(gpt/subtract % f) pts))))] - (= (normalize-pts (get shape :points)) - (normalize-pts (get origin-shape :points))))))) + (mapv #(gpt/subtract % f) pts)))) + a (normalize-pts (get shape :points)) + b (normalize-pts (get origin-shape :points))] + (and (= (count a) (count b)) + (every? identity (map gpt/close? a b))))))) (defn update-attrs-on-switch @@ -2110,8 +2129,8 @@ (contains? #{:auto-height :auto-width} (:grow-type current-shape)))] (loop [attrs updatable-attrs - roperations [{:type :set-touched :touched (:touched previous-shape)}] - uoperations (list {:type :set-touched :touched (:touched current-shape)})] + roperations [] + uoperations '()] (if-let [attr (first attrs)] (let [sync-group (ctk/resolve-sync-group (:type previous-shape) attr) @@ -2154,7 +2173,7 @@ ;; On texts, both text (the actual letters) ;; and attrs (bold, font, etc) are in the same attr :content. - ;; If only one of them is touched, we want to adress this case and + ;; If only one of them is touched, we want to address this case and ;; only update the untouched one text-change? (and (not skip-operations?) @@ -2253,7 +2272,13 @@ (let [updated-attrs (into #{} (comp (filter #(= :set (:type %))) (map :attr)) - roperations)] + roperations) + updated-sync-groups (into #{} + (keep #(ctk/resolve-sync-group (:type previous-shape) %)) + updated-attrs) + new-touched (set/union (or (:touched current-shape) #{}) updated-sync-groups) + roperations (into [{:type :set-touched :touched new-touched}] roperations) + uoperations (into (list {:type :set-touched :touched (:touched current-shape)}) uoperations)] (cond-> changes (> (count roperations) 1) (-> (add-update-attr-changes current-shape container roperations uoperations) @@ -2727,7 +2752,7 @@ frames))) (defn- duplicate-variant - [changes library component base-pos parent page-id into-new-variant?] + [changes library component base-pos parent page-id into-new-variant? new-component-file] (let [component-page (ctpl/get-page (:data library) (:main-instance-page component)) objects (:objects component-page) component-shape (get objects (:main-instance-id component)) @@ -2741,7 +2766,8 @@ {:apply-changes-local-library? true :delta delta :new-variant-id (if into-new-variant? nil (:id parent)) - :page-id page-id}) + :page-id page-id + :new-component-file new-component-file}) value (when into-new-variant? (str ctv/value-prefix (-> (cfv/extract-properties-values (:data library) objects (:id parent)) @@ -2764,15 +2790,18 @@ (defn generate-duplicate-component-change - [changes objects page main parent-id frame-id delta libraries library-data ids-map] - (let [main-id (:id main) - component-id (:component-id main) - file-id (:component-file main) - component (ctf/get-component libraries file-id component-id) - pos (as-> (gsh/move main delta) $ - (gpt/point (:x $) (:y $))) + [changes objects page main parent-id frame-id delta libraries library-data ids-map & {:keys [new-component-file]}] + (let [main-id (:id main) + component-id (:component-id main) + ;; Source library file id (where the component was originally + ;; defined). Renamed from `file-id` to make the contrast with + ;; `new-component-file` explicit when duplicating across files. + source-file-id (:component-file main) + component (ctf/get-component libraries source-file-id component-id) + pos (as-> (gsh/move main delta) $ + (gpt/point (:x $) (:y $))) - parent (get objects parent-id) + parent (get objects parent-id) ;; When we duplicate a variant alone, we will instanciate it @@ -2799,25 +2828,27 @@ (and (ctk/is-variant? main) in-variant-container?) (duplicate-variant changes - (get libraries file-id) + (get libraries source-file-id) component pos parent (:id page) - false) + false + new-component-file) (ctk/is-variant-container? parent) (duplicate-variant changes - (get libraries file-id) + (get libraries source-file-id) component pos parent (:id page) - true) + true + new-component-file) :else (generate-instantiate-component changes objects - file-id + source-file-id component-id pos page @@ -2841,7 +2872,7 @@ changes (ctf/is-main-of-known-component? obj libraries) - (generate-duplicate-component-change changes objects page obj parent-id frame-id delta libraries library-data ids-map) + (generate-duplicate-component-change changes objects page obj parent-id frame-id delta libraries library-data ids-map {:new-component-file file-id}) :else (let [frame? (cfh/frame-shape? obj) diff --git a/common/src/app/common/logic/shapes.cljc b/common/src/app/common/logic/shapes.cljc index f350913987..70c9b63fde 100644 --- a/common/src/app/common/logic/shapes.cljc +++ b/common/src/app/common/logic/shapes.cljc @@ -75,7 +75,7 @@ (reduce check-shape changes mod-obj-changes))) (defn generate-update-shapes - [changes ids update-fn objects {:keys [attrs changed-sub-attr ignore-tree ignore-touched with-objects?]}] + [changes ids update-fn objects {:keys [attrs changed-sub-attr ignore-tree ignore-touched with-objects? translation?]}] (let [changes (reduce (fn [changes id] (let [opts {:attrs attrs @@ -83,16 +83,20 @@ :ignore-touched ignore-touched :with-objects? with-objects?}] (pcb/update-shapes changes [id] update-fn (d/without-nils opts)))) - (-> changes - (pcb/with-objects objects)) + (cond-> changes + (some? objects) (pcb/with-objects objects)) ids) - grid-ids (->> ids (filter (partial ctl/grid-layout? objects))) - changes (-> changes - (pcb/update-shapes grid-ids ctl/assign-cell-positions {:with-objects? true}) - (pcb/reorder-grid-children ids) - (cond-> - (not ignore-touched) - (generate-unapply-tokens objects changed-sub-attr)))] + ;; Translation doesn't shift children between grid cells, so + ;; cell reassignment + child reorder are no-ops. + grid-ids (when-not translation? + (->> ids (filter (partial ctl/grid-layout? objects)))) + changes (cond-> changes + (seq grid-ids) + (-> (pcb/update-shapes grid-ids ctl/assign-cell-positions {:with-objects? true}) + (pcb/reorder-grid-children ids)) + + (not ignore-touched) + (generate-unapply-tokens objects changed-sub-attr))] changes)) (defn- generate-update-shape-flags diff --git a/common/src/app/common/media.cljc b/common/src/app/common/media.cljc index fc349765a2..d047fb5106 100644 --- a/common/src/app/common/media.cljc +++ b/common/src/app/common/media.cljc @@ -13,8 +13,7 @@ #{"font/ttf" "font/woff" "font/woff2" - "font/otf" - "font/opentype"}) + "font/otf"}) (def image-types #{"image/jpeg" @@ -114,3 +113,15 @@ 800 "Extra Bold" 900 "Black" 950 "Extra Black")) + +(defn font-display-variant + [variant-name weight style] + (cond + (and (string? variant-name) (not (str/blank? variant-name))) + (str/trim variant-name) + + :else + (let [base (font-weight->name weight) + italic? (= "italic" style)] + (cond-> base + italic? (str " Italic"))))) diff --git a/common/src/app/common/spec.cljc b/common/src/app/common/spec.cljc index 38af563499..d6f0d6cacc 100644 --- a/common/src/app/common/spec.cljc +++ b/common/src/app/common/spec.cljc @@ -113,12 +113,19 @@ (tgen/fmap keyword))))) ;; --- SPEC: email +;; +;; Regex rules enforced: +;; local part - valid RFC chars, no leading/trailing dot, no consecutive dots +;; domain - labels can't start/end with hyphen, no empty labels +;; TLD - at least 2 alphabetic chars -(def email-re #"[a-zA-Z0-9_.+-\\\\]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+") +(def email-re + #"^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,63}$") (defn parse-email [s] - (some->> s (re-seq email-re) first)) + (when (and (string? s) (re-matches email-re s)) + s)) (letfn [(conformer [v] (or (parse-email v) ::s/invalid)) @@ -126,11 +133,10 @@ (dm/str v))] (s/def ::email (s/with-gen (s/conformer conformer unformer) - #(as-> (tgen/let [p1 (s/gen ::not-empty-string) - p2 (s/gen ::not-empty-string) - p3 (tgen/elements ["com" "net"])] - (str p1 "@" p2 "." p3)) $ - (tgen/such-that (partial re-matches email-re) $ 50))))) + #(tgen/let [local (tgen/string-alphanumeric 1 20) + label (tgen/string-alphanumeric 2 10) + tld (tgen/elements ["com" "net" "org" "io" "co" "dev"])] + (str local "@" label "." tld))))) ;; -- SPEC: uri diff --git a/common/src/app/common/text.cljc b/common/src/app/common/text.cljc index e0ed3515e6..cc997f62d6 100644 --- a/common/src/app/common/text.cljc +++ b/common/src/app/common/text.cljc @@ -37,7 +37,9 @@ (defn attrs-to-styles [attrs] (reduce-kv (fn [res k v] - (conj res (encode-style k v))) + (if (some? v) + (conj res (encode-style k v)) + res)) #{} attrs)) diff --git a/common/src/app/common/time.cljc b/common/src/app/common/time.cljc index d73225008c..9549695161 100644 --- a/common/src/app/common/time.cljc +++ b/common/src/app/common/time.cljc @@ -202,6 +202,24 @@ (zero? result) false :else false))) +(defn is-after-or-equal? + "Analgous to: da >= db" + [da db] + (let [result (compare da db)] + (cond + (neg? result) false + (zero? result) true + :else true))) + +(defn is-before-or-equal? + "Analgous to: da <= db" + [da db] + (let [result (compare da db)] + (cond + (neg? result) true + (zero? result) true + :else false))) + (defn inst? [o] #?(:clj (instance? Instant o) diff --git a/common/src/app/common/types/color.cljc b/common/src/app/common/types/color.cljc index ae56250d96..396f914645 100644 --- a/common/src/app/common/types/color.cljc +++ b/common/src/app/common/types/color.cljc @@ -192,6 +192,9 @@ (def ^:const background-quaternary "#2e3434") (def ^:const background-quaternary-light "#eef0f2") (def ^:const canvas "#E8E9EA") +(def ^:const default-pixel-grid-color "#0070E4") + +(def ^:const default-pixel-grid-opacity 0.2) (def names {"aliceblue" "#f0f8ff" @@ -610,6 +613,36 @@ [hsv] (-> hsv hsv->hex hex->hsl)) +;; HSB (Hue, Saturation, Brightness) — same color model as HSV but with +;; the brightness component normalized to a 0-100 range, matching Figma, +;; Sketch, and Adobe XD conventions. Internally we reuse the HSV math and +;; only rescale the brightness axis. + +(defn rgb->hsb + [rgb] + (let [[h s v] (rgb->hsv rgb)] + [h s (* (/ v 255.0) 100.0)])) + +(defn hsb->rgb + [[h s b]] + (hsv->rgb [h s (int (* (/ b 100.0) 255.0))])) + +(defn hex->hsb + [v] + (-> v hex->rgb rgb->hsb)) + +(defn hsb->hex + [hsb] + (-> hsb hsb->rgb rgb->hex)) + +(defn hsv->hsb + [[h s v]] + [h s (* (/ v 255.0) 100.0)]) + +(defn hsb->hsv + [[h s b]] + [h s (int (* (/ b 100.0) 255.0))]) + (defn expand-hex [v] (cond diff --git a/common/src/app/common/types/container.cljc b/common/src/app/common/types/container.cljc index c3aab0df4e..7ae9e0e074 100644 --- a/common/src/app/common/types/container.cljc +++ b/common/src/app/common/types/container.cljc @@ -259,7 +259,7 @@ (some? (find-component-main objects shape only-direct-child?)))) (defn in-any-component? - "Check if the shape is part of any component (main or copy), wether it's + "Check if the shape is part of any component (main or copy), whether it's head or not." [objects shape] (or (ctk/in-component-copy? shape) @@ -486,65 +486,80 @@ ;; or inside its main component if it's in a copy. comps-nesting-loop?))) +(defn parent-validation-cache + "Pre-computes the `children`-derived data for `find-valid-parent-and-frame-ids`. + Build once per gesture and pass as `cache`; values are delays so unused + branches stay unrealized." + [objects children libraries] + (let [children-ids (set (map :id children)) + top-children (remove #(contains? children-ids (:parent-id %)) children) + all-main? (every? ctk/main-instance? top-children) + get-variant-id (fn [shape] + (when (:component-id shape) + (-> (get-component-from-shape shape libraries) + :variant-id))) + descendants (delay (mapcat #(cfh/get-children-with-self objects %) children-ids)) + any-variant-container-descendant (delay (some ctk/is-variant-container? @descendants)) + descendants-variant-ids-set (delay (->> @descendants + (map get-variant-id) + set)) + any-main-descendant + (delay + (some + (fn [shape] + (some ctk/main-instance? (cfh/get-children-with-self objects (:id shape)))) + children))] + {:top-children top-children + :all-main? all-main? + :descendants descendants + :any-variant-container-descendant any-variant-container-descendant + :descendants-variant-ids-set descendants-variant-ids-set + :any-main-descendant any-main-descendant})) + (defn find-valid-parent-and-frame-ids "Navigate trough the ancestors until find one that is valid. Returns [ parent-id frame-id ]" ([parent-id objects children] - (find-valid-parent-and-frame-ids parent-id objects children false nil)) + (find-valid-parent-and-frame-ids parent-id objects children false nil nil)) ([parent-id objects children pasting? libraries] - (letfn [(get-frame [parent-id] - (if (cfh/frame-shape? objects parent-id) parent-id (get-in objects [parent-id :frame-id])))] - (let [parent (get objects parent-id) + (find-valid-parent-and-frame-ids parent-id objects children pasting? libraries nil)) + ([parent-id objects children pasting? libraries cache] + (letfn [(get-frame [pid] + (if (cfh/frame-shape? objects pid) pid (get-in objects [pid :frame-id])))] + ;; Predicates below are ordered so cheap parent/ascendant checks + ;; short-circuit before the descendant delays are forced. + (let [{:keys [top-children all-main? any-variant-container-descendant + descendants-variant-ids-set any-main-descendant]} + (or cache (parent-validation-cache objects children libraries))] - ;; We need to check only the top shapes - children-ids (set (map :id children)) - top-children (remove #(contains? children-ids (:parent-id %)) children) + (loop [parent-id parent-id] + (let [parent (get objects parent-id) - ;; We can always move the children to the parent they already have. - ;; But if we are pasting, those are new items, so it is considered a change - no-changes? - (and (every? #(= parent-id (:parent-id %)) top-children) - (not pasting?)) + ;; We can always move the children to the parent they already have. + ;; But if we are pasting, those are new items, so it is considered a change + no-changes? + (and (every? #(= parent-id (:parent-id %)) top-children) + (not pasting?)) - ;; Are all the top-children a main-instance of a component? - all-main? - (every? ctk/main-instance? top-children) + ascendants (cfh/get-parents-with-self objects parent-id) + any-main-ascendant (some ctk/main-instance? ascendants) + any-variant-container-ascendant (some ctk/is-variant-container? ascendants)] - ascendants (cfh/get-parents-with-self objects parent-id) - any-main-ascendant (some ctk/main-instance? ascendants) - any-variant-container-ascendant (some ctk/is-variant-container? ascendants) - - get-variant-id (fn [shape] - (when (:component-id shape) - (-> (get-component-from-shape shape libraries) - :variant-id))) - - descendants (mapcat #(cfh/get-children-with-self objects %) children-ids) - any-variant-container-descendant (some ctk/is-variant-container? descendants) - descendants-variant-ids-set (->> descendants - (map get-variant-id) - set) - any-main-descendant - (some - (fn [shape] - (some ctk/main-instance? (cfh/get-children-with-self objects (:id shape)))) - children)] - - (if (or no-changes? - (and (not (invalid-structure-for-component? objects parent children pasting? libraries)) - ;; If we are moving (not pasting) into a main component, no descendant can be main - (or pasting? (nil? any-main-descendant) (not (ctk/main-instance? parent))) - ;; Don't allow variant-container inside variant container nor main - (or (not any-variant-container-descendant) - (and (not any-variant-container-ascendant) (not any-main-ascendant))) - ;; If the parent is a variant-container, all the items should be main - (or (not (ctk/is-variant-container? parent)) all-main?) - ;; If we are pasting, the parent can't be a "brother" of any of the pasted items, - ;; so not have the same variant-id of any descendant - (or (not pasting?) - (not (ctk/is-variant? parent)) - (not (contains? descendants-variant-ids-set (:variant-id parent)))))) - [parent-id (get-frame parent-id)] - (recur (:parent-id parent) objects children pasting? libraries)))))) + (if (or no-changes? + (and (not (invalid-structure-for-component? objects parent children pasting? libraries)) + ;; If we are moving (not pasting) into a main component, no descendant can be main + (or pasting? (not (ctk/main-instance? parent)) (nil? @any-main-descendant)) + ;; Don't allow variant-container inside variant container nor main + (or (and (not any-variant-container-ascendant) (not any-main-ascendant)) + (not @any-variant-container-descendant)) + ;; If the parent is a variant-container, all the items should be main + (or (not (ctk/is-variant-container? parent)) all-main?) + ;; If we are pasting, the parent can't be a "brother" of any of the pasted items, + ;; so not have the same variant-id of any descendant + (or (not pasting?) + (not (ctk/is-variant? parent)) + (not (contains? @descendants-variant-ids-set (:variant-id parent)))))) + [parent-id (get-frame parent-id)] + (recur (:parent-id parent))))))))) ;; --- SHAPE UPDATE diff --git a/common/src/app/common/types/font.cljc b/common/src/app/common/types/font.cljc new file mode 100644 index 0000000000..61ee5a6059 --- /dev/null +++ b/common/src/app/common/types/font.cljc @@ -0,0 +1,21 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.common.types.font + (:require + [app.common.schema :as sm])) + +(def ^:private font-family-re + ;; \p{L} (Unicode letter) works in Java regex natively, but in JavaScript it + ;; requires the "u" flag which ClojureScript regex literals don't support. + #?(:clj #"[\p{L}\d _.-]+" + :cljs (js/RegExp. "[\\p{L}\\d _.-]+" "u"))) + +(def schema:font-family + [:and + [::sm/text {:max 250}] + [:fn {:error/code "errors.font-family-invalid-chars"} + (fn [s] (boolean (re-matches font-family-re s)))]]) diff --git a/common/src/app/common/types/nitrate_permissions.cljc b/common/src/app/common/types/nitrate_permissions.cljc new file mode 100644 index 0000000000..fa7f72e741 --- /dev/null +++ b/common/src/app/common/types/nitrate_permissions.cljc @@ -0,0 +1,96 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.common.types.nitrate-permissions) + +(def ^:private defaults + {:create-teams "any" + :delete-teams "onlyOwners" + :move-teams "always" + :send-invitations "ownersAndAdmins"}) + +(defn- can-create-team? + [{:keys [is-org-owner? permission-value]}] + (or is-org-owner? + (= permission-value "any"))) + +(defn- can-delete-team? + [{:keys [is-org-owner? permission-value team-perms allow-org-owner-delete?]}] + (cond + (= permission-value "onlyMe") + (and allow-org-owner-delete? is-org-owner?) + (= permission-value "onlyOwners") + (boolean (:is-owner team-perms)) + :else false)) + +(defn- can-move-team? + [{:keys [permission-value target-org-same-owner?]}] + (cond + (= permission-value "never") + false + (= permission-value "always") + true + (= permission-value "myOrganizations") + (true? target-org-same-owner?) + :else false)) + +(defn- can-invite-to-team? + [{:keys [permission-value team-perms]}] + (cond + (= permission-value "ownersAndAdmins") + (or (boolean (:is-owner team-perms)) + (boolean (:is-admin team-perms))) + + (= permission-value "owners") + (boolean (:is-owner team-perms)) + + :else false)) + +(def ^:private action-rules + {:create-team {:permission-key :create-teams + :check-fn can-create-team?} + :delete-team {:permission-key :delete-teams + :check-fn can-delete-team?} + :move-team {:permission-key :move-teams + :check-fn can-move-team?} + :send-invitations {:permission-key :send-invitations + :check-fn can-invite-to-team?}}) + +(defn- normalize-org-permissions + [org-perms] + (merge defaults (or (:permissions org-perms) {}))) + +(defn- owner? + [org-perms profile-id] + (= profile-id (:owner-id org-perms))) + +(defn allowed? + "Returns true only for explicitly allowed actions (fail-closed)." + [action {:keys [org-perms profile-id team-perms allow-org-owner-delete? target-org-same-owner?]}] + (let [{:keys [permission-key check-fn] :as rule} + (get action-rules action) + permissions (normalize-org-permissions org-perms) + is-org-owner? (owner? org-perms profile-id) + permission-value (get permissions permission-key)] + (cond + (nil? rule) false + :else (boolean (check-fn {:is-org-owner? is-org-owner? + :permission-value permission-value + :team-perms team-perms + :allow-org-owner-delete? allow-org-owner-delete? + :target-org-same-owner? target-org-same-owner?}))))) + +(defn can-send-invitations? + [{:keys [nitrate-enabled? organization profile-id team-permissions]}] + (let [in-org? (and nitrate-enabled? organization)] + (if in-org? + (allowed? :send-invitations + {:org-perms {:owner-id (:owner-id organization) + :permissions (:permissions organization)} + :profile-id profile-id + :team-perms team-permissions}) + (or (boolean (:is-owner team-permissions)) + (boolean (:is-admin team-permissions)))))) diff --git a/common/src/app/common/types/organization.cljc b/common/src/app/common/types/organization.cljc new file mode 100644 index 0000000000..7a817d9e8b --- /dev/null +++ b/common/src/app/common/types/organization.cljc @@ -0,0 +1,52 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.common.types.organization + (:require + [app.common.schema :as sm])) + +(def schema:organization + [:map + [:id ::sm/uuid] + [:name ::sm/text] + [:slug ::sm/text] + [:owner-id ::sm/uuid] + [:avatar-bg-url ::sm/uri] + [:logo-id {:optional true} [:maybe ::sm/uuid]] + [:expired-license {:optional true} [:maybe :boolean]] + [:permissions {:optional true} + [:maybe [:map + [:create-teams {:optional true} [:maybe [:enum "any" "onlyMe"]]] + [:delete-teams {:optional true} [:maybe [:enum "onlyMe" "onlyOwners"]]]]]]]) + + +(def schema:team-with-organization + [:map + [:id ::sm/uuid] + [:is-your-penpot :boolean] + [:organization schema:organization]]) + +(def organization->team-keys + "Organization field keys to include in the nested :organization map." + [:id :name :custom-photo :slug :avatar-bg-url :owner-id :expired-license :permissions]) + +(defn apply-organization + "Updates a team map with organization fields in a nested :organization map. + Associates each org field within :organization when the value is non-nil; + dissociates the field otherwise. This correctly handles both attaching an org + (all values present) and detaching one (org is nil or all fields absent)." + [team organization] + (let [id (:id organization)] + (if id + (assoc team :organization + (reduce (fn [acc k] + (let [v (get organization k)] + (if (some? v) + (assoc acc k v) + (dissoc acc k)))) + (or (:organization team) {}) + organization->team-keys)) + (dissoc team :organization)))) diff --git a/common/src/app/common/types/page.cljc b/common/src/app/common/types/page.cljc index 0f3e05f97a..e6fa26a006 100644 --- a/common/src/app/common/types/page.cljc +++ b/common/src/app/common/types/page.cljc @@ -59,6 +59,10 @@ [:guides {:optional true} schema:guides] [:plugin-data {:optional true} ctpg/schema:plugin-data] [:background {:optional true} ctc/schema:hex-color] + ;; Per-page pixel grid color. Falls back to a hardcoded default when + ;; unset so existing files render identically to before. + [:pixel-grid-color {:optional true} ctc/schema:hex-color] + [:pixel-grid-opacity {:optional true} ::sm/safe-number] [:comment-thread-positions {:optional true} [:map-of ::sm/uuid schema:comment-thread-position]]]) diff --git a/common/src/app/common/types/path.cljc b/common/src/app/common/types/path.cljc index f3b7c635ab..601de4c36d 100644 --- a/common/src/app/common/types/path.cljc +++ b/common/src/app/common/types/path.cljc @@ -84,6 +84,19 @@ (-> (subpath/close-subpaths content) (impl/from-plain))) +(defn merge-touching-subpaths + "Given a content, fold consecutive subpaths whose endpoints coincide + into a single continuous subpath, returning a PathData instance. + + Conservative counterpart of `close-subpaths`: only adjacent subpaths + are merged and none are reversed, so fill rules and stroke-dasharray + semantics are preserved. Used at SVG-import time on stroke-only paths + to recover the `stroke-linejoin` rendering when authoring tools split + a continuous polyline into adjacent `M..L M..L` subpaths." + [content] + (-> (subpath/merge-touching-subpaths content) + (impl/from-plain))) + (defn apply-content-modifiers "Apply delta modifiers over the path content" [content modifiers] diff --git a/common/src/app/common/types/path/subpath.cljc b/common/src/app/common/types/path/subpath.cljc index b7f13a0aea..12065891e6 100644 --- a/common/src/app/common/types/path/subpath.cljc +++ b/common/src/app/common/types/path/subpath.cljc @@ -128,6 +128,36 @@ (def ^:private xf-mapcat-data (mapcat :data)) +(defn- join-adjacent + "Fold neighbouring subpaths into the accumulator only when the + current accumulator's end-point matches the next subpath's start-point. + Unlike `merge-paths` this does not reverse subpaths nor reorder them; + the original draw order is preserved so stroke-dasharray and animation + semantics stay intact." + [acc subpath] + (if-let [prev (peek acc)] + (if (and (not (is-closed? prev)) + (not (is-closed? subpath)) + (pt= (:to prev) (:from subpath))) + (conj (pop acc) (subpaths-join prev subpath)) + (conj acc subpath)) + (conj acc subpath))) + +(defn merge-touching-subpaths + "Merge consecutive subpaths whose endpoints coincide into a single + continuous subpath, preserving the original drawing order. + + This is a conservative variant of `close-subpaths`: it never reverses + a subpath and only merges immediate neighbours, so closed regions and + fill semantics are left untouched. The intent is to recover the + `stroke-linejoin` rendering for SVG paths whose authoring tools split + a continuous polyline into adjacent `M..L M..L` subpaths (e.g. the + `m0 0` markers Figma emits when exporting Heroicons-like icons)." + [content] + (let [subpaths (get-subpaths content) + merged (reduce join-adjacent [] subpaths)] + (into [] xf-mapcat-data merged))) + (defn close-subpaths "Searches a path for possible subpaths that can create closed loops and merge them" [content] diff --git a/common/src/app/common/types/shape.cljc b/common/src/app/common/types/shape.cljc index 5f6ac22ad3..93e4db19e3 100644 --- a/common/src/app/common/types/shape.cljc +++ b/common/src/app/common/types/shape.cljc @@ -137,6 +137,8 @@ [:stroke-style {:optional true} [::sm/one-of #{:solid :dotted :dashed :mixed}]] [:stroke-width {:optional true} ::sm/safe-number] + [:stroke-dash {:optional true} ::sm/safe-number] + [:stroke-gap {:optional true} ::sm/safe-number] [:stroke-alignment {:optional true} [::sm/one-of #{:center :inner :outer}]] [:stroke-cap-start {:optional true} diff --git a/common/src/app/common/types/shape/attrs.cljc b/common/src/app/common/types/shape/attrs.cljc index 7bdc3c17a3..ce4ef75f38 100644 --- a/common/src/app/common/types/shape/attrs.cljc +++ b/common/src/app/common/types/shape/attrs.cljc @@ -45,6 +45,8 @@ :stroke-style :stroke-alignment :stroke-width + :stroke-dash + :stroke-gap :stroke-color :stroke-color-ref-id :stroke-color-ref-file @@ -151,6 +153,8 @@ :stroke-style :stroke-alignment :stroke-width + :stroke-dash + :stroke-gap :stroke-color :stroke-color-ref-id :stroke-color-ref-file @@ -206,6 +210,8 @@ :stroke-style :stroke-alignment :stroke-width + :stroke-dash + :stroke-gap :stroke-color :stroke-color-ref-id :stroke-color-ref-file @@ -261,6 +267,8 @@ :stroke-style :stroke-alignment :stroke-width + :stroke-dash + :stroke-gap :stroke-color :stroke-color-ref-id :stroke-color-ref-file @@ -315,6 +323,8 @@ :stroke-style :stroke-alignment :stroke-width + :stroke-dash + :stroke-gap :stroke-color :stroke-color-ref-id :stroke-color-ref-file @@ -433,6 +443,8 @@ :stroke-style :stroke-alignment :stroke-width + :stroke-dash + :stroke-gap :stroke-color :stroke-color-ref-id :stroke-color-ref-file @@ -488,6 +500,8 @@ :stroke-style :stroke-alignment :stroke-width + :stroke-dash + :stroke-gap :stroke-color :stroke-color-ref-id :stroke-color-ref-file diff --git a/common/src/app/common/types/shape/layout.cljc b/common/src/app/common/types/shape/layout.cljc index a3c9e31ed6..a8af8d90cc 100644 --- a/common/src/app/common/types/shape/layout.cljc +++ b/common/src/app/common/types/shape/layout.cljc @@ -1074,7 +1074,7 @@ (maybe-remove?))))) (defn check-deassigned-cells - "Clean the cells whith shapes that are no longer in the layout" + "Clean the cells with shapes that are no longer in the layout" [parent objects] (let [child-set (set (:shapes parent)) diff --git a/common/src/app/common/types/stroke.cljc b/common/src/app/common/types/stroke.cljc index ef52ccefd8..b68a9d3775 100644 --- a/common/src/app/common/types/stroke.cljc +++ b/common/src/app/common/types/stroke.cljc @@ -6,7 +6,7 @@ (ns app.common.types.stroke (:require - [app.common.colors :as clr])) + [app.common.types.color :as clr])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; SCHEMAS diff --git a/common/src/app/common/types/team.cljc b/common/src/app/common/types/team.cljc index ad9bac999c..bd33ab14a0 100644 --- a/common/src/app/common/types/team.cljc +++ b/common/src/app/common/types/team.cljc @@ -20,9 +20,16 @@ (def schema:role [::sm/one-of {:title "TeamRole"} valid-roles]) +(def schema:team-name + [:and + [::sm/text {:max 250}] + [:fn {:error/code "errors.team-name-invalid-chars"} + (fn [s] (not (re-find #"[.:/]" s)))]]) + ;; FIXME: specify more fields (def schema:team [:map {:title "Team"} [:id ::sm/uuid] [:name :string]]) + diff --git a/common/src/app/common/types/text.cljc b/common/src/app/common/types/text.cljc index d9cd5488dc..05516a7295 100644 --- a/common/src/app/common/types/text.cljc +++ b/common/src/app/common/types/text.cljc @@ -95,7 +95,9 @@ :text-direction "ltr"}) (def default-text-attrs - {:font-id "sourcesanspro" + {:typography-ref-file nil + :typography-ref-id nil + :font-id "sourcesanspro" :font-family "sourcesanspro" :font-variant-id "regular" :font-size "14" @@ -240,8 +242,11 @@ acc) :else - ;; If the key is not :text, and they are different, it is an attribute differece - (if (not= v1 v2) + ;; If the key is not :text, and they are different, it is an attribute difference. + ;; Take into account that some processes remove empty attributes, so in some + ;; cases we will compare [] with nil, and this is not a difference. + (if (and (not= v1 v2) + (or (d/not-empty? v1) (d/not-empty? v2))) (attribute-cb acc k) acc)))) #{} diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index 1ece712296..31657983d3 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -554,10 +554,17 @@ :opacity [:opacity] :stroke-width [:stroke-width :dimensions] :font-size [:font-size] + :font-weight [:font-weight] + :text-decoration [:text-decoration] + :text-case [:text-case] :letter-spacing [:letter-spacing] + :dimensions [:dimensions] :fill [:color] :stroke-color [:color] - :typography [:typography]}) + :typography [:typography] + :number [:number] + :sizing [:sizing :dimensions] + :spacing [:spacing :dimensions]}) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; HELPERS for tokens application diff --git a/common/src/app/common/types/tokens_lib.cljc b/common/src/app/common/types/tokens_lib.cljc index a54a243bab..699d1d7d9a 100644 --- a/common/src/app/common/types/tokens_lib.cljc +++ b/common/src/app/common/types/tokens_lib.cljc @@ -74,13 +74,19 @@ modified-at) (rename [this new-name] - (assoc this :name new-name)) + (assoc this + :name new-name + :modified-at (ct/now))) (reid [this new-id] - (assoc this :id new-id)) + (assoc this + :id new-id + :modified-at (ct/now))) (set-description [this new-description] - (assoc this :description new-description))) + (assoc this + :description new-description + :modified-at (ct/now)))) (defmethod pp/simple-dispatch Token [^Token obj] @@ -1154,25 +1160,26 @@ Will return a value that matches this schema: (if-let [theme (get-theme this id)] (let [group (:group theme) name (:name theme) - theme' (-> (make-token-theme (f theme)) - (assoc :modified-at (ct/now))) - group' (:group theme') - name' (:name theme') - same-group? (= group group') - same-name? (= name name') - same-path? (and same-group? same-name?)] - (TokensLib. sets - (if same-path? - (update themes group' assoc name' theme') - (-> themes - (d/oassoc-in-before [group name] [group' name'] theme') - (d/dissoc-in [group name]))) - (if same-path? - active-themes - (disj active-themes (join-theme-path group name))))) + theme' (make-token-theme (f theme))] + (if (= theme theme') + this + (let [theme' (assoc theme' :modified-at (ct/now)) + group' (:group theme') + name' (:name theme') + same-group? (= group group') + same-name? (= name name') + same-path? (and same-group? same-name?)] + (TokensLib. sets + (if same-path? + (update themes group' assoc name' theme') + (-> themes + (d/oassoc-in-before [group name] [group' name'] theme') + (d/dissoc-in [group name]))) + (if same-path? + active-themes + (disj active-themes (join-theme-path group name))))))) this)) - (delete-theme [this id] (let [theme (get-theme this id) [group name] [(:group theme) (:name theme)]] @@ -1477,49 +1484,63 @@ Will return a value that matches this schema: (rename copy-name) (reid (uuid/next)))))) -(defn- token-name->path-selector - "Splits token-name into map with `:path` and `:selector` using `token-name->path`. - - `:selector` is the last item of the names path - `:path` is everything leading up the the `:selector`." - [token-name] - (let [path-segments (get-token-path {:name token-name}) - last-idx (dec (count path-segments)) - [path [selector]] (split-at last-idx path-segments)] - {:path (seq path) - :selector selector})) - (defn token-name-path-exists? - "Traverses the path from `token-name` down a `tokens-tree` and checks if a token at that path exists. + "Check if a token name or fragment exists in any part of the library, to prevent creating + duplicated names that may clash when merging sets and resolving tokens. - It's not allowed to create a token inside a token. E.g.: - Creating a token with + Matches any combination of of names completely included inside group or token names. + For example: + - Matches the name \"foo.bar\" with an existing token named \"foo.bar.baz\" or \"foo\". + - Does not match the name \"foo.bar\" with an existing token named \"foo.baz\". - {:name \"foo.bar\"} + You can give a current set id, and it will check if there is a token with the exact same + name in this set (there may be tokens with same name in different sets for overriding + values, but not in the same set). You can also give a token id to ignore, to search for + a token that is a different one. - in the tokens tree: + If the function finds a match, it returns the part of the name that is duplicated; + if not, it returns null." + [token-name tokens-lib current-set-id token-id-to-ignore] + (letfn [(exists-in-set? + [set] + (let [tokens-tree (-> set (get-tokens-) (tokens-tree)) + token-name-path (get-token-path {:name token-name})] + (loop [path-segment token-name-path + subtree tokens-tree] + (if (empty? path-segment) + ;; All path segments found -> return full name + token-name + (let [node (get subtree (first path-segment))] + (cond + ;; Path segment doesn't exist + (nil? node) nil + ;; A token exists at this path + (token? node) + (if (and (some? token-id-to-ignore) + (= (get-id node) token-id-to-ignore)) + ;; This is the token to ignore + nil + (if (and (not= (get-id set) current-set-id) + (= (get-name node) token-name)) + ;; A token with the same name in a different set is allowed + nil + ;; If we are in the same set or the name of the token is a subpath of the + ;; current name: this is a conflict + ;; -> return the part of the name until this point + (str/join "." (take (- (count token-name-path) (count (rest path-segment))) + token-name-path)))) + ;; Continue traversing the tree + :else (recur (rest path-segment) node)))))))] - {\"foo\" {:name \"other\"}}" - [token-name tokens-tree] - (let [{:keys [path selector]} (token-name->path-selector token-name) - path-target (reduce - (fn [acc cur] - (let [target (get acc cur)] - (cond - ;; Path segment doesn't exist yet - (nil? target) (reduced false) - ;; A token exists at this path - (:name target) (reduced true) - ;; Continue traversing the true - :else target))) - tokens-tree - path)] - (cond - (boolean? path-target) path-target - (get path-target :name) true - :else (-> (get path-target selector) - (seq) - (boolean))))) + (if (or (nil? tokens-lib) (empty? (get-sets tokens-lib)) + (nil? token-name) (str/empty? token-name)) + nil + (do + (assert (or (nil? current-set-id) + (some? (get-set tokens-lib current-set-id))) + (str "Set '" current-set-id "' does not exist in the library")) + (assert (or (nil? token-id-to-ignore) (uuid? token-id-to-ignore))) + (some exists-in-set? (get-sets tokens-lib)))))) (defn update-tokens-group "Updates the active tokens path when renaming a group node. @@ -1530,7 +1551,9 @@ Will return a value that matches this schema: active-tokens: map of token-name to token-object for all active tokens in the set current-path: the path of the group being renamed, e.g. \"foo.bar\" current-name: the current name of the group being renamed, e.g. \"bar\" - new-name: the new name for the group being renamed, e.g. \"baz\"" + new-name: the new name for the group being renamed, e.g. \"baz\" + + Returns a sequence of [name token] for each renamed token." [active-tokens current-path current-name new-name] (let [path-prefix (str/replace current-path current-name "")] @@ -1879,7 +1902,11 @@ Will return a value that matches this schema: library (reduce (fn [library name] (if-let [tokens (get sets name)] - (add-set library (make-token-set :name name :tokens tokens)) + (do (doseq [token (vals tokens)] + (when (token-name-path-exists? (get-name token) library nil (get-id token)) + (throw (ex-info (get-name token) + {:error/code :error.import/duplicated-token-name})))) + (add-set library (make-token-set :name name :tokens tokens))) library)) library ordered-set-names) @@ -2136,6 +2163,39 @@ Will return a value that matches this schema: ;; MIGRATIONS HELPERS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(defn update-all-tokens + "Walk through all tokens in the library and apply the given function to them. + The function receives the library, the set and the token as arguments, + and should return the updated token." + [tokens-lib update-fn] + (let [update-one-set + (fn [lib set] + (reduce (fn [lib' token] + (update-token lib' + (get-id set) + (get-id token) + #(update-fn lib' + (get-set lib' (get-id set)) + %))) + lib + (vals (get-tokens lib (get-id set)))))] + (reduce (fn [lib set] + (update-one-set lib set)) + tokens-lib + (get-sets tokens-lib)))) + +(defn update-all-themes + "Walk through all themes in the library and apply the given function to them. + The function receives the library and the theme as arguments, + and should return the updated theme." + [tokens-lib update-fn] + (reduce (fn [lib theme] + (update-theme lib + (get-id theme) + #(update-fn lib %))) + tokens-lib + (get-themes tokens-lib))) + (defn fix-duplicate-token-set-ids "Given an instance of TokensLib fixes it internal sets data sturcture for ensure each set has unique id; @@ -2163,6 +2223,42 @@ Will return a value that matches this schema: (map->tokens-lib) (check))))) +(defn fix-conflicting-token-names + [tokens-lib] + (let [counter (atom 0) + match-suffixes (atom {}) + + generate-name + (fn [name match] + (let [matches (if (contains? @match-suffixes match) + @match-suffixes + (swap! match-suffixes assoc match (swap! counter inc))) + suffix (get matches match)] + (str (str/slice name 0 (count match)) + "-" suffix + (str/slice name (count match)))))] + + (update-all-tokens + tokens-lib + (fn [lib set token] + (let [name (get-name token)] + (if-let [match (token-name-path-exists? name lib (:id set) (get-id token))] + (rename token (generate-name name match)) + token)))))) + +(defn fix-missing-sets-in-themes + [tokens-lib] + (let [existing-set-names (into #{} (map get-name) (get-sets tokens-lib)) + fix-theme-sets + (fn [_ theme] + (let [current-sets (:sets theme) + valid-sets (clojure.set/intersection current-sets existing-set-names)] + (if-not (= valid-sets current-sets) + (assoc theme :sets valid-sets) + theme)))] + + (update-all-themes tokens-lib fix-theme-sets))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; SERIALIZATION (FRESIAN) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -2200,7 +2296,7 @@ Will return a value that matches this schema: #?(:clj (defn- migrate-to-v1-3 "Migrate the TokensLib data structure internals to v1.3 version; it - expects input from v1.2 version" + expects input from v1.2 version" [{:keys [sets themes] :as params}] (let [migrate-token (fn [token] @@ -2248,7 +2344,7 @@ Will return a value that matches this schema: #?(:clj (defn- migrate-to-v1-4 "Migrate the TokensLib data structure internals to v1.4 version; it - expects input from v1.3 version" + expects input from v1.3 version" [params] (let [migrate-set-node (fn recurse [node] diff --git a/common/test/common_tests/attrs_test.cljc b/common/test/common_tests/attrs_test.cljc new file mode 100644 index 0000000000..bab8b9fbaf --- /dev/null +++ b/common/test/common_tests/attrs_test.cljc @@ -0,0 +1,151 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns common-tests.attrs-test + (:require + [app.common.attrs :as attrs] + [clojure.test :as t])) + +(t/deftest get-attrs-multi-same-value + (t/testing "returns value when all objects have the same attribute value" + (let [objs [{:attr "red"} + {:attr "red"} + {:attr "red"}] + result (attrs/get-attrs-multi objs [:attr])] + (t/is (= {:attr "red"} result)))) + + (t/testing "returns nil when all objects have nil value" + (let [objs [{:attr nil} + {:attr nil}] + result (attrs/get-attrs-multi objs [:attr])] + (t/is (= {:attr nil} result))))) + +(t/deftest get-attrs-multi-different-values + (t/testing "returns :multiple when objects have different concrete values" + (let [objs [{:attr "red"} + {:attr "blue"}] + result (attrs/get-attrs-multi objs [:attr])] + (t/is (= {:attr :multiple} result))))) + +(t/deftest get-attrs-multi-missing-key + (t/testing "returns value when one object has the attribute and another doesn't" + (let [objs [{:attr "red"} + {:other "value"}] + result (attrs/get-attrs-multi objs [:attr])] + (t/is (= {:attr "red"} result)))) + + (t/testing "returns value when one object has UUID and another is missing" + (let [uuid #uuid "550e8400-e29b-41d4-a716-446655440000" + objs [{:attr uuid} + {:other "value"}] + result (attrs/get-attrs-multi objs [:attr])] + (t/is (= {:attr uuid} result)))) + + (t/testing "returns :multiple when some objects have the key and some don't" + (let [objs [{:attr "red"} + {:other "value"} + {:attr "blue"}] + result (attrs/get-attrs-multi objs [:attr])] + (t/is (= {:attr :multiple} result)))) + + (t/testing "returns nil when one object has nil and another is missing" + (let [objs [{:attr nil} + {:other "value"}] + result (attrs/get-attrs-multi objs [:attr])] + (t/is (= {:attr nil} result))))) + +(t/deftest get-attrs-multi-all-missing + (t/testing "all missing → attribute NOT included in result" + (let [objs [{:other "value"} + {:different "data"}] + result (attrs/get-attrs-multi objs [:attr])] + (t/is (= {} result) + "Attribute should not be in result when all objects are missing"))) + + (t/testing "all missing with empty maps → attribute NOT included" + (let [objs [{} {}] + result (attrs/get-attrs-multi objs [:attr])] + (t/is (= {} result) + "Attribute should not be in result")))) + +(t/deftest get-attrs-multi-multiple-attributes + (t/testing "handles multiple attributes with different merge results" + (let [objs [{:attr1 "red" :attr2 "blue"} + {:attr1 "red" :attr2 "green"} + {:attr1 "red"}] ; :attr2 missing + result (attrs/get-attrs-multi objs [:attr1 :attr2])] + (t/is (= {:attr1 "red" :attr2 :multiple} result)))) + + (t/testing "handles mixed scenarios: same, different, and missing" + (let [uuid #uuid "550e8400-e29b-41d4-a716-446655440000" + uuid2 #uuid "550e8400-e29b-41d4-a716-446655440001" + objs [{:id :a :ref uuid} + {:id :b :ref uuid2} + {:id :c}] ; :ref missing + result (attrs/get-attrs-multi objs [:id :ref])] + (t/is (= {:id :multiple :ref :multiple} result))))) + +(t/deftest get-attrs-multi-typography-ref-id-scenario + (t/testing "the specific bug scenario: typography-ref-id with UUID vs missing" + (let [uuid #uuid "550e8400-e29b-41d4-a716-446655440000" + ;; Shape 1 has typography-ref-id with a UUID + shape1 {:id :shape1 :typography-ref-id uuid} + ;; Shape 2 does NOT have typography-ref-id at all + shape2 {:id :shape2} + result (attrs/get-attrs-multi [shape1 shape2] [:typography-ref-id])] + (t/is (= {:typography-ref-id uuid} result)))) + + (t/testing "both shapes missing → attribute NOT included in result" + (let [shape1 {:id :shape1} + shape2 {:id :shape2} + result (attrs/get-attrs-multi [shape1 shape2] [:typography-ref-id])] + (t/is (= {} result) + "Expected empty map when all shapes are missing the attribute")))) + +(t/deftest get-attrs-multi-bug-missing-vs-present + (t/testing "BUG FIXED: one shape has :typography-ref-id, other does NOT → returns uuid" + (let [uuid #uuid "550e8400-e29b-41d4-a716-446655440000" + shape1 {:id :shape1 :typography-ref-id uuid} + shape2 {:id :shape2} + result (attrs/get-attrs-multi [shape1 shape2] [:typography-ref-id])] + (t/is (= {:typography-ref-id uuid} result)))) + + (t/testing "both missing → empty map (attribute not in result)" + (let [shape1 {:id :shape1} + shape2 {:id :shape2} + result (attrs/get-attrs-multi [shape1 shape2] [:typography-ref-id])] + (t/is (= {} result) + "Expected empty map when all shapes are missing the attribute"))) + + (t/testing "both equal values → return the value" + (let [uuid #uuid "550e8400-e29b-41d4-a716-446655440000" + shape1 {:id :shape1 :typography-ref-id uuid} + shape2 {:id :shape2 :typography-ref-id uuid} + result (attrs/get-attrs-multi [shape1 shape2] [:typography-ref-id])] + (t/is (= {:typography-ref-id uuid} result)))) + + (t/testing "different values → return :multiple" + (let [uuid1 #uuid "550e8400-e29b-41d4-a716-446655440000" + uuid2 #uuid "550e8400-e29b-41d4-a716-446655440001" + shape1 {:id :shape1 :typography-ref-id uuid1} + shape2 {:id :shape2 :typography-ref-id uuid2} + result (attrs/get-attrs-multi [shape1 shape2] [:typography-ref-id])] + (t/is (= {:typography-ref-id :multiple} result))))) + +(t/deftest get-attrs-multi-default-equal + (t/testing "numbers use close? for equality" + (let [objs [{:value 1.0} + {:value 1.0000001}] + result (attrs/get-attrs-multi objs [:value])] + (t/is (= {:value 1.0} result) + "Numbers within tolerance should be considered equal"))) + + (t/testing "different floating point positions beyond tolerance are :multiple" + (let [objs [{:x -26} + {:x -153}] + result (attrs/get-attrs-multi objs [:x])] + (t/is (= {:x :multiple} result) + "Different positions should be :multiple")))) \ No newline at end of file diff --git a/common/test/common_tests/colors_test.cljc b/common/test/common_tests/colors_test.cljc index 7d6b0f0e3d..5ed2fdeb83 100644 --- a/common/test/common_tests/colors_test.cljc +++ b/common/test/common_tests/colors_test.cljc @@ -113,7 +113,7 @@ (t/deftest ac-rgb-to-hsl ;; Black: h=0, s=0.0, l=0.0 (s is 0.0 not 0 on JVM, and ##NaN for white) - (let [[h s l] (c/rgb->hsl [0 0 0])] + (let [[h _s l] (c/rgb->hsl [0 0 0])] (t/is (= 0 h)) (t/is (mth/close? l 0.0))) ;; White: h=0, s=##NaN (achromatic), l=1.0 diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index dbe015cf89..56b43078dc 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -532,7 +532,12 @@ (t/is (= {nil 0 :b 2} (d/patch-object {nil 0 :a 1 :b 2} {:a nil}))) ;; transducer arity (1-arg returns a fn) (let [f (d/patch-object {:a 99})] - (t/is (= {:a 99 :b 2} (f {:a 1 :b 2}))))) + (t/is (= {:a 99 :b 2} (f {:a 1 :b 2})))) + ;; when object is nil, nil values in changes are stripped (not preserved) + (t/is (= {} (d/patch-object nil {:a nil}))) + (t/is (= {:a 1} (d/patch-object nil {:a 1 :b nil}))) + ;; nested path: patching a key that doesn't exist creates a new map without nils + (t/is (= {:b {:y 2}} (d/patch-object {:b nil} {:b {:x nil :y 2}})))) (t/deftest without-obj-test (t/is (= [1 3] (d/without-obj [1 2 3] 2))) diff --git a/common/test/common_tests/files/shapes_builder_test.cljc b/common/test/common_tests/files/shapes_builder_test.cljc new file mode 100644 index 0000000000..05956918b2 --- /dev/null +++ b/common/test/common_tests/files/shapes_builder_test.cljc @@ -0,0 +1,52 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns common-tests.files.shapes-builder-test + (:require + [app.common.files.shapes-builder :as sb] + [clojure.test :as t])) + +;; Regression for https://github.com/penpot/penpot/issues/7869. +;; ``parse-svg-element`` used to derive the shape name from +;; ``(or (:id attrs) (tag->name tag))`` which dropped Inkscape-authored +;; labels. ``tubax/xml->clj`` (the SVG parser the rest of the import +;; pipeline already feeds these maps to) keeps namespaced attributes as +;; ``:prefix:name`` keywords — same shape the codebase already reads +;; ``:xlink:href`` from in this file (line 134) and in +;; ``app.common.svg``. + +(t/deftest resolve-element-name-prefers-inkscape-label + (t/is (= "Layer 1" + (sb/resolve-element-name :g {:inkscape:label "Layer 1" + :id "g1234"})))) + +(t/deftest resolve-element-name-prefers-sodipodi-label-when-no-inkscape-label + (t/is (= "phone-icon" + (sb/resolve-element-name :path {:sodipodi:label "phone-icon" + :id "path5678"})))) + +(t/deftest resolve-element-name-falls-back-to-id-when-no-label-namespace + (t/is (= "manual-id" + (sb/resolve-element-name :rect {:id "manual-id"})))) + +(t/deftest resolve-element-name-falls-back-to-tag-name-when-no-id-and-no-label + ;; The tag->name mapping returns generic names for known SVG element + ;; tags. Asserting on the call result here (rather than a hardcoded + ;; string) keeps the test stable if the tag->name mapping is updated. + (t/is (some? (sb/resolve-element-name :rect {}))) + (t/is (string? (sb/resolve-element-name :rect {})))) + +(t/deftest resolve-element-name-inkscape-label-wins-over-sodipodi-and-id + ;; Both label conventions and an id present together; the priority is + ;; inkscape > sodipodi > id > tag, matching the order operators expect + ;; (Inkscape's own UI shows ``inkscape:label`` as the canonical name). + (t/is (= "user-name" + (sb/resolve-element-name :g {:inkscape:label "user-name" + :sodipodi:label "stale-label" + :id "g1"})))) + +(t/deftest resolve-element-name-empty-attrs-uses-tag-fallback + (t/is (some? (sb/resolve-element-name :path {})))) diff --git a/common/test/common_tests/logic/comp_creation_test.cljc b/common/test/common_tests/logic/comp_creation_test.cljc index 462734d6ee..ad1277879b 100644 --- a/common/test/common_tests/logic/comp_creation_test.cljc +++ b/common/test/common_tests/logic/comp_creation_test.cljc @@ -304,6 +304,62 @@ (t/is (= (thi/id :main1-child) (:id child1'))) (t/is (not= (thi/id :main1-child) (:id child2'))))) +(t/deftest test-duplicate-component-rewrites-component-file-to-destination + ;; Regression test for Issue #8144. When a component is duplicated + ;; into a different file via `:apply-changes-local-library? true` + ;; and `:new-component-file` is provided, the returned main-instance + ;; shape must carry `:component-file` equal to the destination file + ;; id so the referential-integrity validator + ;; (:component-main-external) is satisfied. + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (tho/add-simple-component :component1 + :main1-root + :main1-child)) + + component (thc/get-component file :component1) + new-component-file (uuid/next) + + ;; ==== Action + [new-shape _] + (cll/generate-duplicate-component (pcb/empty-changes) + file + (:id component) + (uuid/next) + {:apply-changes-local-library? true + :new-component-file new-component-file})] + + ;; ==== Check + (t/is (some? new-shape)) + (t/is (ctk/main-instance? new-shape)) + (t/is (= new-component-file (:component-file new-shape))))) + +(t/deftest test-duplicate-component-keeps-component-file-without-dest + ;; Baseline: when no `:new-component-file` is passed (same-file + ;; duplication), the main-instance's `:component-file` is left + ;; untouched, matching pre-existing behavior. + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (tho/add-simple-component :component1 + :main1-root + :main1-child)) + + component (thc/get-component file :component1) + original-source (:component-file + (ths/get-shape-by-id file (:main-instance-id component))) + + ;; ==== Action + [new-shape _] + (cll/generate-duplicate-component (pcb/empty-changes) + file + (:id component) + (uuid/next) + {:apply-changes-local-library? true})] + + ;; ==== Check + (t/is (some? new-shape)) + (t/is (= original-source (:component-file new-shape))))) + (t/deftest test-delete-component (let [;; ==== Setup file (-> (thf/sample-file :file1) diff --git a/common/test/common_tests/logic/variants_switch_test.cljc b/common/test/common_tests/logic/variants_switch_test.cljc index c991f35ab6..abb53fef70 100644 --- a/common/test/common_tests/logic/variants_switch_test.cljc +++ b/common/test/common_tests/logic/variants_switch_test.cljc @@ -7,6 +7,9 @@ (ns common-tests.logic.variants-switch-test (:require [app.common.files.changes-builder :as pcb] + [app.common.files.helpers :as cfh] + [app.common.geom.point :as gpt] + [app.common.geom.shapes :as gsh] [app.common.logic.shapes :as cls] [app.common.test-helpers.components :as thc] [app.common.test-helpers.compositions :as tho] @@ -14,6 +17,7 @@ [app.common.test-helpers.ids-map :as thi] [app.common.test-helpers.shapes :as ths] [app.common.test-helpers.variants :as thv] + [app.common.types.component :as ctk] [clojure.test :as t])) (t/use-fixtures :each thi/test-fixture) @@ -35,13 +39,13 @@ copy01 (ths/get-shape file :copy01) ;; ==== Action - file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:keep-touched? true}) - copy01' (ths/get-shape file' :copy02)] + copy01' (ths/get-shape file' :copy01)] (thf/dump-file file :keys [:width]) ;; The copy had width 5 before the switch (t/is (= (:width copy01) 5)) - ;; The rect has width 15 after the switch + ;; The copy has width 15 after the switch (t/is (= (:width copy01') 15)))) (t/deftest test-simple-switch @@ -61,15 +65,15 @@ rect01 (get-in page [:objects (-> copy01 :shapes first)]) ;; ==== Action - file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:keep-touched? true}) page' (thf/current-page file') - copy02' (ths/get-shape file' :copy02) - rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + copy01' (ths/get-shape file' :copy01) + rect01' (get-in page' [:objects (-> copy01' :shapes first)])] ;; The rect had width 5 before the switch (t/is (= (:width rect01) 5)) ;; The rect has width 15 after the switch - (t/is (= (:width rect02') 15)))) + (t/is (= (:width rect01') 15)))) ;; ============================================================ ;; SIMPLE ATTRIBUTE OVERRIDES (identical variants) @@ -100,9 +104,9 @@ copy01 (ths/get-shape file :copy01) ;; ==== Action - file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:keep-touched? true}) - copy01' (ths/get-shape file' :copy02)] + copy01' (ths/get-shape file' :copy01)] (thf/dump-file file :keys [:width]) ;; The copy had width 25 before the switch (t/is (= (:width copy01) 25)) @@ -137,16 +141,16 @@ rect01 (get-in page [:objects (:id rect01)]) ;; ==== Action - file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:keep-touched? true}) page' (thf/current-page file') - copy02' (ths/get-shape file' :copy02) - rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + copy01' (ths/get-shape file' :copy01) + rect01' (get-in page' [:objects (-> copy01' :shapes first)])] ;; The rect had width 25 before the switch (t/is (= (:width rect01) 25)) ;; The override is keept: The rect still has width 25 after the switch - (t/is (= (:width rect02') 25)))) + (t/is (= (:width rect01') 25)))) ;; ============================================================ ;; SIMPLE ATTRIBUTE OVERRIDES (different variants) @@ -180,17 +184,193 @@ rect01 (get-in page [:objects (:id rect01)]) ;; ==== Action - file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:keep-touched? true}) page' (thf/current-page file') - copy02' (ths/get-shape file' :copy02) - rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + copy01' (ths/get-shape file' :copy01) + rect01' (get-in page' [:objects (-> copy01' :shapes first)])] ;; The rect had width 25 before the switch (t/is (= (:width rect01) 25)) ;; The override isn't keept, because the property is different in the mains ;; The rect has width 15 after the switch - (t/is (= (:width rect02') 15)))) + (t/is (= (:width rect01') 15)))) + +;; ============================================================ +;; NESTED COPY SWITCH (no overrides) +;; ============================================================ + +(t/deftest test-nested-switch-in-main + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (thv/add-variant + :v01 :c01 :m01 :c02 :m02 + {:variant1-params {:width 5} + :variant2-params {:width 15}}) + + (tho/add-frame :m03) + (thc/instantiate-component :c01 + :copy01 + :parent-label :m03) + (thc/make-component :c03 :m03)) + + copy01 (ths/get-shape file :copy01) + + ;; ==== Action + file' (tho/swap-component-in-shape file :copy01 :c02 {:keep-touched? true}) + + copy01' (ths/get-shape file' :copy01)] + + (thf/dump-file file :keys [:width]) + + ;; The copy had width 5 before the switch + (t/is (= (:width copy01) 5)) + ;; The copy has width 15 after the switch + (t/is (= (:width copy01') 15)) + ;; The copy is not touched but has swap slot + (t/is (= (count (:touched copy01')) 1)) + (t/is (= (ctk/get-swap-slot copy01') (thi/id :copy01))))) + +(t/deftest test-nested-switch-in-copy + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (thv/add-variant + :v01 :c01 :m01 :c02 :m02 + {:variant1-params {:width 5} + :variant2-params {:width 15}}) + + (tho/add-frame :m03) + (thc/instantiate-component :c01 + :nested01 + :parent-label :m03) + (thc/make-component :c03 :m03) + + (thc/instantiate-component :c03 + :nested02 + :children-labels [:child01])) + + child01 (ths/get-shape file :child01) + + ;; ==== Action + file' (tho/swap-component-in-shape file :child01 :c02 {:keep-touched? true}) + + child01' (ths/get-shape file' :child01)] + + (thf/dump-file file :keys [:width]) + + ;; The copy had width 5 before the switch + (t/is (= (:width child01) 5)) + ;; The copy has width 15 after the switch + (t/is (= (:width child01') 15)) + ;; The copy is not touched but has swap slot + (t/is (= (count (:touched child01')) 1)) + (t/is (= (ctk/get-swap-slot child01') (thi/id :nested01))))) + +;; ============================================================ +;; NESTED COPY SWITCH (with overrides) +;; ============================================================ + +(t/deftest test-nested-switch-in-main-with-override + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (thv/add-variant + :v01 :c01 :m01 :c02 :m02 + {:variant1-params {:width 5} + :variant2-params {:width 15}}) + + (tho/add-frame :m03) + (thc/instantiate-component :c01 + :copy01 + :parent-label :m03) + (thc/make-component :c03 :m03)) + + page (thf/current-page file) + fills (ths/sample-fills-color :fill-color "#fabada") + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(thi/id :copy01)} + (fn [shape] + (assoc shape + :width 25 + :fills fills)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + + copy01 (ths/get-shape file :copy01) + + ;; ==== Action + file' (tho/swap-component-in-shape file :copy01 :c02 {:keep-touched? true}) + + copy01' (ths/get-shape file' :copy01)] + + (thf/dump-file file :keys [:width :touched]) + + ;; The copy had fill color before the switch + (t/is (= (:fills copy01) fills)) + ;; The copy still has fill color after the switch + (t/is (= (:fills copy01') fills)) + ;; The copy had width 25 before the switch + (t/is (= (:width copy01) 25)) + ;; The copy gets the switched variant width 15, because this is the value changed in the variant + (t/is (= (:width copy01') 15)) + ;; The copy is fills touched and has swap slot + (t/is (= (count (:touched copy01')) 2)) + (t/is (= (ctk/get-swap-slot copy01') (thi/id :copy01))) + (t/is (contains? (:touched copy01') :fill-group)))) + +(t/deftest test-nested-switch-in-copy-with-override + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (thv/add-variant + :v01 :c01 :m01 :c02 :m02 + {:variant1-params {:width 5} + :variant2-params {:width 15}}) + + (tho/add-frame :m03) + (thc/instantiate-component :c01 + :nested01 + :parent-label :m03) + (thc/make-component :c03 :m03) + + (thc/instantiate-component :c03 + :copy02 + :children-labels [:nested02])) + + page (thf/current-page file) + fills (ths/sample-fills-color :fill-color "#fabada") + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(thi/id :nested02)} + (fn [shape] + (assoc shape + :width 25 + :fills fills)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + + nested02 (ths/get-shape file :nested02) + + ;; ==== Action + file' (tho/swap-component-in-shape file :nested02 :c02 {:keep-touched? true}) + + nested02' (ths/get-shape file' :nested02)] + + (thf/dump-file file :keys [:width]) + + ;; The copy had fill color before the switch + (t/is (= (:fills nested02) fills)) + ;; The copy still has fill color after the switch + (t/is (= (:fills nested02') fills)) + ;; The copy had width 5 before the switch + (t/is (not= (:width nested02) 5)) + ;; The copy gets the switched variant width 15, because this is the value changed in the variant + (t/is (= (:width nested02') 15)) + ;; The copy is fills touched and has swap slot + (t/is (= (count (:touched nested02')) 2)) + (t/is (= (ctk/get-swap-slot nested02') (thi/id :nested01))) + (t/is (contains? (:touched nested02') :fill-group)))) ;; ============================================================ ;; TEXT OVERRIDES (identical variants) @@ -2683,4 +2863,110 @@ ;; Both variants are identical in size (100x50), so the override IS preserved (t/is (= (:width rect02') 150)) ;; The guard must not have suppressed :selrect — it should be consistent - (t/is (= (get-in rect02' [:selrect :width]) 150)))) \ No newline at end of file + (t/is (= (get-in rect02' [:selrect :width]) 150)))) + + +(t/deftest test-switch-when-source-master-child-has-touched-geometry + ;; Regression: when the previous-shape's geometry has sub-pixel drift + ;; relative to its source master (a state produced by interactive transform + ;; modifiers, e.g. alt-drag duplicate of a variant whose children are + ;; component copies), the equal-geometry? guard in update-attrs-on-switch + ;; uses exact equality and fails. The :else branch then copies + ;; previous-shape's :selrect verbatim onto the freshly-instantiated target, + ;; leaving :y correct (the per-attr y skip catches that) but :selrect.y + ;; stale. The shape ends up internally inconsistent (:y disagrees with + ;; :selrect.y); the renderer reads :selrect, so the child appears at the + ;; source variant's position inside a parent that has resized to the + ;; target's dimensions — the visible "cut off" symptom. + (let [;; ==== Setup + ;; A self-contained Input/Button-like component, plus a variant + ;; container whose two variants each instance that component + ;; at different y positions. This mirrors the production setup + ;; where the dragged variant's children are themselves component + ;; copies (and thus carry :touched on geometry within the master). + file (-> (thf/sample-file :file1) + (tho/add-simple-component :btn-comp :btn-root :btn-rect) + (thv/add-variant-with-copy + :v01 :c01 :m01 :c02 :m02 :child1 :child2 :btn-comp)) + + ;; Position child1 at y=101 (in m01) and child2 at y=73 (in m02). + ;; Use gsh/absolute-move so :selrect/:points stay consistent with + ;; :y — a plain (assoc :y …) would leave them out of sync and + ;; produce a different (artificial) failure mode. + page (thf/current-page file) + child1 (ths/get-shape file :child1) + child2 (ths/get-shape file :child2) + changes (-> (pcb/empty-changes nil (:id page)) + (cls/generate-update-shapes + #{(:id child1)} + #(gsh/absolute-move % (gpt/point (:x %) 101)) + (:objects page) {}) + (cls/generate-update-shapes + #{(:id child2)} + #(gsh/absolute-move % (gpt/point (:x %) 73)) + (:objects page) {})) + file (thf/apply-changes file changes) + file (thc/instantiate-component file :c01 :copy01) + + ;; The copy carries an Input/Button instance (Frame1). Introduce + ;; sub-pixel drift in its :width and :selrect.width — the kind of + ;; floating-point error produced by the alt-drag modifier path in + ;; production. This drift is what defeats equal-geometry?'s + ;; exact-equality comparison and lets the bug surface. + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + copy-btn-id (->> (cfh/get-children-ids-with-self (:objects page) (:id copy01)) + (map #(get-in page [:objects %])) + (filter #(= "Frame1" (:name %))) + first :id) + drift 0.00001 + changes (cls/generate-update-shapes + (pcb/empty-changes nil (:id page)) + #{copy-btn-id} + (fn [shape] + (let [w (+ (:width shape) drift) + sr (:selrect shape)] + (-> shape + (assoc :width w) + (assoc :selrect (-> sr + (assoc :width w) + (assoc :x2 (+ (:x1 sr) w))))))) + (:objects page) {}) + file (thf/apply-changes file changes) + m02 (ths/get-shape file :m02) + child2 (ths/get-shape file :child2) + + target-rel-y (- (:y child2) (:y m02)) + + ;; ==== Action + file' (tho/swap-component-in-shape file :copy01 :c02 + {:new-shape-label :copy02 + :keep-touched? true}) + + page' (thf/current-page file') + copy02 (ths/get-shape file' :copy02) + post-btn (->> (cfh/get-children-ids-with-self (:objects page') (:id copy02)) + (map #(get-in page' [:objects %])) + (filter #(= "Frame1" (:name %))) + first) + post-btn-rel-y (- (:y post-btn) (:y copy02)) + post-btn-selrect-rel-y (- (get-in post-btn [:selrect :y]) + (get-in copy02 [:selrect :y]))] + + ;; The post-switch button must sit at the target master's relative y. + ;; Its :y field already does (the per-attr :y skip handles that + ;; correctly); the failure is on :selrect. + (t/is (= target-rel-y post-btn-rel-y) + (str "Child's :y should match target master layout (" target-rel-y ")")) + + ;; The bug: :selrect.y is overwritten with the previous shape's value, + ;; not regenerated from the target master's layout. After fix, this + ;; assertion should pass. + (t/is (= target-rel-y post-btn-selrect-rel-y) + (str ":selrect.y should match target master layout (expected " + target-rel-y " got " post-btn-selrect-rel-y ")")) + + ;; And :y must agree with :selrect.y — a shape whose :y disagrees with + ;; its :selrect.y is internally inconsistent and renders incorrectly. + (t/is (= post-btn-rel-y post-btn-selrect-rel-y) + ":y and :selrect.y must agree after switch"))) diff --git a/common/test/common_tests/media_test.cljc b/common/test/common_tests/media_test.cljc index b6c18aab2d..a41d2466fa 100644 --- a/common/test/common_tests/media_test.cljc +++ b/common/test/common_tests/media_test.cljc @@ -57,3 +57,38 @@ (t/testing "leaves filename intact when it has no extension" (t/is (= (media/strip-image-extension "README") "README")))) + +(t/deftest test-font-display-variant + (t/testing "preserves the foundry-supplied variant string verbatim" + (t/is (= "Thin" (media/font-display-variant "Thin" 100 "normal"))) + (t/is (= "SemiBold" (media/font-display-variant "SemiBold" 600 "normal"))) + (t/is (= "Medium Oblique" (media/font-display-variant "Medium Oblique" 500 "italic"))) + (t/is (= "Ultra" (media/font-display-variant "Ultra" 900 "normal")))) + + (t/testing "trims surrounding whitespace from upstream variant strings" + (t/is (= "Bold" (media/font-display-variant " Bold " 700 "normal")))) + + (t/testing "ignores blank or nil variant strings" + (t/is (= "Hairline" (media/font-display-variant nil 100 "normal"))) + (t/is (= "Regular" (media/font-display-variant "" 400 "normal"))) + (t/is (= "Bold" (media/font-display-variant " " 700 "normal"))) + (t/is (= "Bold Italic" (media/font-display-variant nil 700 "italic")))) + + (t/testing "fallback covers every supported numeric weight" + (t/is (= "Hairline" (media/font-display-variant nil 100 "normal"))) + (t/is (= "Extra Light" (media/font-display-variant nil 200 "normal"))) + (t/is (= "Light" (media/font-display-variant nil 300 "normal"))) + (t/is (= "Regular" (media/font-display-variant nil 400 "normal"))) + (t/is (= "Medium" (media/font-display-variant nil 500 "normal"))) + (t/is (= "Semi Bold" (media/font-display-variant nil 600 "normal"))) + (t/is (= "Bold" (media/font-display-variant nil 700 "normal"))) + (t/is (= "Extra Bold" (media/font-display-variant nil 800 "normal"))) + (t/is (= "Black" (media/font-display-variant nil 900 "normal"))) + (t/is (= "Extra Black" (media/font-display-variant nil 950 "normal")))) + + (t/testing "italic suffix only applied via the fallback path" + (t/is (= "Italic" (media/font-display-variant "Italic" 400 "italic"))) + (t/is (= "Regular Italic" (media/font-display-variant nil 400 "italic")))) + + (t/testing "stored variant survives even when its derived weight disagrees" + (t/is (= "Ultra" (media/font-display-variant "Ultra" 400 "normal"))))) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 1ba7242d58..9245d062f7 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -7,13 +7,19 @@ (ns common-tests.runner (:require #?(:clj [common-tests.fressian-test]) + #?(:cljs [app.common.logging :as l]) + #?(:cljs [clojure.string :as str]) + #?(:cljs [clojure.tools.cli :refer [parse-opts]]) + #?(:cljs [goog.object :as gobj]) [clojure.test :as t] + [common-tests.attrs-test] [common-tests.buffer-test] [common-tests.colors-test] [common-tests.data-test] [common-tests.files-builder-test] [common-tests.files-changes-test] [common-tests.files-migrations-test] + [common-tests.files.shapes-builder-test] [common-tests.geom-align-test] [common-tests.geom-bounds-map-test] [common-tests.geom-flex-layout-test] @@ -54,6 +60,7 @@ [common-tests.path-names-test] [common-tests.record-test] [common-tests.schema-test] + [common-tests.spec-test] [common-tests.svg-path-test] [common-tests.svg-test] [common-tests.text-test] @@ -64,6 +71,7 @@ [common-tests.types.container-test] [common-tests.types.fill-test] [common-tests.types.modifiers-test] + [common-tests.types.nitrate-permissions-test] [common-tests.types.objects-map-test] [common-tests.types.path-data-test] [common-tests.types.shape-decode-encode-test] @@ -74,21 +82,12 @@ [common-tests.undo-stack-test] [common-tests.uuid-test])) -#?(:cljs (enable-console-print!)) - -#?(:cljs - (defmethod cljs.test/report [:cljs.test/default :end-run-tests] [m] - (if (cljs.test/successful? m) - (.exit js/process 0) - (.exit js/process 1)))) - -(defn -main - [& args] - (t/run-tests +(def test-namespaces + [#?(:clj 'common-tests.fressian-test) + 'common-tests.attrs-test 'common-tests.buffer-test 'common-tests.colors-test 'common-tests.data-test - #?(:clj 'common-tests.fressian-test) 'common-tests.files-changes-test 'common-tests.files-builder-test 'common-tests.files-migrations-test @@ -132,6 +131,7 @@ 'common-tests.path-names-test 'common-tests.record-test 'common-tests.schema-test + 'common-tests.spec-test 'common-tests.svg-path-test 'common-tests.svg-test 'common-tests.text-test @@ -142,6 +142,7 @@ 'common-tests.types.container-test 'common-tests.types.fill-test 'common-tests.types.modifiers-test + 'common-tests.types.nitrate-permissions-test 'common-tests.types.objects-map-test 'common-tests.types.path-data-test 'common-tests.types.shape-decode-encode-test @@ -150,4 +151,169 @@ 'common-tests.types.token-test 'common-tests.types.tokens-lib-test 'common-tests.undo-stack-test - 'common-tests.uuid-test)) + 'common-tests.uuid-test]) + +#?(:cljs + (assert (every? find-ns-obj test-namespaces) + "test-namespaces contains a namespace that isn't required in runner.cljc")) + +#?(:cljs (enable-console-print!)) + +#?(:cljs + (defmethod cljs.test/report [:cljs.test/default :end-run-tests] [m] + (if (cljs.test/successful? m) + (.exit js/process 0) + (.exit js/process 1)))) + +#?(:cljs + (do + ;; This runner intentionally mirrors frontend-tests.runner. Both runners need + ;; forwarded CLI args, focused namespace/var execution, fixture preservation, + ;; and app log-level setup. A shared helper could own those mechanics, but we + ;; keep the logic local while there are only two test targets because sharing + ;; it would add cross-module test classpath coupling. + (def ^:private log-levels + #{:trace :debug :info :warn :error}) + + (def cli-options + [["-f" "--focus FOCUS" "Run one test namespace or one test var, e.g. common-tests.logic.comp-sync-test/test-sync-when-changing-attribute"] + ["-l" "--log-level LEVEL" "Set app logger level: trace|debug|info|warn|error" + :parse-fn keyword + :validate [log-levels "must be one of trace, debug, info, warn, error"]] + ["-h" "--help"]]) + + (defn- argv + [] + (let [args (->> (.-argv js/process) + (array-seq) + (drop 2))] + (cond-> args + (= "--" (first args)) rest))) + + (defn- usage + [summary] + (str "Usage: pnpm run test:js -- [options]\n\n" + "Options:\n" + summary "\n\n" + "Focus examples:\n" + " pnpm run test:js -- --focus common-tests.logic.comp-sync-test\n" + " pnpm run test:js -- --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute\n\n" + "Log level example (quiets app logging during the run):\n" + " pnpm run test:js -- --focus common-tests.logic.comp-sync-test --log-level warn")) + + (defn- fail! + [message] + (js/console.error message) + (.exit js/process 1)) + + (defn- parse-focus + [focus] + (let [[ns-name test-name & extra] (str/split focus #"/")] + (cond + (or (str/blank? ns-name) (seq extra)) + (fail! (str "Invalid --focus value: " focus)) + + (some? test-name) + {:ns (symbol ns-name) :test test-name} + + :else + {:ns (symbol ns-name)}))) + + (defn- fixture-value + [ns-obj fixture-name] + (let [value (gobj/get ns-obj (munge fixture-name))] + (when-not (undefined? value) + value))) + + (defn- ns-test-vars + [ns-sym] + (when-let [ns-obj (find-ns-obj ns-sym)] + (->> (js-keys ns-obj) + (keep (fn [key] + (some-> (gobj/get ns-obj key) + (.-cljs$lang$var)))) + (filter (comp :test meta)) + (sort-by (comp :line meta))))) + + (defn- ns-fixtures + [ns-sym vars] + (when-let [ns-obj (find-ns-obj ns-sym)] + (let [ns-key (or (some-> vars first meta :ns) ns-sym) + once-fixtures (fixture-value ns-obj "cljs-test-once-fixtures") + each-fixtures (fixture-value ns-obj "cljs-test-each-fixtures")] + {:once (when once-fixtures {ns-key once-fixtures}) + :each (when each-fixtures {ns-key each-fixtures})}))) + + (defn- selected-tests + [{:keys [ns test]}] + (when-not (some #{ns} test-namespaces) + (fail! (str "Unknown test namespace: " ns))) + (let [vars (vec (ns-test-vars ns))] + (when (empty? vars) + (fail! (str "No tests found in namespace: " ns))) + (if test + (let [test-sym (symbol test) + test-var (some #(when (= test-sym (:name (meta %))) %) vars)] + (if test-var + {:vars [test-var] + :fixtures (ns-fixtures ns [test-var])} + (fail! (str "Unknown test var: " ns "/" test)))) + {:vars vars + :fixtures (ns-fixtures ns vars)}))) + + (defn- merge-fixtures + [fixtures] + {:once (apply merge (keep :once fixtures)) + :each (apply merge (keep :each fixtures))}) + + (defn- run-test-vars! + [tests] + (let [vars (vec (mapcat :vars tests)) + fixtures (merge-fixtures (map :fixtures tests)) + env (assoc (t/empty-env) + :once-fixtures (:once fixtures) + :each-fixtures (:each fixtures)) + summary (volatile! {:test 0 :pass 0 :fail 0 :error 0 :type :summary})] + (t/run-block + (concat [(fn [] (t/set-env! env))] + (t/test-vars-block vars) + [(fn [] + (vswap! summary + (partial merge-with +) + (:report-counters (t/get-and-clear-env!)))) + (fn [] + (t/set-env! env) + (t/do-report @summary) + (t/report (assoc @summary :type :end-run-tests)) + (t/clear-env!))])))) + + (defn- run-focused-test! + [focus] + (run-test-vars! [(selected-tests (parse-focus focus))])) + + (defn- run-all-tests! + [] + (run-test-vars! (map #(selected-tests {:ns %}) test-namespaces))))) + +(defn -main + [& _args] + #?(:cljs + (let [{:keys [options errors summary]} (parse-opts (argv) cli-options)] + (cond + (seq errors) + (fail! (str/join "\n" errors)) + + (:help options) + (do + (println (usage summary)) + (.exit js/process 0)) + + :else + (do + (when-let [level (:log-level options)] + (l/setup! {:app level})) + (if (:focus options) + (run-focused-test! (:focus options)) + (run-all-tests!))))) + :clj + (apply t/run-tests test-namespaces))) diff --git a/common/test/common_tests/spec_test.cljc b/common/test/common_tests/spec_test.cljc new file mode 100644 index 0000000000..425f7f8066 --- /dev/null +++ b/common/test/common_tests/spec_test.cljc @@ -0,0 +1,89 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns common-tests.spec-test + (:require + [app.common.spec :as spec] + [clojure.test :as t])) + +(t/deftest valid-emails + (t/testing "accepts well-formed email addresses" + (doseq [email ["user@domain.com" + "user.name@domain.com" + "user+tag@domain.com" + "user-name@domain.com" + "user_name@domain.com" + "user123@domain.com" + "USER@DOMAIN.COM" + "u@domain.io" + "user@sub.domain.com" + "user@domain.co.uk" + "user@domain.dev" + "a@bc.co"]] + (t/is (some? (spec/parse-email email)) (str "should accept: " email))))) + +(t/deftest rejects-invalid-local-part + (t/testing "rejects local part starting with a dot" + (t/is (nil? (spec/parse-email ".user@domain.com")))) + + (t/testing "rejects local part with consecutive dots" + (t/is (nil? (spec/parse-email "user..name@domain.com")))) + + (t/testing "rejects local part with spaces" + (t/is (nil? (spec/parse-email "us er@domain.com")))) + + (t/testing "rejects local part with comma" + (t/is (nil? (spec/parse-email "user,name@domain.com"))) + (t/is (nil? (spec/parse-email ",user@domain.com")))) + + (t/testing "rejects empty local part" + (t/is (nil? (spec/parse-email "@domain.com"))))) + +(t/deftest rejects-invalid-domain + (t/testing "rejects domain starting with a dot" + (t/is (nil? (spec/parse-email "user@.domain.com")))) + + (t/testing "rejects domain part with comma" + (t/is (nil? (spec/parse-email "user@domain,com"))) + (t/is (nil? (spec/parse-email "user@,domain.com")))) + + (t/testing "rejects domain with consecutive dots" + (t/is (nil? (spec/parse-email "user@sub..domain.com")))) + + (t/testing "rejects label starting with hyphen" + (t/is (nil? (spec/parse-email "user@-domain.com")))) + + (t/testing "rejects label ending with hyphen" + (t/is (nil? (spec/parse-email "user@domain-.com")))) + + (t/testing "rejects TLD shorter than 2 chars" + (t/is (nil? (spec/parse-email "user@domain.c")))) + + (t/testing "rejects domain without a dot" + (t/is (nil? (spec/parse-email "user@domain")))) + + (t/testing "rejects domain with spaces" + (t/is (nil? (spec/parse-email "user@do main.com")))) + + (t/testing "rejects domain ending with a dot" + (t/is (nil? (spec/parse-email "user@domain."))))) + +(t/deftest rejects-invalid-structure + (t/testing "rejects nil" + (t/is (nil? (spec/parse-email nil)))) + + (t/testing "rejects empty string" + (t/is (nil? (spec/parse-email "")))) + + (t/testing "rejects string without @" + (t/is (nil? (spec/parse-email "userdomain.com")))) + + (t/testing "rejects string with multiple @" + (t/is (nil? (spec/parse-email "user@@domain.com"))) + (t/is (nil? (spec/parse-email "us@er@domain.com")))) + + (t/testing "rejects empty domain" + (t/is (nil? (spec/parse-email "user@"))))) diff --git a/common/test/common_tests/types/color_test.cljc b/common/test/common_tests/types/color_test.cljc index 9a3ab00ac9..deb0f24346 100644 --- a/common/test/common_tests/types/color_test.cljc +++ b/common/test/common_tests/types/color_test.cljc @@ -164,3 +164,78 @@ {:color "#ffffff" :opacity 1.0 :offset 0.5}] result (colors/interpolate-gradient stops 1.0)] (t/is (= "#ffffff" (:color result)))))) + +(t/deftest rgb-to-hsb + ;; Achromatic black: brightness 0 + (let [[h s b] (colors/rgb->hsb [0 0 0])] + (t/is (= 0 h)) + (t/is (= 0 s)) + (t/is (mth/close? b 0.0))) + ;; Pure red: hue 0, full saturation, brightness 100 + (let [[h s b] (colors/rgb->hsb [255 0 0])] + (t/is (mth/close? h 0.0)) + (t/is (mth/close? s 1.0)) + (t/is (mth/close? b 100.0))) + ;; Pure white: brightness 100 + (let [[_ _ b] (colors/rgb->hsb [255 255 255])] + (t/is (mth/close? b 100.0))) + ;; Mid gray: brightness ~50.2 + (let [[_ _ b] (colors/rgb->hsb [128 128 128])] + (t/is (mth/close? b (* (/ 128.0 255.0) 100.0))))) + +(t/deftest hsb-to-rgb + (t/is (= [0 0 0] (colors/hsb->rgb [0 0 0]))) + (t/is (= [255 255 255] (colors/hsb->rgb [0 0 100]))) + ;; Pure red from HSB + (let [[r g b] (colors/hsb->rgb [0 1 100])] + (t/is (= 255 r)) + (t/is (= 0 g)) + (t/is (= 0 b)))) + +(t/deftest hex-to-hsb + ;; Black + (let [[h s b] (colors/hex->hsb "#000000")] + (t/is (= 0 h)) + (t/is (= 0 s)) + (t/is (mth/close? b 0.0))) + ;; White: brightness 100 + (let [[_ _ b] (colors/hex->hsb "#ffffff")] + (t/is (mth/close? b 100.0))) + ;; Red + (let [[h s b] (colors/hex->hsb "#ff0000")] + (t/is (mth/close? h 0.0)) + (t/is (mth/close? s 1.0)) + (t/is (mth/close? b 100.0)))) + +(t/deftest hsb-to-hex + (t/is (= "#000000" (colors/hsb->hex [0 0 0]))) + (t/is (= "#ffffff" (colors/hsb->hex [0 0 100])))) + +(t/deftest hsv-hsb-roundtrip + ;; HSV brightness is 0-255, HSB brightness is 0-100. Round-trip + ;; should reach the same triple within ±1 (integer rounding). + (let [orig [210.0 0.5 128] + hsb (colors/hsv->hsb orig) + result (colors/hsb->hsv hsb)] + (t/is (mth/close? (nth orig 0) (nth result 0))) + (t/is (mth/close? (nth orig 1) (nth result 1))) + (t/is (< (mth/abs (- (nth orig 2) (nth result 2))) 2)))) + +(t/deftest rgb-hsb-roundtrip + ;; RGB → HSB → RGB should land within ±1 per channel + (let [orig [100 150 200] + hsb (colors/rgb->hsb orig) + result (colors/hsb->rgb hsb)] + (t/is (every? true? (map #(< (mth/abs (- %1 %2)) 2) orig result))))) + +(t/deftest hex-hsb-roundtrip + ;; HEX → HSB → HEX should preserve the color across the model swap + (let [orig "#fabada" + hsb (colors/hex->hsb orig) + result (colors/hsb->hex hsb)] + ;; Allow ±1 per channel after the round-trip due to integer rounding + (let [[r1 g1 b1] (colors/hex->rgb orig) + [r2 g2 b2] (colors/hex->rgb result)] + (t/is (< (mth/abs (- r1 r2)) 2)) + (t/is (< (mth/abs (- g1 g2)) 2)) + (t/is (< (mth/abs (- b1 b2)) 2))))) diff --git a/common/test/common_tests/types/font_test.cljc b/common/test/common_tests/types/font_test.cljc new file mode 100644 index 0000000000..ee04e656fb --- /dev/null +++ b/common/test/common_tests/types/font_test.cljc @@ -0,0 +1,41 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns common-tests.types.font-test + (:require + [app.common.schema :as sm] + [app.common.types.font :as ctf] + [clojure.test :as t])) + +(t/deftest font-family-schema-valid + (t/is (sm/validate ctf/schema:font-family "Source Sans Pro")) + (t/is (sm/validate ctf/schema:font-family "Roboto")) + (t/is (sm/validate ctf/schema:font-family "Open Sans 300")) + (t/is (sm/validate ctf/schema:font-family "Font-Name_v2")) + (t/is (sm/validate ctf/schema:font-family "Noto Sans CJK SC")) + (t/is (sm/validate ctf/schema:font-family "A")) + ;; hyphens, underscores and dots are allowed + (t/is (sm/validate ctf/schema:font-family "Fira-Code")) + (t/is (sm/validate ctf/schema:font-family "font_name")) + (t/is (sm/validate ctf/schema:font-family "Soucre Sans Pro 3.0")) + ;; Unicode letters are allowed + (t/is (sm/validate ctf/schema:font-family "思源黑体")) + (t/is (sm/validate ctf/schema:font-family "العربية"))) + +(t/deftest font-family-schema-invalid + ;; HTML injection characters + (t/is (not (sm/validate ctf/schema:font-family "evil<script>"))) + (t/is (not (sm/validate ctf/schema:font-family "<test>name"))) + ;; CSS injection characters + (t/is (not (sm/validate ctf/schema:font-family "evil'name"))) + (t/is (not (sm/validate ctf/schema:font-family "evil\"name"))) + (t/is (not (sm/validate ctf/schema:font-family "evil}name"))) + (t/is (not (sm/validate ctf/schema:font-family "evil;name"))) + (t/is (not (sm/validate ctf/schema:font-family "evil\\name"))) + ;; empty string + (t/is (not (sm/validate ctf/schema:font-family ""))) + ;; too long + (t/is (not (sm/validate ctf/schema:font-family (apply str (repeat 251 "a")))))) diff --git a/common/test/common_tests/types/nitrate_permissions_test.cljc b/common/test/common_tests/types/nitrate_permissions_test.cljc new file mode 100644 index 0000000000..b5832b3793 --- /dev/null +++ b/common/test/common_tests/types/nitrate_permissions_test.cljc @@ -0,0 +1,183 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns common-tests.types.nitrate-permissions-test + (:require + [app.common.types.nitrate-permissions :as nitrate-perms] + [clojure.test :as t])) + +(def org-perms + {:owner-id :owner + :permissions {:create-teams "any" + :delete-teams "onlyOwners" + :send-invitations "ownersAndAdmins"}}) + +(t/deftest unknown-action-is-denied + (t/is (false? (nitrate-perms/allowed? :unknown + {:org-perms org-perms + :profile-id :member + :team-perms {:is-admin true}})))) + +(t/deftest org-owner-is-allowed-for-create + (t/is (true? (nitrate-perms/allowed? :create-team + {:org-perms org-perms + :profile-id :owner + :team-perms {:is-admin false}}))) + (t/is (false? (nitrate-perms/allowed? :delete-team + {:org-perms org-perms + :profile-id :owner + :team-perms {:is-admin false}})))) + +(t/deftest create-team-permission-rules + (t/is (true? (nitrate-perms/allowed? :create-team + {:org-perms org-perms + :profile-id :member + :team-perms {:is-admin false}}))) + (t/is (false? (nitrate-perms/allowed? :create-team + {:org-perms (assoc org-perms :permissions {:create-teams "none" + :delete-teams "onlyOwners"}) + :profile-id :member + :team-perms {:is-admin false}})))) + +(t/deftest delete-team-onlyowners-allows-only-team-owners + (t/is (true? (nitrate-perms/allowed? :delete-team + {:org-perms org-perms + :profile-id :member + :team-perms {:is-owner true :is-admin true}}))) + (t/is (false? (nitrate-perms/allowed? :delete-team + {:org-perms org-perms + :profile-id :member + :team-perms {:is-admin true}}))) + (t/is (false? (nitrate-perms/allowed? :delete-team + {:org-perms (assoc org-perms :permissions {:create-teams "any" + :delete-teams "invalid-value"}) + :profile-id :member + :team-perms {:is-admin true}})))) + +(t/deftest delete-team-onlyme-is-gated-for-future-org-flow + (let [only-me-org (assoc org-perms :permissions {:create-teams "any" + :delete-teams "onlyMe"})] + (t/is (false? (nitrate-perms/allowed? :delete-team + {:org-perms only-me-org + :profile-id :owner + :team-perms {:is-owner false :is-admin false}}))) + (t/is (true? (nitrate-perms/allowed? :delete-team + {:org-perms only-me-org + :allow-org-owner-delete? true + :profile-id :owner + :team-perms {:is-owner false :is-admin false}}))) + (t/is (false? (nitrate-perms/allowed? :delete-team + {:org-perms only-me-org + :profile-id :member + :team-perms {:is-owner true :is-admin true}}))))) + +(t/deftest move-team-always-allows-any-org-owner-or-all-users + (let [always-org (assoc org-perms :permissions {:create-teams "any" + :delete-teams "onlyOwners" + :move-teams "always"})] + ;; Org owner should always be allowed + (t/is (true? (nitrate-perms/allowed? :move-team + {:org-perms always-org + :profile-id :owner + :team-perms {}}))) + ;; Regular member should be allowed when move-teams is "always" + (t/is (true? (nitrate-perms/allowed? :move-team + {:org-perms always-org + :profile-id :member + :team-perms {}}))))) + +(t/deftest move-team-myorganizations-allows-only-within-same-owner + (let [my-orgs (assoc org-perms :permissions {:create-teams "any" + :delete-teams "onlyOwners" + :move-teams "myOrganizations"})] + ;; Org owner must also stay within same-owner organizations + (t/is (false? (nitrate-perms/allowed? :move-team + {:org-perms my-orgs + :profile-id :owner + :team-perms {} + :target-org-same-owner? false}))) + (t/is (true? (nitrate-perms/allowed? :move-team + {:org-perms my-orgs + :profile-id :owner + :team-perms {} + :target-org-same-owner? true}))) + ;; Regular member should be allowed only if target has same owner + (t/is (true? (nitrate-perms/allowed? :move-team + {:org-perms my-orgs + :profile-id :member + :team-perms {} + :target-org-same-owner? true}))) + (t/is (false? (nitrate-perms/allowed? :move-team + {:org-perms my-orgs + :profile-id :member + :team-perms {} + :target-org-same-owner? false}))))) + +(t/deftest move-team-never-denies-all + (let [never-org (assoc org-perms :permissions {:create-teams "any" + :delete-teams "onlyOwners" + :move-teams "never"})] + ;; Even org owner should be denied + (t/is (false? (nitrate-perms/allowed? :move-team + {:org-perms never-org + :profile-id :owner + :team-perms {}}))) + ;; Regular member should be denied + (t/is (false? (nitrate-perms/allowed? :move-team + {:org-perms never-org + :profile-id :member + :team-perms {}}))))) + +(t/deftest move-team-defaults-to-always + (let [default-org (assoc org-perms :permissions {:create-teams "any" + :delete-teams "onlyOwners"})] + ;; Should default to "always" when not specified + (t/is (true? (nitrate-perms/allowed? :move-team + {:org-perms default-org + :profile-id :member + :team-perms {}}))))) + +(t/deftest send-invitations-defaults-to-owners-and-admins + (let [default-org (assoc org-perms :permissions {:create-teams "any" + :delete-teams "onlyOwners"})] + (t/is (true? (nitrate-perms/allowed? :send-invitations + {:org-perms default-org + :profile-id :owner + :team-perms {:is-owner true :is-admin false}}))) + (t/is (true? (nitrate-perms/allowed? :send-invitations + {:org-perms default-org + :profile-id :member + :team-perms {:is-owner false :is-admin true}}))) + (t/is (false? (nitrate-perms/allowed? :send-invitations + {:org-perms default-org + :profile-id :member + :team-perms {:is-owner false :is-admin false}}))))) + +(t/deftest send-invitations-owners-allows-only-team-owners + (let [only-owners-org (assoc org-perms :permissions {:create-teams "any" + :delete-teams "onlyOwners" + :send-invitations "owners"})] + (t/is (true? (nitrate-perms/allowed? :send-invitations + {:org-perms only-owners-org + :profile-id :member + :team-perms {:is-owner true :is-admin true}}))) + (t/is (false? (nitrate-perms/allowed? :send-invitations + {:org-perms only-owners-org + :profile-id :owner + :team-perms {:is-owner false :is-admin false}}))) + (t/is (false? (nitrate-perms/allowed? :send-invitations + {:org-perms only-owners-org + :profile-id :member + :team-perms {:is-owner false :is-admin true}}))))) + +(t/deftest send-invitations-invalid-value-is-denied + (let [invalid-org (assoc org-perms :permissions {:create-teams "any" + :delete-teams "onlyOwners" + :send-invitations "invalid-value"})] + (t/is (false? (nitrate-perms/allowed? :send-invitations + {:org-perms invalid-org + :profile-id :member + :team-perms {:is-owner true :is-admin true}}))))) diff --git a/common/test/common_tests/types/path_data_test.cljc b/common/test/common_tests/types/path_data_test.cljc index 6dc7fa5207..69d14355b7 100644 --- a/common/test/common_tests/types/path_data_test.cljc +++ b/common/test/common_tests/types/path_data_test.cljc @@ -667,6 +667,41 @@ result (path.subpath/close-subpaths content)] (t/is (seq result))))) +(t/deftest subpath-merge-touching-subpaths + (t/testing "adjacent subpaths sharing an endpoint collapse into one chain" + ;; Heroicons-style fragment: continuous polyline split as M-L M-L M-L + ;; with the second/third subpath starting at the first's endpoint. + (let [content [{:command :move-to :params {:x 0.0 :y 10.0}} + {:command :line-to :params {:x 10.0 :y 10.0}} + {:command :move-to :params {:x 10.0 :y 10.0}} + {:command :line-to :params {:x 5.0 :y 0.0}} + {:command :move-to :params {:x 10.0 :y 10.0}} + {:command :line-to :params {:x 5.0 :y 20.0}}] + result (path.subpath/merge-touching-subpaths content) + moves (filter #(= :move-to (:command %)) result)] + ;; Subpaths 1+2 share (10,10) → merged. Subpath 3 also starts at (10,10), + ;; but the merged chain now ends at (5,0), so it does NOT match and + ;; is preserved as its own subpath. Two move-tos in the final result. + (t/is (= 2 (count moves))) + (t/is (= 5 (count result))))) + (t/testing "non-touching subpaths are left untouched" + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 5.0 :y 0.0}} + {:command :move-to :params {:x 50.0 :y 50.0}} + {:command :line-to :params {:x 60.0 :y 60.0}}] + result (path.subpath/merge-touching-subpaths content)] + (t/is (= content (vec result))))) + (t/testing "closed subpath is not absorbed into a neighbour" + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 5.0 :y 0.0}} + {:command :line-to :params {:x 5.0 :y 5.0}} + {:command :line-to :params {:x 0.0 :y 0.0}} + {:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 1.0 :y 1.0}}] + result (path.subpath/merge-touching-subpaths content) + moves (filter #(= :move-to (:command %)) result)] + (t/is (= 2 (count moves)))))) + (t/deftest subpath-reverse-content (let [result (path.subpath/reverse-content simple-open-content)] (t/is (= (count simple-open-content) (count result))) @@ -1100,6 +1135,24 @@ (t/is (path/content? result)) (t/is (seq (vec result))))) +(t/deftest path-merge-touching-subpaths + (t/testing "regression for #5283 — heroicons arrow path serialises as a single chain" + ;; SVG `d` originally split a continuous polyline by inserting a + ;; redundant moveto at the elbow. Importing it must collapse the + ;; first two subpaths so that stroke-linejoin renders the rounded tip. + (let [content (path/from-string + (str "M350.5,1846 L365.5,1846" + " M365.5,1846 L358.75,1839.25" + " M365.5,1846 L358.75,1852.75")) + merged (path/merge-touching-subpaths content) + rendered (str merged)] + (t/is (path/content? merged)) + ;; First two subpaths fold into M ... L ... L ... ; third stays + ;; separate (its start point matches the original M, not the merged + ;; chain's tail), so exactly two M commands remain. + (t/is (= 2 (count (re-seq #"M" rendered)))) + (t/is (= 3 (count (re-seq #"L" rendered))))))) + (t/deftest path-move-content (let [content (path/content sample-content-square) move-vec (gpt/point 3.0 4.0) diff --git a/common/test/common_tests/types/tokens_lib_test.cljc b/common/test/common_tests/types/tokens_lib_test.cljc index de18be42de..abba5d9454 100644 --- a/common/test/common_tests/types/tokens_lib_test.cljc +++ b/common/test/common_tests/types/tokens_lib_test.cljc @@ -2027,12 +2027,235 @@ (t/is (= (:value imported-ref) (:value original-ref)))))))) (t/deftest token-name-path-exists?-test - (t/is (true? (ctob/token-name-path-exists? "border-radius" {"border-radius" {"sm" {:name "sm"}}}))) - (t/is (true? (ctob/token-name-path-exists? "border-radius" {"border-radius" {:name "sm"}}))) - (t/is (true? (ctob/token-name-path-exists? "border-radius.sm" {"border-radius" {:name "sm"}}))) - (t/is (true? (ctob/token-name-path-exists? "border-radius.sm.x" {"border-radius" {:name "sm"}}))) - (t/is (false? (ctob/token-name-path-exists? "other" {"border-radius" {:name "sm"}}))) - (t/is (false? (ctob/token-name-path-exists? "dark.border-radius.md" {"dark" {"border-radius" {"sm" {:name "sm"}}}})))) + (let [tokens-lib (ctob/make-tokens-lib) + add-set (fn [lib set-label set-name token-names] + (ctob/add-set lib (ctob/make-token-set + :id (thi/new-id! set-label) + :name set-name + :tokens (into {} + (map (fn [token-name] + [token-name (ctob/make-token + {:name token-name + :type :border-radius + :value "1"})])) + token-names))))] + + ;; Empty cases + + (t/testing "returns match for no library or empty library or empty name" + (t/is (not (ctob/token-name-path-exists? nil nil nil nil))) + (t/is (not (ctob/token-name-path-exists? nil tokens-lib nil nil))) + (t/is (not (ctob/token-name-path-exists? "" tokens-lib nil nil))) + (t/is (not (ctob/token-name-path-exists? "bad-name" tokens-lib nil nil))) + (t/is (not (ctob/token-name-path-exists? "bad-name" + (ctob/add-theme tokens-lib + (ctob/make-token-theme {:name "theme1"})) + nil + nil)))) + + (t/testing "throws error when giving a bad set id" + (t/is (thrown-with-msg? #?(:clj AssertionError :cljs js/Error) + #"Set '[0-9a-f-]+' does not exist in the library" + (ctob/token-name-path-exists? "some-name" + (-> tokens-lib + (add-set :empty-set "empty-set" [])) + (thi/new-id! :non-existent-set) nil)))) + + (t/testing "does not throw error when giving a nil set id" + (t/is (not (ctob/token-name-path-exists? "some-name" + (-> tokens-lib + (add-set :empty-set "empty-set" [])) + nil nil)))) + + (t/testing "returns not match for empty set" + (t/is (not (ctob/token-name-path-exists? "some-name" + (-> tokens-lib + (add-set :empty-set "empty-set" [])) + (thi/id :empty-set) nil)))) + + ;; Search in the current set + + (t/testing "returns match when name matches exactly a token in the set without groups" + (t/is (= "token1" + (ctob/token-name-path-exists? "token1" + (-> tokens-lib + (add-set :set1 "set1" ["token1" "token2" "token3"])) + (thi/id :set1) nil)))) + + (t/testing "returns match when name matches exactly a token in the set with groups" + (t/is (= "group1.subgroup1.token2" + (ctob/token-name-path-exists? "group1.subgroup1.token2" + (-> tokens-lib + (add-set :set1 "set1" ["group1.subgroup1.token1" + "group1.subgroup1.token2" + "group2.subgroup2.token3"])) + (thi/id :set1) nil)))) + + (t/testing "returns match when name is a subpath of a token in the set" + (t/is (= "group1" + (ctob/token-name-path-exists? "group1" + (-> tokens-lib + (add-set :set1 "set1" ["group1.subgroup1.token1" + "group1.subgroup1.token2" + "group2.subgroup2.token3"])) + (thi/id :set1) nil))) + (t/is (= "group1.subgroup1" + (ctob/token-name-path-exists? "group1.subgroup1" + (-> tokens-lib + (add-set :set1 "set1" ["group1.subgroup1.token1" + "group1.subgroup1.token2" + "group2.subgroup2.token3"])) + (thi/id :set1) nil)))) + + (t/testing "returns match when one of the token names in the set is a subpath of the name" + (t/is (= "group2.subgroup2.token3" + (ctob/token-name-path-exists? "group2.subgroup2.token3.subtoken" + (-> tokens-lib + (add-set :set1 "set1" ["group1.subgroup1.token1" + "group1.subgroup1.token2" + "group2.subgroup2.token3"])) + (thi/id :set1) nil)))) + + (t/testing "returns not match when name matches part of the path but not the full token name" + (t/is (not (ctob/token-name-path-exists? "group1.subgroup1.token4" + (-> tokens-lib + (add-set :set1 "set1" ["group1.subgroup1.token1" + "group1.subgroup1.token2" + "group2.subgroup2.token3"])) + (thi/id :set1) nil)))) + + (t/testing "returns not match when name does not match any part of the token names in the set" + (t/is (not (ctob/token-name-path-exists? "token4" + (-> tokens-lib + (add-set :set1 "set1" ["group1.subgroup1.token1" + "group1.subgroup1.token2" + "group2.subgroup2.token3"])) + (thi/id :set1) nil)))) + + ;; Search in other set + + (t/testing "returns not match when name matches exactly a token in other set without groups" + (t/is (not (ctob/token-name-path-exists? "token1" + (-> tokens-lib + (add-set :set1 "set1" ["token1" "token2" "token3"]) + (add-set :set2 "set2" [])) + (thi/id :set2) nil)))) + + (t/testing "returns not match when name matches exactly a token in other set with groups" + (t/is (not (ctob/token-name-path-exists? "group1.subgroup1.token2" + (-> tokens-lib + (add-set :set1 "set1" ["group1.subgroup1.token1" + "group1.subgroup1.token2" + "group2.subgroup2.token3"]) + (add-set :set2 "set2" [])) + (thi/id :set2) nil)))) + + (t/testing "returns match when name is a subpath of a token in other set" + (t/is (= "group1" + (ctob/token-name-path-exists? "group1" + (-> tokens-lib + (add-set :set1 "set1" ["group1.subgroup1.token1" + "group1.subgroup1.token2" + "group2.subgroup2.token3"]) + (add-set :set2 "set2" [])) + (thi/id :set2) nil))) + (t/is (= "group1.subgroup1" + (ctob/token-name-path-exists? "group1.subgroup1" + (-> tokens-lib + (add-set :set1 "set1" ["group1.subgroup1.token1" + "group1.subgroup1.token2" + "group2.subgroup2.token3"]) + (add-set :set2 "set2" [])) + (thi/id :set2) nil)))) + + (t/testing "returns match when one of the token names in other set is a subpath of the name" + (t/is (= "group2.subgroup2.token3" + (ctob/token-name-path-exists? "group2.subgroup2.token3.subtoken" + (-> tokens-lib + (add-set :set1 "set1" ["group1.subgroup1.token1" + "group1.subgroup1.token2" + "group2.subgroup2.token3"]) + (add-set :set2 "set2" [])) + (thi/id :set2) nil)))) + + (t/testing "returns not match when name matches part of the path but not the full token name" + (t/is (not (ctob/token-name-path-exists? "group1.subgroup1.token4" + (-> tokens-lib + (add-set :set1 "set1" ["group1.subgroup1.token1" + "group1.subgroup1.token2" + "group2.subgroup2.token3"]) + (add-set :set2 "set2" [])) + (thi/id :set2) nil)))) + + (t/testing "returns not match when name does not match any part of the token names in the set" + (t/is (not (ctob/token-name-path-exists? "token4" + (-> tokens-lib + (add-set :set1 "set1" ["group1.subgroup1.token1" + "group1.subgroup1.token2" + "group2.subgroup2.token3"]) + (add-set :set2 "set2" [])) + (thi/id :set2) nil)))) + + ;; Additional cases + + (t/testing "returns match when matches an exact token with several sets" + (t/is (= "group3.subgroup3.token4" + (ctob/token-name-path-exists? "group3.subgroup3.token4" + (-> tokens-lib + (add-set :set1 "set1" ["group1.subgroup1.token1" + "group1.subgroup1.token2" + "group2.subgroup2.token3"]) + (add-set :set2 "set2" ["group3.subgroup3.token4"])) + (thi/id :set2) nil)))) + + (t/testing "returns match when matches in one of the sets, even if the set is disabled" + (let [tokens-lib (-> tokens-lib + (add-set :set1 "set1" ["group1.subgroup1.token1" + "group1.subgroup1.token2" + "group2.subgroup2.token3"]) + (add-set :set2 "set2" ["group3.subgroup3.token4"])) + hidden-theme (ctob/get-hidden-theme tokens-lib) + tokens-lib (ctob/toggle-set-in-theme tokens-lib (:id hidden-theme) "set2")] + (t/is (= "group3.subgroup3.token4" + (ctob/token-name-path-exists? "group3.subgroup3.token4" + tokens-lib + (thi/id :set2) + nil))))) + + (t/testing "returns not match when does not match in any of the sets" + (t/is (not (ctob/token-name-path-exists? "group3.subgroup3.token5" + (-> tokens-lib + (add-set :set1 "set1" ["group1.subgroup1.token1" + "group1.subgroup1.token2" + "group2.subgroup2.token3"]) + (add-set :set2 "set2" ["group3.subgroup3.token4"])) + (thi/id :set1) + nil)))) + + (t/testing "returns not match when the token exists but is the one we have told it to ignore" + (let [tokens-lib (-> tokens-lib + (add-set :set1 "set1" ["group1.subgroup1.token1" + "group1.subgroup1.token2" + "group2.subgroup2.token3"]) + (add-set :set2 "set2" ["group3.subgroup3.token4"])) + token4 (ctob/get-token-by-name tokens-lib "set2" "group3.subgroup3.token4")] + (t/is (not (ctob/token-name-path-exists? "group3.subgroup3.token4" + tokens-lib + (thi/id :set2) + (:id token4)))))) + + (t/testing "returns match when we give an id to ignore but is not the token that matches" + (let [tokens-lib (-> tokens-lib + (add-set :set1 "set1" ["group1.subgroup1.token1" + "group1.subgroup1.token2" + "group2.subgroup2.token3"]) + (add-set :set2 "set2" ["group3.subgroup3.token4"])) + token4 (ctob/get-token-by-name tokens-lib "set2" "group3.subgroup3.token4")] + (t/is (= "group1.subgroup1.token1" + (ctob/token-name-path-exists? "group1.subgroup1.token1" + tokens-lib + (thi/id :set1) + (:id token4)))))))) #?(:clj (t/deftest token-set-encode-decode-roundtrip-with-invalid-set-name diff --git a/common/test/common_tests/types/tokens_migrations_test.cljc b/common/test/common_tests/types/tokens_migrations_test.cljc new file mode 100644 index 0000000000..aa0c7c5fc8 --- /dev/null +++ b/common/test/common_tests/types/tokens_migrations_test.cljc @@ -0,0 +1,280 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns common-tests.types.tokens-migrations-test + (:require + [app.common.data :as d] + [app.common.test-helpers.ids-map :as thi] + [app.common.time :as ct] + [app.common.types.tokens-lib :as ctob] + [clojure.datafy :refer [datafy]] + [clojure.test :as t])) + +(t/deftest test-v1-5-fix-token-names + + (t/testing "empty tokens-lib should not need any action" + (let [tokens-lib (ctob/make-tokens-lib) + tokens-lib' (ctob/fix-conflicting-token-names tokens-lib)] + (t/is (empty? (d/map-diff (datafy tokens-lib) (datafy tokens-lib')))))) + + (t/testing "tokens with valid names should not need any action" + (let [tokens-lib (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set + :id (thi/new-id! :set1) + :name "set1" + :tokens {"name1" (ctob/make-token + {:id (thi/new-id! :token1) + :name "name1" + :type :border-radius + :value "1"})})) + (ctob/add-set (ctob/make-token-set + :id (thi/new-id! :set2) + :name "set2" + :tokens {"name1" (ctob/make-token ;; Same name in different + {:id (thi/new-id! :token2) ;; sets is ok + :name "name1" + :type :border-radius + :value "2"})}))) + + tokens-lib' (ctob/fix-conflicting-token-names tokens-lib)] + + (t/is (empty? (d/map-diff (datafy tokens-lib) (datafy tokens-lib')))))) + + (t/testing "tokens with conflicting names should be renamed, and the rest of the library should be unchanged" + (let [tokens-lib (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set + :id (thi/new-id! :set1) + :name "set1" + :tokens {"name1" (ctob/make-token + {:id (thi/new-id! :token1) + :name "name1" + :type :border-radius + :value "1"})})) + (ctob/add-set (ctob/make-token-set + :id (thi/new-id! :set2) + :name "set2" + :tokens {"name1.name2" (ctob/make-token + {:id (thi/new-id! :token2) + :name "name1.name2" + :type :border-radius + :value "2"})}))) + + tokens-lib' (ctob/fix-conflicting-token-names tokens-lib) + + token-sets (ctob/get-set-tree tokens-lib) + set1 (ctob/get-set tokens-lib (thi/id :set1)) + set2 (ctob/get-set tokens-lib (thi/id :set2)) + tokens1 (ctob/get-tokens tokens-lib (thi/id :set1)) + tokens2 (ctob/get-tokens tokens-lib (thi/id :set2)) + token1 (ctob/get-token tokens-lib (thi/id :set1) (thi/id :token1)) + token2 (ctob/get-token tokens-lib (thi/id :set2) (thi/id :token2)) + + token-sets' (ctob/get-set-tree tokens-lib') + set1' (ctob/get-set tokens-lib' (thi/id :set1)) + set2' (ctob/get-set tokens-lib' (thi/id :set2)) + tokens1' (ctob/get-tokens tokens-lib' (thi/id :set1)) + tokens2' (ctob/get-tokens tokens-lib' (thi/id :set2)) + token1' (ctob/get-token tokens-lib' (thi/id :set1) (thi/id :token1)) + token2' (ctob/get-token tokens-lib' (thi/id :set2) (thi/id :token2))] + + (t/is (= (count token-sets') (count token-sets))) + + (t/is (= (ctob/get-id set1') (ctob/get-id set1))) + (t/is (= (ctob/get-name set1') (ctob/get-name set1))) + (t/is (= (ctob/get-description set1') (ctob/get-description set1))) + (t/is (ct/is-after-or-equal? (ctob/get-modified-at set1') (ctob/get-modified-at set1))) ;; <-- MODIFIED + + (t/is (= (ctob/get-id set2') (ctob/get-id set2))) + (t/is (= (ctob/get-name set2') (ctob/get-name set2))) + (t/is (= (ctob/get-description set2') (ctob/get-description set2))) + (t/is (= (ctob/get-modified-at set2') (ctob/get-modified-at set2))) + + (t/is (= (count tokens1') (count tokens1))) + (t/is (= (count tokens2') (count tokens2))) + + (t/is (= (ctob/get-id token1') (ctob/get-id token1))) + (t/is (= (ctob/get-name token1') "name1-1")) ;; <-- RENAMED + (t/is (= (ctob/get-description token1') (ctob/get-description token1))) + (t/is (ct/is-after-or-equal? (ctob/get-modified-at set1') (ctob/get-modified-at set1))) ;; <-- MODIFIED + (t/is (= (:type token1') (:type token1))) + (t/is (= (:value token1') (:value token1))) + + (t/is (= (ctob/get-id token2') (ctob/get-id token2))) + (t/is (= (ctob/get-name token2') (ctob/get-name token2))) + (t/is (= (ctob/get-description token2') (ctob/get-description token2))) + (t/is (= (ctob/get-modified-at token2') (ctob/get-modified-at token2))) + (t/is (= (:type token2') (:type token2))) + (t/is (= (:value token2') (:value token2))))) + + (t/testing "the renamed token is always the first one found with a conflicting name" + (let [tokens-lib (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set + :id (thi/new-id! :set1) + :name "set1" + :tokens {"name1.name2" (ctob/make-token + {:id (thi/new-id! :token1) + :name "name1.name2" + :type :border-radius + :value "1"})})) + (ctob/add-set (ctob/make-token-set + :id (thi/new-id! :set2) + :name "set2" + :tokens {"name1" (ctob/make-token + {:id (thi/new-id! :token2) + :name "name1" + :type :border-radius + :value "2"})}))) + + tokens-lib' (ctob/fix-conflicting-token-names tokens-lib) + token1' (ctob/get-token tokens-lib' (thi/id :set1) (thi/id :token1)) + token2' (ctob/get-token tokens-lib' (thi/id :set2) (thi/id :token2))] + + (t/is (= "name1-1.name2" (ctob/get-name token1'))) + (t/is (= "name1" (ctob/get-name token2'))))) + + (t/testing "several tokens with the same conflicting prefix should be assigned the same number as suffixes" + (let [tokens-lib (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set + :id (thi/new-id! :set1) + :name "set1" + :tokens {"name1.name2" (ctob/make-token + {:id (thi/new-id! :token1) + :name "name1.name2" + :type :border-radius + :value "1"}) + "name1.name3" (ctob/make-token + {:id (thi/new-id! :token2) + :name "name1.name3" + :type :border-radius + :value "2"})})) + (ctob/add-set (ctob/make-token-set + :id (thi/new-id! :set2) + :name "set2" + :tokens {"name1" (ctob/make-token + {:id (thi/new-id! :token3) + :name "name1" + :type :border-radius + :value "3"})}))) + + tokens-lib' (ctob/fix-conflicting-token-names tokens-lib) + token1' (ctob/get-token tokens-lib' (thi/id :set1) (thi/id :token1)) + token2' (ctob/get-token tokens-lib' (thi/id :set1) (thi/id :token2)) + token3' (ctob/get-token tokens-lib' (thi/id :set2) (thi/id :token3))] + + (t/is (= "name1-1.name2" (ctob/get-name token1'))) + (t/is (= "name1-1.name3" (ctob/get-name token2'))) + (t/is (= "name1" (ctob/get-name token3'))))) + + (t/testing "tokens with diferent conflicting prefixes should be assigned consecutive numbers as suffixes" + (let [tokens-lib (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set + :id (thi/new-id! :set1) + :name "set1" + :tokens {"name1" (ctob/make-token + {:id (thi/new-id! :token1) + :name "name1" + :type :border-radius + :value "1"}) + "name2" (ctob/make-token + {:id (thi/new-id! :token2) + :name "name2" + :type :border-radius + :value "2"})})) + (ctob/add-set (ctob/make-token-set + :id (thi/new-id! :set2) + :name "set2" + :tokens {"name1.subname1" (ctob/make-token + {:id (thi/new-id! :token3) + :name "name1.subname1" + :type :border-radius + :value "3"})})) + (ctob/add-set (ctob/make-token-set + :id (thi/new-id! :set3) + :name "set3" + :tokens {"name2.subname2" (ctob/make-token + {:id (thi/new-id! :token4) + :name "name2.subname2" + :type :border-radius + :value "3"})}))) + + tokens-lib' (ctob/fix-conflicting-token-names tokens-lib) + token1' (ctob/get-token tokens-lib' (thi/id :set1) (thi/id :token1)) + token2' (ctob/get-token tokens-lib' (thi/id :set1) (thi/id :token2)) + token3' (ctob/get-token tokens-lib' (thi/id :set2) (thi/id :token3)) + token4' (ctob/get-token tokens-lib' (thi/id :set3) (thi/id :token4))] + + (t/is (= "name1-1" (ctob/get-name token1'))) + (t/is (= "name2-2" (ctob/get-name token2'))) + (t/is (= "name1.subname1" (ctob/get-name token3'))) + (t/is (= "name2.subname2" (ctob/get-name token4')))))) + +(t/deftest test-v1-6-fix-token-names + + (t/testing "empty tokens-lib should not need any action" + (let [tokens-lib (ctob/make-tokens-lib) + tokens-lib' (ctob/fix-missing-sets-in-themes tokens-lib)] + (t/is (empty? (d/map-diff (datafy tokens-lib) (datafy tokens-lib')))))) + + (t/testing "library with a valid theme should not need any action" + (let [tokens-lib (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set + :id (thi/new-id! :set1) + :name "set1")) + (ctob/add-set (ctob/make-token-set + :id (thi/new-id! :set2) + :name "set2")) + (ctob/add-theme (ctob/make-token-theme + :id (thi/new-id! :theme1) + :name "theme1" + :sets #{"set1"}))) + tokens-lib' (ctob/fix-missing-sets-in-themes tokens-lib)] + (t/is (empty? (d/map-diff (datafy tokens-lib) (datafy tokens-lib')))))) + + (t/testing "library with a theme containing a non-existent set should have it removed, and the rest of the library should be unchanged" + (let [tokens-lib (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set + :id (thi/new-id! :set1) + :name "set1")) + (ctob/add-set (ctob/make-token-set + :id (thi/new-id! :set2) + :name "set2")) + (ctob/add-theme (ctob/make-token-theme + :id (thi/new-id! :theme1) + :name "theme1" + :sets #{"set1" "set3"})) ;; "set3" does not exist + (ctob/add-theme (ctob/make-token-theme + :id (thi/new-id! :theme2) + :name "theme2" + :sets #{"set1" "set2"}))) + tokens-lib' (ctob/fix-missing-sets-in-themes tokens-lib) + + set1 (ctob/get-set tokens-lib (thi/id :set1)) + set2 (ctob/get-set tokens-lib (thi/id :set2)) + theme1 (ctob/get-theme tokens-lib (thi/id :theme1)) + theme2 (ctob/get-theme tokens-lib (thi/id :theme2)) + set1' (ctob/get-set tokens-lib' (thi/id :set1)) + set2' (ctob/get-set tokens-lib' (thi/id :set2)) + theme1' (ctob/get-theme tokens-lib' (thi/id :theme1)) + theme2' (ctob/get-theme tokens-lib' (thi/id :theme2))] + + (t/is (= (:sets theme1') #{"set1"})) + (t/is (= (:sets theme2') #{"set1" "set2"})) + + (t/is (= (ctob/get-id set1') (ctob/get-id set1))) + (t/is (= (ctob/get-name set1') (ctob/get-name set1))) + (t/is (= (ctob/get-description set1') (ctob/get-description set1))) + (t/is (= (ctob/get-modified-at set1') (ctob/get-modified-at set1))) + (t/is (= (ctob/get-id set2') (ctob/get-id set2))) + (t/is (= (ctob/get-name set2') (ctob/get-name set2))) + (t/is (= (ctob/get-description set2') (ctob/get-description set2))) + (t/is (= (ctob/get-modified-at set2') (ctob/get-modified-at set2))) + + (t/is (= (ctob/get-id theme1') (ctob/get-id theme1))) + (t/is (= (ctob/get-name theme1') (ctob/get-name theme1))) + (t/is (= (ctob/get-description theme1') (ctob/get-description theme1))) + (t/is (= (ctob/get-id theme2') (ctob/get-id theme2))) + (t/is (= (ctob/get-name theme2') (ctob/get-name theme2))) + (t/is (= (ctob/get-description theme2') (ctob/get-description theme2)))))) diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 07fbab0bb4..24ec6dfd54 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -32,7 +32,7 @@ RUN set -ex; \ FROM base AS setup-node -ENV NODE_VERSION=v22.22.0 \ +ENV NODE_VERSION=v24.15.0 \ PATH=/opt/node/bin:$PATH RUN set -eux; \ @@ -67,7 +67,7 @@ RUN set -eux; \ FROM base AS setup-caddy -ENV CADDY_VERSION=2.10.2 +ENV CADDY_VERSION=2.11.2 RUN set -eux; \ ARCH="$(dpkg --print-architecture)"; \ @@ -99,18 +99,18 @@ RUN set -eux; \ FROM base AS setup-jvm # https://clojure.org/releases/tools -ENV CLOJURE_VERSION=1.12.4.1602 +ENV CLOJURE_VERSION=1.12.4.1618 RUN set -eux; \ ARCH="$(dpkg --print-architecture)"; \ case "${ARCH}" in \ aarch64|arm64) \ - ESUM='9903c6b19183a33725ca1dfdae5b72400c9d00995c76fafc4a0d31c5152f33f7'; \ - BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_aarch64.tar.gz'; \ + ESUM='cc1b459dc442d7422b46a3b5fe52acaea54879fa7913e29a05650cef54687f5f'; \ + BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.30.11-ca-jdk26.0.1-linux_aarch64.tar.gz'; \ ;; \ amd64|x86_64) \ - ESUM='946ad9766d98fc6ab495a1a120072197db54997f6925fb96680f1ecd5591db4e'; \ - BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_x64.tar.gz'; \ + ESUM='7d6663ea8d4298df65de065e32f9f449745ff607d30ba5d13777cb92e9d4613d'; \ + BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.30.11-ca-jdk26.0.1-linux_x64.tar.gz'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ @@ -181,10 +181,16 @@ RUN set -eux; \ FROM base AS setup-utils -ENV CLJKONDO_VERSION=2026.01.19 \ +ENV CLJKONDO_VERSION=2026.04.15 \ BABASHKA_VERSION=1.12.208 \ - CLJFMT_VERSION=0.15.6 \ - PIXI_VERSION=0.63.2 + CLJFMT_VERSION=0.16.4 \ + PIXI_VERSION=0.67.2 \ + GITHUB_CLI_VERSION=2.91.0 \ + UV_VERSION=0.11.9 \ + UV_TOOL_DIR=/opt/uv/tools \ + UV_TOOL_BIN_DIR=/opt/utils/bin \ + UV_PYTHON_INSTALL_DIR=/opt/uv/python \ + SERENA_VERSION=1.5.0 RUN set -ex; \ ARCH="$(dpkg --print-architecture)"; \ @@ -267,6 +273,28 @@ RUN set -ex; \ tar -xf /tmp/cljfmt.tar.gz; \ rm -rf /tmp/cljfmt.tar.gz; + +RUN set -ex; \ + ARCH="$(dpkg --print-architecture)"; \ + case "${ARCH}" in \ + aarch64|arm64) \ + BINARY_URL="https://github.com/cli/cli/releases/download/v${GITHUB_CLI_VERSION}/gh_${GITHUB_CLI_VERSION}_linux_arm64.tar.gz"; \ + ;; \ + amd64|x86_64) \ + BINARY_URL="https://github.com/cli/cli/releases/download/v${GITHUB_CLI_VERSION}/gh_${GITHUB_CLI_VERSION}_linux_amd64.tar.gz"; \ + ;; \ + *) \ + echo "Unsupported arch: ${ARCH}"; \ + exit 1; \ + ;; \ + esac; \ + cd /tmp; \ + curl -LfsSo /tmp/gh.tar.gz ${BINARY_URL}; \ + mkdir /opt/gh; \ + cd /opt/gh; \ + tar -xv --strip-components=1 -f /tmp/gh.tar.gz; \ + rm -rf /tmp/gh.tar.gz; + # Install minio client RUN set -ex; \ ARCH="$(dpkg --print-architecture)"; \ @@ -286,6 +314,31 @@ RUN set -ex; \ mv /tmp/mc /opt/utils/bin/; \ chmod +x /opt/utils/bin/mc; +# Install uv +RUN set -ex; \ + ARCH="$(dpkg --print-architecture)"; \ + case "${ARCH}" in \ + aarch64|arm64) \ + BINARY_URL="https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-aarch64-unknown-linux-musl.tar.gz"; \ + ;; \ + amd64|x86_64) \ + BINARY_URL="https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-x86_64-unknown-linux-musl.tar.gz"; \ + ;; \ + *) \ + echo "Unsupported arch: ${ARCH}"; \ + exit 1; \ + ;; \ + esac; \ + curl -LfsSo /tmp/uv.tar.gz ${BINARY_URL}; \ + cd /opt/utils/bin; \ + tar -xf /tmp/uv.tar.gz --strip-components=1; \ + rm -rf /tmp/uv.tar.gz; + +# Install uv-managed tools +RUN set -ex; \ + /opt/utils/bin/uv tool install -p 3.13 \ + "serena-agent@${SERENA_VERSION}" \ + --prerelease=allow; ################################################################################ ## DEVENV BASE @@ -308,6 +361,8 @@ RUN set -ex; \ less \ jq \ nginx \ + fd-find \ + bat \ \ fontconfig \ woff-tools \ @@ -396,13 +451,20 @@ ENV LANG='C.UTF-8' \ JAVA_HOME="/opt/jdk" \ CARGO_HOME="/opt/cargo" \ RUSTUP_HOME="/opt/rustup" \ - PATH="/opt/jdk/bin:/opt/utils/bin:/opt/clojure/bin:/opt/node/bin:/opt/imagick/bin:/opt/cargo/bin:$PATH" + UV_TOOL_DIR="/opt/uv/tools" \ + UV_TOOL_BIN_DIR="/opt/utils/bin" \ + UV_PYTHON_INSTALL_DIR="/opt/uv/python" \ + SERENA_HOME="/home/penpot/.serena" \ + SERENA_CONTEXT="claude-code" \ + PATH="/opt/jdk/bin:/opt/gh/bin:/opt/utils/bin:/opt/clojure/bin:/opt/node/bin:/opt/imagick/bin:/opt/cargo/bin:$PATH" COPY --from=penpotapp/imagemagick:7.1.2-13 /opt/imagick /opt/imagick COPY --from=setup-jvm /opt/jdk /opt/jdk COPY --from=setup-jvm /opt/clojure /opt/clojure COPY --from=setup-node /opt/node /opt/node COPY --from=setup-utils /opt/utils /opt/utils +COPY --from=setup-utils /opt/gh /opt/gh +COPY --from=setup-utils /opt/uv /opt/uv COPY --from=setup-rust /opt/cargo /opt/cargo COPY --from=setup-rust /opt/rustup /opt/rustup COPY --from=setup-rust /opt/emsdk /opt/emsdk @@ -418,6 +480,7 @@ COPY files/tmux.conf /root/.tmux.conf COPY files/sudoers /etc/sudoers COPY files/Caddyfile /home/ +COPY files/serena_config.yml /home/serena_config.yml COPY files/selfsigned.crt /home/ COPY files/selfsigned.key /home/ COPY files/start-tmux.sh /home/start-tmux.sh diff --git a/docker/devenv/docker-compose.yaml b/docker/devenv/docker-compose.yaml index 4a680e4e6b..6963b18d8a 100644 --- a/docker/devenv/docker-compose.yaml +++ b/docker/devenv/docker-compose.yaml @@ -57,6 +57,10 @@ services: - 4201:4201 - 4202:4202 + # Serena MCP server (agentic mode only) + - ${SERENA_EXTERNAL_PORT:-14281}:14281 + - ${SERENA_DASHBOARD_EXTERNAL_PORT:-14282}:24282 + environment: - EXTERNAL_UID=${CURRENT_USER_ID} # SMTP setup @@ -83,6 +87,10 @@ services: - PENPOT_LDAP_ATTRS_FULLNAME=cn - PENPOT_LDAP_ATTRS_PHOTO=jpegPhoto + # agentic devenv + # Serena update: set to a commit/tag to update Serena on startup, leave empty to skip update and use the version in the image + - SERENA_UPDATE_VERSION=1.5.0 + networks: default: aliases: diff --git a/docker/devenv/files/bashrc b/docker/devenv/files/bashrc index 79ef2bd532..799d2f285a 100644 --- a/docker/devenv/files/bashrc +++ b/docker/devenv/files/bashrc @@ -2,7 +2,8 @@ EMSDK_QUIET=1 . /opt/emsdk/emsdk_env.sh; -export PATH="/home/penpot/.cargo/bin:/opt/jdk/bin:/opt/utils/bin:/opt/clojure/bin:/opt/node/bin:/opt/imagick/bin:/opt/cargo/bin:$PATH" +export JAVA_OPTS="-Djava.net.preferIPv4Stack=true" +export PATH="/home/penpot/.cargo/bin:/opt/jdk/bin:/opt/gh/bin:/opt/utils/bin:/opt/clojure/bin:/opt/node/bin:/opt/imagick/bin:/opt/cargo/bin:$PATH" export CARGO_HOME="/home/penpot/.cargo" export PENPOT_MCP_PLUGIN_SERVER_HOST=0.0.0.0 diff --git a/docker/devenv/files/entrypoint.sh b/docker/devenv/files/entrypoint.sh index 1427b19148..e12b062b4d 100755 --- a/docker/devenv/files/entrypoint.sh +++ b/docker/devenv/files/entrypoint.sh @@ -10,9 +10,20 @@ cp /root/.bashrc /home/penpot/.bashrc cp /root/.vimrc /home/penpot/.vimrc cp /root/.tmux.conf /home/penpot/.tmux.conf +# Seed SERENA_HOME with default config on first run +mkdir -p ${SERENA_HOME} +if [ ! -f "${SERENA_HOME}/serena_config.yml" ]; then + cp /home/serena_config.yml "${SERENA_HOME}/serena_config.yml" +fi +chown -R penpot:users ${SERENA_HOME} + chown penpot:users /home/penpot +# we need to be able to install rust-analyzer and possibly other dependencies with rustup +chown -R penpot:ubuntu /opt/rustup + rsync -ar --chown=penpot:users /opt/cargo/ /home/penpot/.cargo/ +export JAVA_OPTS="-Djava.net.preferIPv4Stack=true" export PATH="/home/penpot/.cargo/bin:$PATH" export CARGO_HOME="/home/penpot/.cargo" diff --git a/docker/devenv/files/nginx-security-headers.conf b/docker/devenv/files/nginx-security-headers.conf new file mode 100644 index 0000000000..d41baf3a22 --- /dev/null +++ b/docker/devenv/files/nginx-security-headers.conf @@ -0,0 +1,4 @@ +add_header X-Content-Type-Options "nosniff" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; +add_header X-Frame-Options SAMEORIGIN always; diff --git a/docker/devenv/files/nginx.conf b/docker/devenv/files/nginx.conf index 3a6f50b4be..5847e6551a 100644 --- a/docker/devenv/files/nginx.conf +++ b/docker/devenv/files/nginx.conf @@ -74,6 +74,8 @@ http { resolver 127.0.0.11 ipv6=off; etag off; + proxy_hide_header X-Powered-By; + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; root /home/penpot/penpot/frontend/resources/public; @@ -92,6 +94,7 @@ http { proxy_pass $redirect_uri; proxy_ssl_server_name on; + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header x-internal-redirect "$redirect_uri"; add_header x-cache-control "$redirect_cache_control"; add_header cache-control "$redirect_cache_control"; @@ -108,6 +111,7 @@ http { location /internal/assets { internal; alias /home/penpot/penpot/backend/assets; + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header x-internal-redirect "$upstream_http_x_accel_redirect"; } @@ -174,7 +178,7 @@ http { proxy_pass http://127.0.0.1:5000; } - location /control-center { + location /admin-console { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; @@ -186,6 +190,7 @@ http { location /wasm-playground { alias /home/penpot/penpot/frontend/resources/public/wasm-playground/; + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header Cache-Control "no-cache, max-age=0"; autoindex on; } @@ -211,6 +216,7 @@ http { proxy_set_header User-Agent "curl/8.5.0"; proxy_set_header Host "raw.githubusercontent.com"; proxy_set_header Accept "*/*"; + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header Access-Control-Allow-Origin $http_origin; proxy_buffering off; } @@ -235,6 +241,7 @@ http { proxy_cache penpot; + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header Access-Control-Allow-Origin $http_origin; add_header Cache-Control max-age=86400; add_header X-Cache-Status $upstream_cache_status; @@ -257,16 +264,19 @@ http { proxy_cache penpot; + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header Access-Control-Allow-Origin $http_origin; add_header Cache-Control max-age=86400; add_header X-Cache-Status $upstream_cache_status; } location ~* \.(jpg|png|svg|ttf|woff|woff2|gif)$ { + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header Cache-Control "public, max-age=604800" always; # 7 days } location ~* \.(js|css|wasm)$ { + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header Cache-Control "no-store" always; } @@ -274,6 +284,7 @@ http { return 301 " /404"; } + include /home/penpot/penpot/docker/devenv/files/nginx-security-headers.conf; add_header Cache-Control "no-store" always; try_files $uri /index.html$is_args$args /index.html =404; } diff --git a/docker/devenv/files/serena_config.yml b/docker/devenv/files/serena_config.yml new file mode 100644 index 0000000000..d62bad2c79 --- /dev/null +++ b/docker/devenv/files/serena_config.yml @@ -0,0 +1,153 @@ +language_backend: LSP + +# line ending convention to use when writing source files. +# Possible values: "lf" (Unix), "crlf" (Windows), "native" (platform default). +# Note that Serena's own files (e.g. memories and configuration files) always use native line endings. +# This setting can be overridden on a per-project basis in project.yml files. +line_ending: native + +# whether to open a graphical window with Serena's logs. +# This is mainly supported on Windows and (partly) on Linux; not available on macOS. +# If you prefer a browser-based tool, use the `web_dashboard` option instead. +# Further information: https://oraios.github.io/serena/02-usage/060_dashboard.html +# +# Being able to inspect logs is useful both for troubleshooting and for monitoring the tool calls, +# especially when using the agno playground, since the tool calls are not always shown, +# and the input params are never shown in the agno UI. +# When used as MCP server for Claude Desktop, the logs are primarily for troubleshooting. +# Note: unfortunately, the various entities starting the Serena server or agent do so in +# mysterious ways, often starting multiple instances of the process without shutting down +# previous instances. This can lead to multiple log windows being opened, and only the last +# window being updated. Since we can't control how agno or Claude Desktop start Serena, +# we have to live with this limitation for now. +gui_log_window: false + +# whether to start the Serena Dashboard, which provides detailed information on your Serena session, +# the current configuration and furthermore allows some settings to be conveniently modified on the fly. +# We strongly recommend to always enable this option! +# If you want to prevent the Dashboard window from being opened on launch, +# set `web_dashboard_open_on_launch` to false (see below). +# Further information: https://oraios.github.io/serena/02-usage/060_dashboard.html +web_dashboard: true + +# whether to open the Dashboard window/browser tab when Serena starts (provided that web_dashboard is enabled). +# If set to false, you can still open the dashboard manually by clicking on the Serena icon in your system +# tray on Windows and macOS. On Linux, there is no system tray support, so you can only open the dashboard by +# a) telling the LLM to "open the dashboard" (provided that the open_dashboard tool is enabled) or by +# b) manually navigating to http://localhost:24282/dashboard/ in your web browser (actual port +# may be higher if you have multiple instances running; try ports 24283, 24284, etc.) +# See also: https://oraios.github.io/serena/02-usage/060_dashboard.html +web_dashboard_open_on_launch: false + +# defines the interface (application mode) used for the web dashboard (if enabled). +# If empty/null, use platform-dependent default. Otherwise, possible values: +# * browser: the dashboard is opened in the default browser (if `web_dashboard_open_on_launch` is true) +# This is supported on all platforms. +# * app: the dashboard is opened in a separate native-like app window with accompanying tray icon, whose +# lifecycle is tied to the Serena process. +# If `web_dashboard_open_on_launch` is false, the dashboard can be conveniently accessed via the tray icon. +# This is supported on Windows and macOS, but note that on macOS, where tray icons are very visible, +# this may result in too many icons being displayed when using multi-agent setups. +# * tray_manager: use a global tray icon to provide access to the dashboards of all running Serena instances, +# opening the dashboard in browser tabs when selected from the tray menu. +# This is EXPERIMENTAL. It is tested on Windows only. We will establish macOS support, but it is yet untested. +# On Linux, this cannot be universally supported, but it may work in some desktop environments. +web_dashboard_interface: + +# the address the web dashboard will listen on (bind address). +web_dashboard_listen_address: 0.0.0.0 + +# address where JetBrains plugin servers are running (only relevant when using the JetBrains language backend) +jetbrains_plugin_server_address: 127.0.0.1 + +# the minimum log level for the GUI log window and the dashboard (10 = debug, 20 = info, 30 = warning, 40 = error) +log_level: 20 + +# whether to trace the communication between Serena and the language servers. +# This is useful for debugging language server issues. +trace_lsp_communication: false + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} + +# list of paths to ignore across all projects. +# Same syntax as gitignore, so you can use * and **. +# These patterns are merged additively with each project's own ignored_paths. +ignored_paths: [] + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# For example, "global/.*" will mark all global memories as read-only. +# You can extend the list on a per-project basis in the project.yml configuration file. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# This is useful for projects with large numbers of archived memory files. +# You can extend the list on a per-project basis in the project.yml configuration file. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] + +# timeout, in seconds, after which tool executions are terminated +tool_timeout: 240 + +# list of tools to be globally excluded +excluded_tools: [] + +# list of optional tools (which are disabled by default) to be included +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +fixed_tools: [] + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If this is undefined, no base modes are included. +# The project configuration (project.yml) may override this setting. +base_modes: [no-onboarding] + +# list of mode names that are to be activated by default. +# The full set of modes to be activated is base_modes + default_modes. +# These modes can be overridden by the project configuration (project.yml) or through the CLI (--mode). +default_modes: +- interactive +- editing +default_max_tool_answer_chars: 150000 + +# the name of the token count estimator to use for tool usage statistics. +# See the `RegisteredTokenCountEstimator` enum for available options. +# +# By default, a very naive character count estimator is used, which simply counts the number of characters. +# You can configure this to TIKTOKEN_GPT4 to use a local tiktoken-based estimator for GPT-4 (will download tiktoken +# data files on first run), or ANTHROPIC_CLAUDE_SONNET_4 which will use the (free of cost) Anthropic API to +# estimate the token count using the Claude Sonnet 4 tokenizer. +token_count_estimator: CHAR_COUNT + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# (currently only used by LSP-based tools). +# If the budget is exceeded, Serena stops issuing further retrieval requests +# and returns partial info results. +# 0 disables the budget (no early stopping). Negative values are invalid. +# This is an advanced setting that can help alleviate problems with LSP servers +# that have a slow implementation of request_hover (clangd is one of those) +# or with tool calls that find very many symbols. +# Can be overridden in project.yml. +symbol_info_budget: 10 + +# template for the location of the per-project .serena data folder (memories, caches, etc.). +# Supports the following placeholders: +# $projectDir - the absolute path to the project root directory +# $projectFolderName - the name of the project directory +# Default: "$projectDir/.serena" (data stored inside the project directory) +# Example for a central location: "/projects-metadata/$projectFolderName/.serena" +project_serena_folder_location: "$projectDir/.serena" + +# the list of registered project paths (updated automatically). +projects: +- /home/penpot/penpot diff --git a/docker/devenv/files/start-tmux.sh b/docker/devenv/files/start-tmux.sh index 6418e0a86b..ae61f32c5c 100755 --- a/docker/devenv/files/start-tmux.sh +++ b/docker/devenv/files/start-tmux.sh @@ -41,4 +41,26 @@ tmux select-window -t penpot:3 tmux send-keys -t penpot 'cd penpot/backend' enter C-l tmux send-keys -t penpot './scripts/start-dev' enter +if echo "$PENPOT_FLAGS" | grep -q "enable-mcp"; then + pushd ~/penpot/mcp/ + ./scripts/setup; + pnpm run build; + popd + + tmux new-window -t penpot:4 -n 'mcp' + tmux select-window -t penpot:4 + tmux send-keys -t penpot 'cd penpot/mcp' enter C-l + tmux send-keys -t penpot './scripts/start-mcp-devenv' enter +fi + +if [ "${SERENA_ENABLED:-false}" = "true" ]; then + if [ -n "${SERENA_UPDATE_VERSION}" ]; then + # update Serena (use sudo since the initial Serena installation is global; see Dockerfile) + sudo -E uv tool install -p 3.13 serena-agent@${SERENA_UPDATE_VERSION} --prerelease=allow + fi + tmux new-window -t penpot:5 -n 'serena' + tmux select-window -t penpot:5 + tmux send-keys -t penpot "serena start-mcp-server --transport streamable-http --port 14281 --project penpot --context ${SERENA_CONTEXT} --host 0.0.0.0" enter +fi + tmux -2 attach-session -t penpot diff --git a/docker/images/Dockerfile.backend b/docker/images/Dockerfile.backend index c3d08916a6..c27d2c4363 100644 --- a/docker/images/Dockerfile.backend +++ b/docker/images/Dockerfile.backend @@ -5,7 +5,7 @@ ENV LANG='C.UTF-8' \ LC_ALL='C.UTF-8' \ JAVA_HOME="/opt/jdk" \ DEBIAN_FRONTEND=noninteractive \ - NODE_VERSION=v22.22.0 \ + NODE_VERSION=v24.15.0 \ TZ=Etc/UTC RUN set -ex; \ @@ -46,12 +46,12 @@ RUN set -eux; \ ARCH="$(dpkg --print-architecture)"; \ case "${ARCH}" in \ aarch64|arm64) \ - ESUM='9903c6b19183a33725ca1dfdae5b72400c9d00995c76fafc4a0d31c5152f33f7'; \ - BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_aarch64.tar.gz'; \ + ESUM='cc1b459dc442d7422b46a3b5fe52acaea54879fa7913e29a05650cef54687f5f'; \ + BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.30.11-ca-jdk26.0.1-linux_aarch64.tar.gz'; \ ;; \ amd64|x86_64) \ - ESUM='946ad9766d98fc6ab495a1a120072197db54997f6925fb96680f1ecd5591db4e'; \ - BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_x64.tar.gz'; \ + ESUM='7d6663ea8d4298df65de065e32f9f449745ff607d30ba5d13777cb92e9d4613d'; \ + BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.30.11-ca-jdk26.0.1-linux_x64.tar.gz'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ @@ -68,7 +68,7 @@ RUN set -eux; \ --no-header-files \ --no-man-pages \ --strip-debug \ - --add-modules java.base,jdk.management.agent,java.se,jdk.compiler,jdk.javadoc,jdk.attach,jdk.unsupported,jdk.jfr,jdk.jcmd \ + --add-modules java.base,jdk.net,jdk.management.agent,java.se,jdk.compiler,jdk.javadoc,jdk.attach,jdk.unsupported,jdk.jfr,jdk.jcmd \ --output /opt/jre; FROM ubuntu:24.04 AS image diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index 03be19d2f3..0049c6dd76 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -3,7 +3,7 @@ LABEL maintainer="Penpot <docker@penpot.app>" ENV LANG=en_US.UTF-8 \ LC_ALL=en_US.UTF-8 \ - NODE_VERSION=v22.22.0 \ + NODE_VERSION=v24.15.0 \ DEBIAN_FRONTEND=noninteractive \ PATH=/opt/node/bin:/opt/imagick/bin:$PATH \ PLAYWRIGHT_BROWSERS_PATH=/opt/penpot/browsers diff --git a/docker/images/Dockerfile.frontend b/docker/images/Dockerfile.frontend index 9f435551ad..3e0edbf002 100644 --- a/docker/images/Dockerfile.frontend +++ b/docker/images/Dockerfile.frontend @@ -1,4 +1,4 @@ -FROM nginxinc/nginx-unprivileged:1.29.1 +FROM nginxinc/nginx-unprivileged:1.30.0 LABEL maintainer="Penpot <docker@penpot.app>" USER root @@ -17,6 +17,7 @@ ARG BUNDLE_PATH="./bundle-frontend/" COPY $BUNDLE_PATH /var/www/app/ COPY ./files/config.js /var/www/app/js/config.js COPY ./files/nginx.conf.template /tmp/nginx.conf.template +COPY ./files/nginx-security-headers.conf /etc/nginx/nginx-security-headers.conf COPY ./files/nginx-resolvers.conf.template /tmp/resolvers.conf.template COPY ./files/nginx-mime.types /etc/nginx/mime.types COPY ./files/nginx-external-locations.conf /etc/nginx/overrides/location.d/external-locations.conf diff --git a/docker/images/Dockerfile.storybook b/docker/images/Dockerfile.storybook index 24e6acc5cf..9cccbe799b 100644 --- a/docker/images/Dockerfile.storybook +++ b/docker/images/Dockerfile.storybook @@ -1,4 +1,4 @@ -FROM nginxinc/nginx-unprivileged:1.29.1 +FROM nginxinc/nginx-unprivileged:1.30.0 LABEL maintainer="Penpot <docker@penpot.app>" USER root diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index 5d3b84d09c..25d419a354 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -24,7 +24,7 @@ # WARNING: if you're exposing Penpot to the internet, you should remove the flags # 'disable-secure-session-cookies' and 'disable-email-verification' x-flags: &penpot-flags - PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies + PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp x-uri: &penpot-public-uri PENPOT_PUBLIC_URI: http://localhost:9001 @@ -78,7 +78,7 @@ services: # - "443:443" penpot-frontend: - image: "penpotapp/frontend:${PENPOT_VERSION:-latest}" + image: "penpotapp/frontend:${PENPOT_VERSION:-2.15}" restart: always ports: - 9001:8080 @@ -105,10 +105,10 @@ services: # - "traefik.http.routers.penpot-https.tls=true" environment: - << : [*penpot-flags, *penpot-http-body-size] + << : [*penpot-flags, *penpot-http-body-size, *penpot-public-uri] penpot-backend: - image: "penpotapp/backend:${PENPOT_VERSION:-latest}" + image: "penpotapp/backend:${PENPOT_VERSION:-2.15}" restart: always volumes: @@ -176,8 +176,14 @@ services: PENPOT_SMTP_TLS: false PENPOT_SMTP_SSL: false + penpot-mcp: + image: "penpotapp/mcp:${PENPOT_VERSION:-2.15}" + restart: always + networks: + - penpot + penpot-exporter: - image: "penpotapp/exporter:${PENPOT_VERSION:-latest}" + image: "penpotapp/exporter:${PENPOT_VERSION:-2.15}" restart: always depends_on: diff --git a/docker/images/files/nginx-entrypoint.sh b/docker/images/files/nginx-entrypoint.sh index adf5844dea..571c9f6782 100644 --- a/docker/images/files/nginx-entrypoint.sh +++ b/docker/images/files/nginx-entrypoint.sh @@ -19,6 +19,10 @@ update_flags() { -e "s|^//var penpotFlags = .*;|var penpotFlags = \"$PENPOT_FLAGS\";|g" \ "$1")" > "$1" fi + + if [ -n "$PENPOT_PUBLIC_URI" ]; then + echo "var penpotPublicURI = \"$PENPOT_PUBLIC_URI\";" >> "$1"; + fi } update_oidc_name() { @@ -39,8 +43,10 @@ update_oidc_name /var/www/app/js/config.js export PENPOT_BACKEND_URI=${PENPOT_BACKEND_URI:-http://penpot-backend:6060} export PENPOT_EXPORTER_URI=${PENPOT_EXPORTER_URI:-http://penpot-exporter:6061} export PENPOT_NITRATE_URI=${PENPOT_NITRATE_URI:-http://penpot-nitrate:3000} +export PENPOT_MCP_URI=${PENPOT_MCP_URI:-http://penpot-mcp:4401} +export PENPOT_MCP_URI_WS=${PENPOT_MCP_URI_WS:-http://penpot-mcp:4402} export PENPOT_HTTP_SERVER_MAX_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_BODY_SIZE:-367001600} # Default to 350MiB -envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_NITRATE_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE" \ +envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_NITRATE_URI,\$PENPOT_MCP_URI,\$PENPOT_MCP_URI_WS,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE" \ < /tmp/nginx.conf.template > /etc/nginx/nginx.conf PENPOT_DEFAULT_INTERNAL_RESOLVER="$(awk 'BEGIN{ORS=" "} $1=="nameserver" { sub(/%.*$/,"",$2); print ($2 ~ ":")? "["$2"]": $2}' /etc/resolv.conf)" diff --git a/docker/images/files/nginx-security-headers.conf b/docker/images/files/nginx-security-headers.conf new file mode 100644 index 0000000000..d41baf3a22 --- /dev/null +++ b/docker/images/files/nginx-security-headers.conf @@ -0,0 +1,4 @@ +add_header X-Content-Type-Options "nosniff" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; +add_header X-Frame-Options SAMEORIGIN always; diff --git a/docker/images/files/nginx.conf.template b/docker/images/files/nginx.conf.template index d0b7bc3b1f..3be94f0a5d 100644 --- a/docker/images/files/nginx.conf.template +++ b/docker/images/files/nginx.conf.template @@ -80,6 +80,8 @@ http { charset utf-8; etag off; + proxy_hide_header X-Powered-By; + include /etc/nginx/nginx-security-headers.conf; root /var/www/app/; @@ -100,6 +102,7 @@ http { proxy_ssl_server_name on; proxy_pass $redirect_uri; + include /etc/nginx/nginx-security-headers.conf; add_header x-internal-redirect "$redirect_uri"; add_header x-cache-control "$redirect_cache_control"; add_header cache-control "$redirect_cache_control"; @@ -118,6 +121,7 @@ http { location /internal/assets { internal; alias /opt/data/assets; + include /etc/nginx/nginx-security-headers.conf; add_header x-internal-redirect "$upstream_http_x_accel_redirect"; } @@ -135,6 +139,23 @@ http { proxy_http_version 1.1; } + location /mcp/ws { + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_pass $PENPOT_MCP_URI_WS; + proxy_http_version 1.1; + } + + location /mcp/stream { + proxy_pass $PENPOT_MCP_URI/mcp; + proxy_http_version 1.1; + } + + location /mcp/sse { + proxy_pass $PENPOT_MCP_URI/sse; + proxy_http_version 1.1; + } + location /readyz { access_log off; proxy_pass $PENPOT_BACKEND_URI$request_uri; @@ -146,7 +167,7 @@ http { proxy_pass $PENPOT_BACKEND_URI/ws/notifications; } - location /control-center { + location /admin-console { proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $http_cf_connecting_ip; @@ -160,6 +181,7 @@ http { include /etc/nginx/overrides/location.d/*.conf; location ~* \.(js|css|jpg|png|svg|gif|ttf|woff|woff2|wasm|map)$ { + include /etc/nginx/nginx-security-headers.conf; add_header Cache-Control "public, max-age=604800" always; # 7 days } @@ -167,7 +189,7 @@ http { return 301 " /404"; } - add_header X-Frame-Options SAMEORIGIN always; + include /etc/nginx/nginx-security-headers.conf; add_header Cache-Control "no-store, no-cache, max-age=0" always; try_files $uri /index.html$is_args$args /index.html =404; diff --git a/docs/_includes/layouts/base.njk b/docs/_includes/layouts/base.njk index 076acbffec..97fc32e20b 100644 --- a/docs/_includes/layouts/base.njk +++ b/docs/_includes/layouts/base.njk @@ -84,7 +84,7 @@ <path d="M68.318 0L31.941 51.237v28.961L.286 95.255 0 95.121v188.952l123.419 58.309 7.542 3.56 7.542-3.56 123.418-58.309V95.121l-.232.11-31.665-15.062V51.237l-1.107-1.559L193.645 0l-36.38 51.237v.05l-26.47-37.281L104.528 51l-.939-1.322L68.318 0zm6.437 29.762l14.07 19.816H47.811L61.72 29.995l13.036-.233zm125.324 0l14.072 19.816h-41.012l13.903-19.583 13.037-.233zM137.23 43.77l14.07 19.813h-41.008L124.195 44l13.035-.23zM43.923 59.564h19.452v65.497l-19.452-9.19V59.564zm29.438 0h19.354v79.356l-19.354-9.142V59.564zm95.89 0h19.45v70.146l-19.45 9.188V59.564zm29.435 0h19.352v56.285l-19.352 9.142V59.564zM106.4 73.57h19.451v81.004l-19.451-9.19V73.57zm29.438 0h19.352v71.971l-19.352 9.145V73.57zm924.624 7.15l-.096 36.464-19.553-.134v14.857h19.61v78.333c0 5.705.445 10.996 1.348 15.873l.009.042.007.044c1.107 4.982 2.979 9.326 5.65 12.95 2.692 3.654 6.295 6.463 10.645 8.36 4.437 2.11 9.836 3.096 16.17 3.096 2.887 0 6.193-.47 9.981-1.373a90.691 90.691 0 0011.285-3.492l2.202-.84-4.751-14.248-2.492 1.15c-4.84 2.098-9.583 3.667-14.656 3.695-4.087 0-7.299-.587-9.651-1.6-2.245-1.215-3.98-2.738-5.298-4.629-1.375-2.15-2.385-4.622-3.025-7.463-.491-3.143-.748-6.645-.748-10.518v-79.38h36.342v-14.844H1077.1V80.72h-16.637zM230.02 95.096l17.126 7.002-17.126 8.09V95.095zm-198.08.025v15.09l-17.122-8.09 17.122-7zm802.504 18.8l.001.002v.002c-8.243 0-16.244 2.03-23.884 6.031l-.01.007-.012.006c-6.172 3.312-11.324 6.77-16.74 10.856l-.096-13.762H777.61v187.141h16.414V237.31c5.379 3.67 10.692 6.85 15.895 9.163l.026.01.027.014c6.529 2.72 12.96 4.108 19.247 4.108 7.652 0 14.986-1.557 21.917-4.658l.013-.005.015-.008c7.106-3.28 13.324-7.946 18.592-13.941l-.005.002c5.456-6.184 9.708-13.62 12.762-22.242 3.073-8.675 4.585-18.459 4.585-29.319 0-19.865-4.26-35.933-13.01-47.97l-.003-.006c-8.818-12.304-22.34-18.537-39.64-18.537zm-397.499.002v.002c-8.243 0-16.244 2.03-23.884 6.031l-.01.007-.012.006c-6.172 3.312-11.324 6.772-16.74 10.858l-.096-13.764H380.11v187.143h16.414v-66.894c5.379 3.67 10.692 6.848 15.895 9.16l.026.014.028.012c6.528 2.72 12.958 4.11 19.246 4.11 7.652 0 14.986-1.558 21.917-4.659l.013-.007.015-.006c7.106-3.28 13.324-7.946 18.592-13.941l-.005.001c5.456-6.183 9.708-13.618 12.762-22.241 3.073-8.675 4.585-18.459 4.585-29.32 0-19.864-4.26-35.933-13.01-47.97l-.003-.005c-8.817-12.304-22.338-18.537-39.639-18.537zm267.246 0v.002c-9.113 0-17.375 2.306-24.569 6.91-5.873 3.616-11.564 8.222-17.142 13.445l.115-17.217h-15.765v130.406h16.417v-94.62c7.284-7.373 13.843-14.357 19.506-17.917l.02-.014.02-.013c5.715-3.756 11.989-5.61 19.046-5.61 9.77 0 16.07 2.884 19.925 8.59l.022.032.024.033c4.045 5.6 6.332 14.962 6.332 28.107v81.412h16.415v-82.984c0-16.688-3.059-29.35-9.668-37.847-6.622-8.515-17.134-12.715-30.698-12.715zm-134.972.002c-7.331 0-14.503 1.659-21.42 4.93l-.01.006c-6.764 3.11-12.876 7.682-18.298 13.647l-.002.002c-5.287 5.833-9.533 12.995-12.76 21.425l-.01.022-.007.022c-3.075 8.501-4.593 18.028-4.593 28.548s1.518 20.049 4.593 28.55l.009.029.013.03c3.233 8.263 7.572 15.42 13.018 21.412l.015.012.011.015c5.453 5.817 11.854 10.299 19.133 13.392l.009.004.01.004c7.447 3.087 15.367 4.63 23.687 4.63 9.459 0 17.36-1.32 23.714-4.118l.013-.007c6.178-2.648 11.843-5.48 16.994-8.5l2.03-1.188-7.144-13.493L605.93 225c-4.517 3.346-8.972 5.427-14.335 7.44-5.093 1.803-11.373 2.728-17.93 2.728-6.832 0-12.9-.649-18.294-3.095-5.45-2.645-10.123-6.175-14.083-10.62-3.792-4.625-6.783-10.087-8.96-16.446l-.001-.006c-1.93-5.795-2.945-12.072-3.3-18.708h90.5c0-2.558.246-5.015.255-7.163v-4.706c0-18.854-4.374-33.79-13.39-44.463-8.998-10.65-21.63-16.035-37.171-16.035v-.001zm397.808 0c-7.818 0-15.318 1.554-22.418 4.649-7.104 3.096-13.407 7.576-18.85 13.383l-.014.014-.016.015c-5.295 5.843-9.541 13.1-12.768 21.704l-.005.012-.006.015c-3.077 8.506-4.59 18.12-4.59 28.81 0 10.69 1.513 20.305 4.59 28.811l.011.03.013.03c3.23 8.254 7.475 15.324 12.753 21.148l.015.013.014.016c5.444 5.807 11.746 10.285 18.85 13.381 7.1 3.096 14.602 4.65 22.42 4.65 7.819 0 15.32-1.554 22.42-4.65 7.27-3.093 13.585-7.575 18.863-13.396 5.465-5.832 9.724-12.922 12.783-21.196 3.258-8.514 4.863-18.14 4.863-28.837 0-10.692-1.604-20.315-4.86-28.826-3.054-8.614-7.31-15.885-12.787-21.731-5.279-5.82-11.592-10.303-18.862-13.396-7.1-3.095-14.6-4.65-22.418-4.65h-.001zm-951.946 5.004l108.337 51.178v155.59L15.082 274.519v-155.59zm231.755 0v155.59l-108.334 51.178v-155.59l108.334-51.178zm322.641 9.842h.002c10.75 0 18.852 3.647 25.038 11.134l.005.011.008.011c5.748 6.807 8.88 17.511 9.22 32.04h-74.538c.78-6.15 2.074-11.798 4.1-16.723 2.515-5.69 5.586-10.488 9.203-14.438 3.8-3.96 7.955-6.882 12.506-8.833l.033-.016.033-.015c4.566-2.12 9.33-3.171 14.39-3.171zm-134.36.26v.002c6.826 0 12.488 1.325 17.114 3.878l.03.014.029.017c4.685 2.423 8.428 5.844 11.349 10.387l.012.019.011.016c2.936 4.404 5.096 9.763 6.438 16.137l.006.031.009.03c1.52 6.416 2.287 13.372 2.287 20.874 0 8.323-1.104 15.91-3.285 22.787l-.007.019-.006.018c-2.02 6.733-4.856 12.468-8.492 17.261-3.63 4.786-7.952 8.447-13.028 11.067-5.06 2.611-10.479 3.91-16.39 3.91-4.471 0-9.768-1.132-15.842-3.494-5.688-2.212-12.01-7.647-18.827-13.056v-69.463c7.23-6.16 13.974-12.007 20.127-15.245l.009-.004.011-.007c6.52-3.51 12.647-5.198 18.444-5.198zm397.497 0l.002.002c6.827 0 12.489 1.325 17.115 3.878l.03.014.029.017c4.685 2.423 8.428 5.844 11.348 10.387l.013.019.011.016c2.936 4.404 5.096 9.763 6.438 16.137l.006.031.009.03c1.52 6.416 2.287 13.372 2.287 20.874 0 8.323-1.104 15.91-3.285 22.787l-.01.019-.005.018c-2.02 6.733-4.856 12.468-8.492 17.261-3.63 4.786-7.952 8.447-13.028 11.067-5.059 2.611-10.479 3.91-16.39 3.91-4.47 0-9.768-1.132-15.842-3.494-5.687-2.212-12.01-7.647-18.827-13.056v-69.463c7.23-6.16 13.974-12.007 20.127-15.245l.01-.005.01-.006c6.52-3.51 12.647-5.198 18.444-5.198zm134.414 0v.002c5.911 0 11.331 1.3 16.39 3.91l.044.02.04.021c5.223 2.448 9.595 6.004 13.206 10.765l.027.038.03.035c3.798 4.624 6.797 10.183 8.981 16.736 2.17 6.51 3.272 13.824 3.272 21.969 0 7.965-1.1 15.19-3.272 21.707-2.184 6.553-5.183 12.11-8.981 16.734l-.017.022-.014.022c-3.614 4.6-8.012 8.182-13.272 10.814-5.064 2.448-10.505 3.671-16.434 3.671-5.921 0-11.358-1.221-16.417-3.664-5.087-2.63-9.503-6.226-13.304-10.849-3.629-4.624-6.548-10.191-8.734-16.75-2.173-6.517-3.274-13.742-3.274-21.707 0-8.145 1.103-15.458 3.274-21.97 2.188-6.565 5.11-12.138 8.745-16.766l.003-.004.004-.007c3.801-4.79 8.2-8.364 13.255-10.81l.03-.015.027-.013c5.059-2.611 10.48-3.911 16.391-3.911z"></path> </svg> </a> - <span>Design as code</span> + <span>Full-Stack Design</span> </div> <ul class="footer-block"> <li class="footer-block--title">Product</li> diff --git a/docs/contributing-guide/index.njk b/docs/contributing-guide/index.njk index e0bb204487..74574e4975 100644 --- a/docs/contributing-guide/index.njk +++ b/docs/contributing-guide/index.njk @@ -2,7 +2,7 @@ title: Contributing desc: Learn how to contribute to Penpot, the open-source design collaboration platform! Find guides on bug reporting, translations, code contributions, and more. eleventyNavigation: - key: Contributing + key: Contribute order: 3 --- @@ -10,7 +10,7 @@ eleventyNavigation: <img src="/img/home-contribution.webp" alt="User guide" border="0"> </div> -<h1 id="contributing-guide">Contributing guide.</h1> +<h1 id="contributing-guide">Contributing guide</h1> <p class="main-paragraph">In this documentation you will find (almost) everything you need to know about how to contribute at Penpot.</p> diff --git a/docs/img/troubleshooting/troubleshooting-chrome.webp b/docs/img/troubleshooting/troubleshooting-chrome.webp new file mode 100644 index 0000000000..a999f41b2d Binary files /dev/null and b/docs/img/troubleshooting/troubleshooting-chrome.webp differ diff --git a/docs/img/troubleshooting/troubleshooting-firefox.webp b/docs/img/troubleshooting/troubleshooting-firefox.webp new file mode 100644 index 0000000000..93c151fee5 Binary files /dev/null and b/docs/img/troubleshooting/troubleshooting-firefox.webp differ diff --git a/docs/mcp/index.md b/docs/mcp/index.md index c338faf96d..e0d31fe6ce 100644 --- a/docs/mcp/index.md +++ b/docs/mcp/index.md @@ -2,6 +2,9 @@ title: Penpot MCP server order: 1 desc: Installing and using the Penpot MCP server with any AI agent or LLM you trust. +eleventyNavigation: + key: MCP Server + order: 6 --- <div class="main-illus"> @@ -69,7 +72,7 @@ There are three key pieces: ### Basic concepts Some important concepts for users: -* **Integrations page**: MCP is configured under **Your account → Integrations → MCP Server (Beta)**. Here you enable or disable MCP, get the server URL and manage the MCP key. +* **Integrations page**: MCP is configured under **Your account → Integrations → MCP Server**. Here you enable or disable MCP, get the server URL and manage the MCP key. * **MCP key**: a personal, non-recoverable token that authenticates your AI client with the MCP server. Only one key can exist per user at a time. This is used by the remote MCP setup. * **Currently focused page**: MCP always operates on the page you have in focus in Penpot. If you change the focused page (even in another browser window), the MCP context follows that page. * **Active MCP tab**: MCP can only be active in one browser tab at a time. If you have Penpot open in several tabs, you choose explicitly which one owns MCP before running agents. @@ -106,36 +109,24 @@ Because **remote MCP** does not expose local file-system access: ## Quick start -If you just want to try Penpot MCP quickly, follow this path for the **hosted (remote) MCP server**. +If you just want to try Penpot AI workflows quickly through the MCP, follow this path for the **hosted (remote) MCP server**. ### Remote MCP in 5 steps -<div class="advice"> - -### Important: remote MCP is not in production yet - -Remote MCP is not available yet in Penpot production (`design.penpot.app`). It is planned for an upcoming release (currently targeted around **2.16**). - -Right now, the remote MCP flow is available only in **testing environments** (for example, instances deployed from the `staging` branch: https://github.com/penpot/penpot/tree/staging). - -If you need MCP in production today, use the **Local MCP server** setup instead. See [Local MCP server](#local-mcp-server). - -</div> - 1. #### Enable MCP in Penpot - Go to **Your account → Integrations → MCP Server (Beta)** and enable the feature. + Go to **Your account → Integrations → MCP Server** and enable the feature. - ![MCP Server (Beta) in Penpot Integrations, enable](/img/mcp/mcp-enable.webp) + ![MCP Server in Penpot Integrations, enable](/img/mcp/mcp-enable.webp) 2. #### Generate your MCP key If you do not have one yet, create it. The key is shown only once—store it safely. - ![MCP Server (Beta) in Penpot Integrations, generate key](/img/mcp/mcp-generate-key.webp) + ![MCP Server in Penpot Integrations, generate key](/img/mcp/mcp-generate-key.webp) 3. #### Copy the server URL In the same Integrations section, copy the **server URL** that already includes your MCP key as `userToken`. - ![MCP Server (Beta) in Penpot Integrations, copy server url](/img/mcp/mcp-server-url.webp) + ![MCP Server in Penpot Integrations, copy server url](/img/mcp/mcp-server-url.webp) 4. #### Add the server to your MCP client In your MCP-aware IDE/agent (Cursor, Claude Code, etc.), add a new server pointing to that URL. @@ -191,7 +182,7 @@ You can use Penpot MCP server in two main ways: * Hosted for you (no need to run anything on your machine). * Best option for most users, simpler installation and fewer moving parts. * Does **not** have privileged access to your local file system, it can only work with what Penpot exposes (design files, libraries, tokens, etc.). - * The **server URL** is provided in **Your account → Integrations → MCP Server (Beta)** and looks like: + * The **server URL** is provided in **Your account → Integrations → MCP Server** and looks like: * `https://<your-penpot-domain>/mcp/stream?userToken=YOUR_MCP_KEY` * The domain depends on the Penpot installation. In the official SaaS it will be `design.penpot.app`. * **Local MCP server** @@ -298,21 +289,11 @@ In Penpot, open a file and connect the plugin from **File → MCP Server → Con Remote MCP is the easiest way to start using AI agents with Penpot. It's hosted for you, so you don't need to install or run anything on your machine. -<div class="advice"> - -### Availability note - -Remote MCP is currently available only in **testing environments**. It is not yet available in Penpot production (`design.penpot.app`) and is planned for an upcoming release (currently targeted around **2.16**). - -If you need MCP in production today, use the **Local MCP server** setup instead. See [Local MCP server](#local-mcp-server). - -</div> - <a id="install-and-activate-remote"></a> ### Install and activate 1. Open **Your account → Integrations**. -2. In the **MCP Server (Beta)** section, read the short description to confirm that feature is available for your account. +2. In the **MCP Server** section, read the short description to confirm that feature is available for your account. 3. Use the **Status** toggle to enable MCP Server. Penpot remembers this state per user across sessions. 4. If this is your first time, Penpot will ask you to **generate an MCP key**. The key is shown only once, store it safely. * Treat the MCP key like a password/token: do not share it in screenshots, logs, or code samples. @@ -326,7 +307,7 @@ If you need MCP in production today, use the **Local MCP server** setup instead. For client-specific setup, use the shared section **Connect your MCP client**. -For remote mode, use the URL shown in **Your account → Integrations → MCP Server (Beta)**, which includes your `userToken`. +For remote mode, use the URL shown in **Your account → Integrations → MCP Server**, which includes your `userToken`. <a id="use-remote"></a> ### Use @@ -336,7 +317,7 @@ Once everything is configured, day-to-day use of Penpot MCP follows a simple pat #### Run 1. **Enable MCP** - * Go to **Your account → Integrations → MCP Server (Beta)** and set **Status** to **Enabled**. + * Go to **Your account → Integrations → MCP Server** and set **Status** to **Enabled**. 2. **Connect plugin**: * Open a design file and use **File → MCP Server → Connect**. 3. **Run prompts**: @@ -393,7 +374,7 @@ At a high level: 2. Start the MCP server and plugin server from your terminal: ```json -npx @penpot/mcp@beta +npx @penpot/mcp@stable ``` Leave this terminal running while you use MCP. @@ -426,7 +407,7 @@ Once everything is configured, day-to-day use of Penpot MCP follows a simple pat 1. **Start MCP** - Run `npx -y @penpot/mcp@stable` (production) or `npx -y @penpot/mcp@beta` (test), and keep that terminal running. + Run `npx -y @penpot/mcp@stable` (production), and keep that terminal running. 2. **Connect plugin** In Penpot, load `http://localhost:4400/manifest.json`, run the plugin, and click **Connect to MCP server**. diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index 4c70936dc7..ddbe801fd7 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -425,6 +425,12 @@ In a high-availability (HA) scenario, managing the state outside of replicas is - Valkey: Penpot only needs one Valkey instance to function correctly. Due to the nature of the data it manages, replication isn't even essential. - User media storage: This should not be configured with local storage but rather with centralized storage, such as Kubernetes PVC or S3. + +__Since version 2.15.0__ + +Starting with version 2.15, we have introduced the MCP server. Due to architectural constraints, using the MCP server requires running only a single instance of Penpot. +If the MCP server is not installed, then Penpot can scale normally and multiple application instances may be deployed without restrictions. + ## Backend This section enumerates the backend only configuration variables. diff --git a/docs/technical-guide/developer/agentic-devenv.md b/docs/technical-guide/developer/agentic-devenv.md new file mode 100644 index 0000000000..19940ccc00 --- /dev/null +++ b/docs/technical-guide/developer/agentic-devenv.md @@ -0,0 +1,193 @@ +--- +title: 3.11. Agentic Development Environment +desc: Dive into agentic Penpot development. +--- + +# Agentic Development Environment + +The agentic DevEnv is an extension of the standard DevEnv +(the [general DevEnv instructions](/technical-guide/developer/devenv/) apply), +which is optimised for AI agent-based development, +adding additional tools and processes that support agentic automation. + +The general workflow is as follows: + +1. Start the agentic DevEnv. +2. Start a debugging-enabled browser and open Penpot, using a Penpot user with + the remote MCP integration enabled. +3. Use an AI client (MCP client) which is connected to a suite of MCP servers + to solve development tasks. + +## Capabilities + +The agentic DevEnv leverages several MCP servers in order to provide AI agents +with a comprehensive toolbox for Penpot development: + +* **Penpot MCP Server** provides tools for directly interacting with a live Penpot instance, + enabling the agent to + * execute JavaScript code in the frontend (using the plugin API), + * execute ClojureScript code in the frontend (REPL), + * import .penpot files for reproducing issues, + * export design elements as images, and more. +* **Serena MCP Server** provides code intelligence tools with support for Clojure and TypeScript. + Its memory system is used to organise project knowledge in a context-efficient manner. +* **Playwright MCP Server** provides tools for browser remote control. +* (optional) **GitHub MCP Server** provides tools for interacting with GitHub (issue, PRs, etc.) + +Equipped with the tools provided by these MCP servers, the agent can fully close the development loop, +i.e. it can ... +* retrieve information on an issue from GitHub, +* import relevant design files for reproduction, +* execute JavaScript and ClojureScript code directly in Penpot in order to + * simulate user interactions (e.g. to reproduce an issue), + * test hypotheses on the root cause of an issue, and + * experiment with implementations before touching the actual codebase, +* detect, analyse and recover from crashes in the frontend, +* make code changes (using IDE-like symbolic operations) +* test the changes in the live Penpot instance, and +* create commits and PRs resolving the issue. + +## Configuring and Starting the Agentic DevEnv + +**First-Time Setup: Building the Image.** If you are starting the agentic DevEnv for the first time, you need to build +the updated docker image, adding support for agentic tools: + +```bash +./manage.sh build-devenv --local +``` + +**Enable the Penpot MCP Connection in the Frontend.** +The agentic DevEnv relies on a connection between the Penpot frontend and the Penpot MCP server +being established automatically. +Edit the file `frontend/resources/public/js/config.js`, +creating it if it does not exist, and make sure the `penpotFlags` variable contains the +`enable-mcp` flag. + +```javascript +var penpotFlags = "enable-mcp"; +``` + +**Running the DevEnv in Agentic Mode.** Start the DevEnv in agentic mode with: + +```bash +./manage.sh run-devenv-agentic +``` + +## Opening Penpot with Remote Debugging & MCP Enabled + +**Enable Remote Debugging in Your Browser.** +Penpot needs to be opened in a browser that has remote debugging enabled. +In Chromium-based browsers (such as Google Chrome, Opera, Vivaldi, etc.), +this can be achieved by launching the browser with the `--remote-debugging-port` argument. +For most newer browsers, you will also need to specify a user data directory, +as using debugging with your regular browser profile is disallowed for security reasons. + +```bash +google-chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.chrome-debug-profile" +``` + +This enables the Playwright MCP server to connect to the browser and control it. +Verify that debugging was enabled correctly by navigating to `http://127.0.0.1:9222/json/version`. +If you change the port, adjust the MCP server configuration accordingly (see below). +Note: For security reasons, you should not enable remote debugging with a profile +that you use for regular browsing activities. + +**Open Penpot with the MCP Integration Enabled.** +The Penpot instance in the DevEnv can be accessed at [https://localhost:3449](https://localhost:3449). +Once logged in, navigate to your account settings, click on "Integrations" in the sidebar, and enable the "MCP Server" toggle. +Note: You do not need to use the generated key (or the provided URL), as the MCP server in the agentic DevEnv is running in single-user mode and does not require authentication. + +## Configuring Your AI Client + +Your AI client needs to be configured to connect to the MCP servers that collectively provide the agent with the necessary tools for Penpot development. + +Below, we exemplarily provide a JSON-based configuration snippet, using `mcp-remote` to wrap HTTP-based servers. + +Most clients using JSON-based configuration (e.g. Copilot, JetBrains AI Assistant, Claude Desktop, Antigravity) +will work when inserting the server entries below into the client's configuration file. +If your client uses a different configuration format, extract the relevant information (i.e. server URLs or launch commands) +and configure the servers appropriately, referring to the documentation of your client. + +```json +{ + "mcpServers": { + "penpot": { + "command": "npx", + "args": ["-y", "mcp-remote", "http://localhost:4401/mcp", "--allow-http" ] + }, + "serena-devenv": { + "command": "npx", + "args": ["-y", "mcp-remote", "http://localhost:14281/mcp", "--allow-http"] + }, + "playwright": { + "command": "npx", + "args": ["@playwright/mcp@latest", "--cdp-endpoint=http://127.0.0.1:9222"] + }, + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "TODO_your_token" + } + } + } +} +``` + +**Penpot MCP Server** +* The URL above connects directly to the server in the DevEnv, which runs in single-user mode. + You do not need to use the proxied URL or the user token that is provided by the Penpot UI. + +**Serena MCP Server** +* You can access Serena's dashboard at [http://localhost:14282](http://localhost:14282) + +**GitHub MCP Server** +* The use of this MCP server is optional. (Direct shell access to GitHub CLI can be used alternatively.) +* You need to provide a personal access token (PAT) with appropriate permissions: + * Create a token in your GitHub account settings [here](https://github.com/settings/personal-access-tokens). + * Choose the right resource owner: As a member of the `penpot` organisation, be sure to create a token where the resource owner is the organisation. + Otherwise, you will not be able to create pull requests or issues in the `penpot/penpot` repository. + * Grant the necessary permissions, e.g. read and write access to issues and pull requests. + +## Working on Development Tasks + +After having made the configuration changes, restart your AI client. +All four MCP servers should now be running and accessible to your client. + +The agent's entrypoint for development is an activation of the `penpot` project with Serena. +Start by instructing your agent as follows, + +> Activate project penpot. + +and it should retrieve fundamental project information, +expecting further instructions on what to do. + +**Always start your first prompt with these activation instructions**, as this bootstraps the agent's context. + +### Checking MCP Server Operability + +To check if all integrations are working correctly, you can perform a series of tests. + +1. Open Penpot in the debugging-enabled browser and open a design file. +2. Ask the agent to activate the project (Serena project activation): + + > Activate project penpot. + +3. **Penpot MCP** + * Checking the connection to the Penpot frontend: + + > Get an overview of the current page in Penpot by using the `execute_code` tool. + + * Checking the ClojureScript REPL: + + > Use the `cljs_repl` tool to check whether the Penpot frontend has crashed. + +4. **Serena MCP** + * Checking Serena's symbolic tools: + + > Use the `find_symbol` tool to find function `locate-shape` (cljs) and class `PenpotMcpServer` (ts) + +* **Playwright MCP** + * Checking the connection to the browser: + + > Use Playwright MCP server to find the Penpot browser tab. diff --git a/docs/technical-guide/developer/common.md b/docs/technical-guide/developer/common.md index 2ad8821d78..e9bf7bda43 100644 --- a/docs/technical-guide/developer/common.md +++ b/docs/technical-guide/developer/common.md @@ -277,14 +277,61 @@ You can also mark tests in the code by adding metadata: Please refer to the [kaocha manual](https://cljdoc.org/d/lambdaisland/kaocha/1.91.1392/doc/6-focusing-and-skipping) for how to define custom metadata and other ways of selecting tests. +Common tests can also be run in the JavaScript runtime with shadow-cljs. From +<code class="language-text">common</code>: + +```bash +# To run all common JavaScript tests once +pnpm run test:js + +# Quiet run for non-interactive output +pnpm run test:quiet + +# To run a single common JavaScript tests module +pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test + +# To run a single common JavaScript test and quiet app-level logging +pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute --log-level warn +``` + +The common JavaScript runner accepts the same forwarded runner arguments through +<code class="language-text">pnpm run test:js -- ...</code>, +<code class="language-text">pnpm run test:quiet -- ...</code>, or directly through +<code class="language-text">node target/tests/test.js ...</code> after +<code class="language-text">pnpm run build:test</code>. Supported runner arguments +are <code class="language-text">--focus namespace-or-var</code> and +<code class="language-text">--log-level trace|debug|info|warn|error</code>. + **NOTE**: in <code class="language-text">frontend</code> we still can't use kaocha to run the tests. We are on it, but for now we use shadow-cljs with <code class="language-text">package.json</code> scripts: ```bash +# To run all frontend tests once pnpm run test -pnpm run test:watch + +# Quiet run for non-interactive output +pnpm run test:quiet + +# To run all frontend tests and keep watching for changes +pnpm run watch:test + +# To run a single frontend tests module +pnpm run test:quiet -- --focus frontend-tests.logic.components-and-tokens + +# To run a single frontend test +pnpm run test:quiet -- --focus frontend-tests.logic.components-and-tokens/change-token-in-main + +# To quiet app-level logging during the run (trace|debug|info|warn|error) +pnpm run test:quiet -- --focus frontend-tests.logic.components-and-tokens --log-level warn ``` +For non-interactive runs (CI, scripted invocations, agent loops), use the quiet +variant. It runs the same `build:wasm && build:test && node target/tests/test.js` +pipeline but buffers each build step's output and only replays it on failure; +test-runner output streams through normally. Short progress hints +(`Building wasm...`, `Running tests...`) go to `stderr`, so capturing `stdout` +gives you just the test results. + #### Test output The default kaocha reporter outputs a summary for the test run. There is a pair diff --git a/docs/technical-guide/developer/devenv.md b/docs/technical-guide/developer/devenv.md index facb810dd8..0443466e03 100644 --- a/docs/technical-guide/developer/devenv.md +++ b/docs/technical-guide/developer/devenv.md @@ -161,6 +161,59 @@ If an exception is raised or an error occurs when code is reloaded, just use <code class="language-bash">(repl/refresh-all)</code> to finish loading the code correctly and then use <code class="language-bash">(restart)</code> again. + +### MCP Server + +To set up the MCP server local development environment it's needed some additional steps. + +### Activate the MCP features variables + +Create or modify the file `frontend/resources/public/js/config.js` and add (or modify) the `penpotFlags` to add the following: + +```javascript +var penpotFlags = "enable-mcp enable-access-tokens" +``` + +This will enable the MCP in the workspace and in the user settings profile. + +### Start the DEVENV + +Start as usual the development environment + +``` +./manage.sh start-devenv +``` + +Once the TMUX is showing, create a new tmux tab (Ctrl+b c). And in the new tab run: + +```bash +cd mcp +pnpm run bootstrap:multi-user +``` + +This will start the MCP server and the multi-user plugin that will be loaded automaticaly by Penpot. + +There is a NGINX proxy that makes a proxy-pass from outside the docker container so you don't need to remember the ports it's using. + +### Configure the MCP in your tool + +You can use the instructions in [/mcp/#remote-mcp-in-5-steps](/mcp/#remote-mcp-in-5-steps) to setup the server. + +Warning: by default Cursor won't support HTTPS with a self-signed certificate. In order to work around this issue please use the port `3450` that uses an standard `http` protocol + +An example of your cursor configuration can be: + +```javascript +{ + "mcpServers": { + "penpot-devenv": { + "url": "http://localhost:3450/mcp/stream?userToken=TOKEN", + "type": "http" + } + } +} +``` + ## Email To test email sending, the devenv includes [MailCatcher](https://mailcatcher.me/), diff --git a/docs/user-guide/first-steps/troubleshooting-webgl.njk b/docs/user-guide/first-steps/troubleshooting-webgl.njk new file mode 100644 index 0000000000..bf241d0b1f --- /dev/null +++ b/docs/user-guide/first-steps/troubleshooting-webgl.njk @@ -0,0 +1,107 @@ +--- +title: Troubleshooting WebGL +order: 5 +desc: Diagnose and fix common WebGL issues in Penpot with browser, GPU, and system checks so you can open the workspace canvas correctly. +--- + +<h1 id="troubleshooting-webgl">Troubleshooting WebGL</h1> + +<div class="advice"> + <h3>Availability note</h3> + <p>WebGL renderer is currently not available yet in Penpot production (<code>design.penpot.app</code>).</p> + <p>Right now, this renderer is available only in <strong>testing environments</strong>. It is planned for an upcoming release and should be available soon.</p> +</div> + +<p class="main-paragraph">Penpot uses WebGL to render the design canvas. If WebGL is unavailable, Penpot cannot open the workspace canvas correctly.</p> + +<p>Sometimes WebGL appears enabled in your browser, but Penpot still cannot create a graphics context. This is usually related to browser settings, GPU acceleration, drivers, or temporary GPU overload.</p> + +<h2>Before changing anything</h2> +<ol> + <li>Open <a href="https://get.webgl.org/" target="_blank">https://get.webgl.org</a>.</li> + <li>Check the result: + <ul> + <li>If you see a spinning cube, WebGL works at browser level.</li> + <li>If it fails (blank page, error message, or no animation), continue with browser and system checks below.</li> + </ul> + </li> +</ol> + +<h2>Quick checks (2 minutes)</h2> +<ol> + <li>Close graphics-heavy tabs/apps (video editors, 3D apps, many design tabs).</li> + <li>Reload Penpot.</li> + <li>Fully restart the browser.</li> + <li>If needed, restart your computer.</li> +</ol> +<p>Why this helps: GPU memory or context slots can be temporarily exhausted, even when your configuration is correct.</p> + +<h2>Chrome</h2> +<ol> + <li>Open <code>chrome://settings/system</code>.</li> + <li>Turn on <strong>Use graphics acceleration when available</strong>.</li> + <li>Restart Chrome.</li> + <li>Open <code>chrome://gpu</code> and review WebGL-related warnings.</li> +</ol> +<figure> + <a href="/img/troubleshooting/troubleshooting-chrome.webp" target="_blank"> + <img src="/img/troubleshooting/troubleshooting-chrome.webp" alt="Chrome system settings with graphics acceleration option" /> + </a> +</figure> +<p>Why this helps: WebGL depends on hardware acceleration and a healthy GPU process.</p> + +<h2>Mozilla Firefox</h2> +<ol> + <li>Open Firefox and check that zoom is set to <strong>100%</strong> from the top-right menu.</li> + <li>From that same menu, open Settings/Preferences.</li> + <li>In General settings, confirm Firefox is up to date and run <strong>Check for updates</strong> if needed.</li> + <li>Enable hardware acceleration in Firefox settings.</li> + <li>Restart Firefox.</li> + <li>Open <code>about:support</code> and review the Graphics/WebGL section.</li> +</ol> +<figure> + <a href="/img/troubleshooting/troubleshooting-firefox.webp" target="_blank"> + <img src="/img/troubleshooting/troubleshooting-firefox.webp" alt="Firefox settings showing hardware acceleration configuration" /> + </a> +</figure> +<p>Why this helps: outdated browser builds, disabled acceleration, or blocked GPU features can prevent context creation.</p> + +<h2>Safari</h2> +<ol> + <li>Update Safari/macOS to the latest available version.</li> + <li>Restart Safari.</li> + <li>Re-test in <a href="https://get.webgl.org/" target="_blank">https://get.webgl.org</a>.</li> +</ol> +<p>Why this helps: Safari WebGL behavior is strongly tied to OS/browser version and graphics stack updates.</p> + +<h2>Settings</h2> +<p>Some advanced browser configurations or experimental settings can interfere with WebGL. If you have modified these in the past, consider restoring default browser settings or testing in a fresh profile.</p> + +<h2>About zoom and trackpad settings</h2> +<p>In some cases, changing browser zoom or trackpad settings is suggested as a workaround.</p> +<p>In Penpot, these are <strong>not baseline requirements</strong> for WebGL. Treat them only as temporary diagnostics if support explicitly asks for them.</p> +<p>If you temporarily changed one of these settings and Penpot starts working, you can usually revert it and test again.</p> + +<h2>GPU drivers and OS checks</h2> +<ol> + <li>Install any pending OS updates.</li> + <li>Update GPU drivers (especially on Windows/Linux).</li> + <li>Disable graphics overlays/tools (recording overlays, GPU tuning utilities) and test again.</li> +</ol> +<p>Why this helps: outdated or conflicting graphics layers can break WebGL context creation.</p> + +<h2>Known edge case: Linux + Nvidia</h2> +<p>Some Linux + Nvidia combinations can report WebGL as available but still fail at runtime in specific browser/driver combinations.</p> +<p>In some cases, switching between proprietary and open-source drivers or updating the NVIDIA driver resolves the issue.</p> +<p>If this is your setup, collect diagnostics and contact support.</p> + +<h2>If the issue persists</h2> +<p>Please share:</p> +<ul> + <li>Browser and version.</li> + <li>Operating system and version.</li> + <li>Result from <a href="https://get.webgl.org/" target="_blank">https://get.webgl.org</a>.</li> + <li>A screenshot of browser graphics diagnostics (<code>chrome://gpu</code> or <code>about:support</code>).</li> +</ul> + +<p>Then contact us at <a href="mailto:support@penpot.app">support@penpot.app</a> or open a GitHub issue at <a href="https://github.com/penpot/penpot/issues" target="_blank">https://github.com/penpot/penpot/issues</a>.</p> \ No newline at end of file diff --git a/docs/user-guide/index.njk b/docs/user-guide/index.njk index d017f151c2..4b932f7ac0 100644 --- a/docs/user-guide/index.njk +++ b/docs/user-guide/index.njk @@ -2,7 +2,7 @@ title: User guide desc: Learn everything from interface basics to advanced features like prototyping and design sharing with Penpot's comprehensive user guide! Free access. eleventyNavigation: - key: User guide + key: User Guide order: 2 --- diff --git a/exporter/deps.edn b/exporter/deps.edn index 1c5cd1fb55..08e3a3cdd8 100644 --- a/exporter/deps.edn +++ b/exporter/deps.edn @@ -2,12 +2,12 @@ :deps {penpot/common {:local/root "../common"} org.clojure/clojure {:mvn/version "1.12.2"} - binaryage/devtools {:mvn/version "RELEASE"} + binaryage/devtools {:mvn/version "1.0.7"} metosin/reitit-core {:mvn/version "0.9.1"} } :aliases {:outdated - {:extra-deps {com.github.liquidz/antq {:mvn/version "RELEASE"} + {:extra-deps {com.github.liquidz/antq {:mvn/version"2.11.1276"} ;; org.slf4j/slf4j-nop {:mvn/version "RELEASE"} } :main-opts ["-m" "antq.core"]} diff --git a/exporter/package.json b/exporter/package.json index 94e188896a..48ef7f9aa0 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -11,22 +11,22 @@ }, "type": "module", "dependencies": { - "archiver": "^7.0.1", + "archiver": "7.0.1", "cookies": "^0.9.1", "date-fns": "^4.1.0", "generic-pool": "^3.9.0", "inflation": "^2.1.0", - "ioredis": "^5.8.2", - "playwright": "^1.57.0", + "ioredis": "^5.10.1", + "playwright": "^1.60.0", "raw-body": "^3.0.2", "source-map-support": "^0.5.21", "svgo": "penpot/svgo#v3.1", - "undici": "^7.16.0", + "undici": "^8.2.0", "xml-js": "^1.6.11", "xregexp": "^5.1.2" }, "devDependencies": { - "ws": "^8.18.3" + "ws": "^8.20.1" }, "scripts": { "clear:shadow-cache": "rm -rf .shadow-cljs && rm -rf target", diff --git a/exporter/pnpm-lock.yaml b/exporter/pnpm-lock.yaml index 78e28acc49..16082370ea 100644 --- a/exporter/pnpm-lock.yaml +++ b/exporter/pnpm-lock.yaml @@ -9,7 +9,7 @@ importers: .: dependencies: archiver: - specifier: ^7.0.1 + specifier: 7.0.1 version: 7.0.1 cookies: specifier: ^0.9.1 @@ -24,11 +24,11 @@ importers: specifier: ^2.1.0 version: 2.1.0 ioredis: - specifier: ^5.8.2 - version: 5.8.2 + specifier: ^5.10.1 + version: 5.10.1 playwright: - specifier: ^1.57.0 - version: 1.57.0 + specifier: ^1.60.0 + version: 1.60.0 raw-body: specifier: ^3.0.2 version: 3.0.2 @@ -39,8 +39,8 @@ importers: specifier: penpot/svgo#v3.1 version: https://codeload.github.com/penpot/svgo/tar.gz/a46262c12c0d967708395972c374eb2adead4180 undici: - specifier: ^7.16.0 - version: 7.16.0 + specifier: ^8.2.0 + version: 8.2.0 xml-js: specifier: ^1.6.11 version: 1.6.11 @@ -49,8 +49,8 @@ importers: version: 5.1.2 devDependencies: ws: - specifier: ^8.18.3 - version: 8.18.3 + specifier: ^8.20.1 + version: 8.20.1 packages: @@ -58,8 +58,8 @@ packages: resolution: {integrity: sha512-h7iEYiW4HebClDEhtvFObtPmIvrd1SSfpI9EhOeKk4CtIK/ngBWFpuhCzhdmRKtg71ylcue+9I6dv54XYO1epQ==} engines: {node: '>=6.9.0'} - '@ioredis/commands@1.4.0': - resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==} + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} @@ -104,8 +104,8 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - b4a@1.7.3: - resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} peerDependencies: react-native-b4a: '*' peerDependenciesMeta: @@ -123,14 +123,47 @@ packages: bare-abort-controller: optional: true + bare-fs@4.7.1: + resolution: {integrity: sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.9.1: + resolution: {integrity: sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.0: + resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + + bare-stream@2.13.1: + resolution: {integrity: sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.3: + resolution: {integrity: sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} buffer-crc32@1.0.0: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} @@ -278,6 +311,7 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true graceful-fs@4.2.11: @@ -301,8 +335,8 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ioredis@5.8.2: - resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==} + ioredis@5.10.1: + resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} engines: {node: '>=12.22.0'} is-fullwidth-code-point@3.0.0: @@ -349,16 +383,16 @@ packages: mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} - minimatch@5.1.6: - resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} engines: {node: '>=10'} - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} - minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} ms@2.1.3: @@ -382,13 +416,13 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} - playwright-core@1.57.0: - resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} engines: {node: '>=18'} hasBin: true - playwright@1.57.0: - resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==} + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} engines: {node: '>=18'} hasBin: true @@ -466,8 +500,8 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - streamx@2.23.0: - resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} + streamx@2.25.0: + resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} @@ -487,8 +521,8 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-ansi@7.1.2: - resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} svgo@https://codeload.github.com/penpot/svgo/tar.gz/a46262c12c0d967708395972c374eb2adead4180: @@ -496,11 +530,14 @@ packages: version: 4.0.0 engines: {node: '>=16.0.0'} - tar-stream@3.1.7: - resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} - text-decoder@1.2.3: - resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} @@ -510,9 +547,9 @@ packages: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} - undici@7.16.0: - resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} - engines: {node: '>=20.18.1'} + undici@8.2.0: + resolution: {integrity: sha512-Z+4Hx9GE26Lh9Upwfnc8C7SsrpBPGaM/Gm6kMFtiG7c+5IvQKlXi/t+9x9DrrCh29cww5TSP9YdVaBcnLDs5fQ==} + engines: {node: '>=22.19.0'} unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} @@ -534,8 +571,8 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} - ws@8.18.3: - resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -563,13 +600,13 @@ snapshots: dependencies: core-js-pure: 3.47.0 - '@ioredis/commands@1.4.0': {} + '@ioredis/commands@1.5.1': {} '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 strip-ansi-cjs: strip-ansi@6.0.1 wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 @@ -610,25 +647,56 @@ snapshots: buffer-crc32: 1.0.0 readable-stream: 4.7.0 readdir-glob: 1.1.3 - tar-stream: 3.1.7 + tar-stream: 3.2.0 zip-stream: 6.0.1 transitivePeerDependencies: - bare-abort-controller + - bare-buffer - react-native-b4a async@3.2.6: {} - b4a@1.7.3: {} + b4a@1.8.1: {} balanced-match@1.0.2: {} bare-events@2.8.2: {} + bare-fs@4.7.1: + dependencies: + bare-events: 2.8.2 + bare-path: 3.0.0 + bare-stream: 2.13.1(bare-events@2.8.2) + bare-url: 2.4.3 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-os@3.9.1: {} + + bare-path@3.0.0: + dependencies: + bare-os: 3.9.1 + + bare-stream@2.13.1(bare-events@2.8.2): + dependencies: + streamx: 2.25.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.3: + dependencies: + bare-path: 3.0.0 + base64-js@1.5.1: {} boolbase@1.0.0: {} - brace-expansion@2.0.2: + brace-expansion@2.1.0: dependencies: balanced-match: 1.0.2 @@ -767,8 +835,8 @@ snapshots: dependencies: foreground-child: 3.3.1 jackspeak: 3.4.3 - minimatch: 9.0.5 - minipass: 7.1.2 + minimatch: 9.0.9 + minipass: 7.1.3 package-json-from-dist: 1.0.1 path-scurry: 1.11.1 @@ -792,9 +860,9 @@ snapshots: inherits@2.0.4: {} - ioredis@5.8.2: + ioredis@5.10.1: dependencies: - '@ioredis/commands': 1.4.0 + '@ioredis/commands': 1.5.1 cluster-key-slot: 1.1.2 debug: 4.4.3 denque: 2.1.0 @@ -840,15 +908,15 @@ snapshots: mdn-data@2.12.2: {} - minimatch@5.1.6: + minimatch@5.1.9: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.1.0 - minimatch@9.0.5: + minimatch@9.0.9: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.1.0 - minipass@7.1.2: {} + minipass@7.1.3: {} ms@2.1.3: {} @@ -865,13 +933,13 @@ snapshots: path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 - minipass: 7.1.2 + minipass: 7.1.3 - playwright-core@1.57.0: {} + playwright-core@1.60.0: {} - playwright@1.57.0: + playwright@1.60.0: dependencies: - playwright-core: 1.57.0 + playwright-core: 1.60.0 optionalDependencies: fsevents: 2.3.2 @@ -906,7 +974,7 @@ snapshots: readdir-glob@1.1.3: dependencies: - minimatch: 5.1.6 + minimatch: 5.1.9 redis-errors@1.2.0: {} @@ -945,11 +1013,11 @@ snapshots: statuses@2.0.2: {} - streamx@2.23.0: + streamx@2.25.0: dependencies: events-universal: 1.0.1 fast-fifo: 1.3.2 - text-decoder: 1.2.3 + text-decoder: 1.2.7 transitivePeerDependencies: - bare-abort-controller - react-native-b4a @@ -964,7 +1032,7 @@ snapshots: dependencies: eastasianwidth: 0.2.0 emoji-regex: 9.2.2 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 string_decoder@1.1.1: dependencies: @@ -978,7 +1046,7 @@ snapshots: dependencies: ansi-regex: 5.0.1 - strip-ansi@7.1.2: + strip-ansi@7.2.0: dependencies: ansi-regex: 6.2.2 @@ -990,18 +1058,27 @@ snapshots: csso: 5.0.5 lodash: 4.17.21 - tar-stream@3.1.7: + tar-stream@3.2.0: dependencies: - b4a: 1.7.3 + b4a: 1.8.1 + bare-fs: 4.7.1 fast-fifo: 1.3.2 - streamx: 2.23.0 + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.25.0 transitivePeerDependencies: - bare-abort-controller - react-native-b4a - text-decoder@1.2.3: + text-decoder@1.2.7: dependencies: - b4a: 1.7.3 + b4a: 1.8.1 transitivePeerDependencies: - react-native-b4a @@ -1009,7 +1086,7 @@ snapshots: tsscmp@1.0.6: {} - undici@7.16.0: {} + undici@8.2.0: {} unpipe@1.0.0: {} @@ -1029,9 +1106,9 @@ snapshots: dependencies: ansi-styles: 6.2.3 string-width: 5.1.2 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 - ws@8.18.3: {} + ws@8.20.1: {} xml-js@1.6.11: dependencies: diff --git a/exporter/src/app/handlers/export_shapes.cljs b/exporter/src/app/handlers/export_shapes.cljs index 901a2f2bb2..1789ba9531 100644 --- a/exporter/src/app/handlers/export_shapes.cljs +++ b/exporter/src/app/handlers/export_shapes.cljs @@ -64,7 +64,7 @@ (defn- handle-single-export [{:keys [:request/auth-token] :as exchange} {:keys [export name skip-children is-wasm] :as params}] (let [resource (rsc/create (:type export) (or name (:name export))) - export (assoc export :skip-children skip-children :is-wasm is-wasm)] + export (assoc export :skip-children skip-children :is-wasm (boolean is-wasm))] (->> (rd/render export (fn [{:keys [path] :as object}] @@ -112,7 +112,7 @@ (rsc/add-to-zip zip path (str/replace filename sanitize-file-regex "_"))) proc (->> exports - (map (fn [export] (rd/render (assoc export :is-wasm is-wasm) append))) + (map (fn [export] (rd/render (assoc export :is-wasm (boolean is-wasm)) append))) (p/all) (p/mcat (fn [_] (rsc/close-zip zip))) (p/fmap (constantly resource)) diff --git a/exporter/src/app/handlers/resources.cljs b/exporter/src/app/handlers/resources.cljs index e981856da4..eadf4c1a2c 100644 --- a/exporter/src/app/handlers/resources.cljs +++ b/exporter/src/app/handlers/resources.cljs @@ -68,7 +68,6 @@ [auth-token resource] (->> (fsp/readFile (:path resource)) (p/fmap (fn [buffer] - (js/console.log buffer) (new js/Blob #js [buffer] #js {:type (:mtype resource)}))) (p/mcat (fn [blob] (let [fdata (new http/FormData) diff --git a/exporter/src/app/renderer/bitmap.cljs b/exporter/src/app/renderer/bitmap.cljs index bc8b06abf3..63276ee48a 100644 --- a/exporter/src/app/renderer/bitmap.cljs +++ b/exporter/src/app/renderer/bitmap.cljs @@ -63,6 +63,7 @@ :wasm (when is-wasm "true") :scale scale} uri (-> (cf/get :public-uri) - (assoc :path "/render.html") + (u/ensure-path-slash) + (u/join "render.html") (assoc :query (u/map->query-string params)))] (bw/exec! (prepare-options uri) (partial render uri))))) diff --git a/exporter/src/app/renderer/pdf.cljs b/exporter/src/app/renderer/pdf.cljs index 27402db513..bdfd8c6dc5 100644 --- a/exporter/src/app/renderer/pdf.cljs +++ b/exporter/src/app/renderer/pdf.cljs @@ -35,7 +35,7 @@ :object-id object-id :route "objects"}] (-> base-uri - (assoc :path "/render.html") + (u/join "render.html") (assoc :query (u/map->query-string params))))) (sync-page-size! [dom] @@ -77,6 +77,7 @@ (on-object (assoc object :path path)) (p/recur (rest objects))))))] - (let [base-uri (cf/get :public-uri)] + (let [base-uri (-> (cf/get :public-uri) + (u/ensure-path-slash))] (bw/exec! (prepare-options base-uri) (partial render base-uri))))) diff --git a/exporter/src/app/renderer/svg.cljs b/exporter/src/app/renderer/svg.cljs index 66e6dd61b2..135edee8d0 100644 --- a/exporter/src/app/renderer/svg.cljs +++ b/exporter/src/app/renderer/svg.cljs @@ -350,7 +350,8 @@ :object-id (mapv :id objects) :route "objects"} uri (-> (cf/get :public-uri) - (assoc :path "/render.html") + (u/ensure-path-slash) + (u/join "render.html") (assoc :query (u/map->query-string params)))] (bw/exec! (prepare-options uri) (partial render uri))))) diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md deleted file mode 100644 index 681528b4f3..0000000000 --- a/frontend/AGENTS.md +++ /dev/null @@ -1,371 +0,0 @@ -# Penpot Frontend – Agent Instructions - -ClojureScript-based frontend application that uses React and RxJS as its main -architectural pieces. - -## General Guidelines - -### 1. Testing & Validation - -#### Unit Tests - -If code is added or modified in `src/`, corresponding tests in -`test/frontend_tests/` must be added or updated. - -* **Environment:** Tests should run in a Node.js or browser-isolated - environment without requiring the full application state or a - running backend. Test are developed using cljs.test. -* **Mocks & Stubs:** * Use proper mocks for any side-effecting - functions (e.g., API calls, storage access). - * Avoid testing through the UI (DOM); we have e2e tests for that. - * Use `with-redefs` or similar ClojureScript mocking utilities to isolate the logic under test. -* **No Flakiness:** Tests must be deterministic. Do not use `setTimeout` or real - network calls. Use synchronous mocks for asynchronous workflows where - possible. -* **Location:** Place tests in the `test/frontend_tests/` directory, following the - namespace structure of the source code (e.g., `app.utils.timers` -> - `frontend-tests.util-timers-test`). -* **Execution:** - * **Isolated:** To run a focused ClojureScript unit test: edit the - `test/frontend_tests/runner.cljs` to narrow the test suite, then `pnpm run - test`. - * **Regression:** To run `pnpm run test` without modifications on the runner (preferred) - - -#### Integration Tests (Playwright) - -Integration tests are developed under `frontend/playwright` directory, we use -mocks for remote communication with the backend. - -You should not add, modify or run the integration tests unless explicitly asked. - - -``` -pnpm run test:e2e # Playwright e2e tests -pnpm run test:e2e --grep "pattern" # Single e2e test by pattern -``` - -Ensure everything is installed before executing tests with the `./scripts/setup` script. - - -### 2. Code Quality & Formatting - -* **Linting:** All code changes must pass linter checks: - * Run `pnpm run lint:clj` for CLJ/CLJS/CLJC - * Run `pnpm run lint:js` for JS - * Run `pnpm run lint:scss` for SCSS -* **Formatting:** All code changes must pass the formatting check - * Run `pnpm run check-fmt:clj` for CLJ/CLJS/CLJC - * Run `pnpm run check-fmt:js` for JS - * Run `pnpm run check-fmt:scss` for SCSS - * Use the `pnpm run fmt` fix all the formatting issues (`pnpm run fmt:clj`, - `pnpm run fmt:js` or `pnpm run fmt:scss` for isolated formatting fix) - -### 3. Implementation Rules - -* **Logic vs. View:** If logic is embedded in a UI component, extract it into a - function in the same namespace if it is only used locally, or look for a helper - namespace to make it unit-testable. - -### 4. Stack Trace Analysis - -When analyzing production stack traces (minified code), you can generate a -production bundle locally to map the minified code back to the source. - -**To build the production bundle:** - -Run: `pnpm run build:app` - -The compiled files and their corresponding source maps will be generated in -`resources/public/js`. - -**Analysis Tips:** - -- **Source Maps:** Use the `.map` files generated in `resources/public/js` with - tools like `source-map-lookup` or browser dev tools to resolve minified - locations. -- **Bundle Inspection:** If the issue is related to bundle size or unexpected - code inclusion, inspect the generated modules in `resources/public/js`. -- **Shadow-CLJS Reports:** For more detailed analysis of what is included in the - bundle, you can run shadow-cljs build reports (consult `shadow-cljs.edn` for - build IDs like `main` or `worker`). - - -## Code Conventions - -### Namespace Overview - -The source is located under `src` directory and this is a general overview of -namespaces structure: - -- `app.main.ui.*` – React UI components (`workspace`, `dashboard`, `viewer`) -- `app.main.data.*` – Potok event handlers (state mutations + side effects) -- `app.main.refs` – Reactive subscriptions (okulary lenses) -- `app.main.store` – Potok event store -- `app.util.*` – Utilities (DOM, HTTP, i18n, keyboard shortcuts) - - -### State Management (Potok) - -State is a single atom managed by a Potok store. Events implement protocols -(funcool/potok library): - -```clojure -(defn my-event - "doc string" - [data] - (ptk/reify ::my-event - ptk/UpdateEvent - (update [_ state] ;; synchronous state transition - (assoc state :key data)) - - ptk/WatchEvent - (watch [_ state stream] ;; async: returns an observable - (->> (rp/cmd! :some-rpc-command params) - (rx/map success-event) - (rx/catch error-handler))) - - ptk/EffectEvent - (effect [_ state _] ;; pure side effects (DOM, logging) - (dom/focus (dom/get-element "id"))))) -``` - -The state is located under `app.main.store` namespace where we have -the `emit!` function responsible for emitting events. - -Example: - -```cljs -(ns some.ns - (:require - [app.main.data.my-events :refer [my-event]] - [app.main.store :as st])) - -(defn on-click - [event] - (st/emit! (my-event))) -``` - -On `app.main.refs` we have reactive references which look up the main state -for inner data or precalculated data. These references are very useful but -should be used with care because, for example, if we have a complex operation, -this operation will be executed on each state change. Sometimes it is better to -have simple references and use React `use-memo` for more granular memoization. - -Prefer helpers from `app.util.dom` instead of using direct DOM calls. If no -helper is available, prefer adding a new helper and then using it. - -### UI Components (React & Rumext: mf/defc) - -The codebase contains various component patterns. When creating or refactoring -components, follow the Modern Syntax rules outlined below. - -#### 1. The * Suffix Convention - -The most recent syntax uses a * suffix in the component name (e.g., -my-component*). This suffix signals the mf/defc macro to apply specific rules -for props handling and destructuring and optimization. - -#### 2. Component Definition - -Modern components should use the following structure: - -```clj -(mf/defc my-component* - {::mf/wrap [mf/memo]} ;; Equivalent to React.memo - [{:keys [name on-click]}] ;; Destructured props - [:div {:class (stl/css :root) - :on-click on-click} - name]) -``` - -#### 3. Hooks - -Use the mf namespace for hooks to maintain consistency with the macro's -lifecycle management. These are analogous to standard React hooks: - -```clj -(mf/use-state) ;; analogous to React.useState adapted to cljs semantics -(mf/use-effect) ;; analogous to React.useEffect -(mf/use-memo) ;; analogous to React.useMemo -(mf/use-fn) ;; analogous to React.useCallback -``` - -The `mf/use-state` in difference with React.useState, returns an atom-like -object, where you can use `swap!` or `reset!` to perform an update and -`deref` to get the current value. - -You also have the `mf/deref` hook (which does not follow the `use-` naming -pattern) and its purpose is to watch (subscribe to changes on) an atom or -derived atom (from okulary) and get the current value. It is mainly used to -subscribe to lenses defined in `app.main.refs` or private lenses defined in -namespaces. - -Rumext also comes with improved syntax macros as alternative to `mf/use-effect` -and `mf/use-memo` functions. Examples: - - -Example for `mf/with-effect` macro: - -```clj -;; Using functions -(mf/use-effect - (mf/deps team-id) - (fn [] - (st/emit! (dd/initialize team-id)) - (fn [] - (st/emit! (dd/finalize team-id))))) - -;; The same effect but using mf/with-effect -(mf/with-effect [team-id] - (st/emit! (dd/initialize team-id)) - (fn [] - (st/emit! (dd/finalize team-id)))) -``` - -Example for `mf/with-memo` macro: - -``` -;; Using functions -(mf/use-memo - (mf/deps projects team-id) - (fn [] - (->> (vals projects) - (filterv #(= team-id (:team-id %)))))) - -;; Using the macro -(mf/with-memo [projects team-id] - (->> (vals projects) - (filterv #(= team-id (:team-id %))))) -``` - -Prefer using the macros for their syntax simplicity. - - -#### 4. Component Usage (Hiccup Syntax) - -When invoking a component within Hiccup, always use the [:> component* props] -pattern. - -Requirements for props: - -- Must be a map literal or a symbol pointing to a JavaScript props object. -- To create a JS props object, use the `#js` literal or the `mf/spread-object` helper macro. - -Examples: - -```clj -;; Using object literal (no need of #js because macro already interprets it) -[:> my-component* {:data-foo "bar"}] - -;; Using object literal (no need of #js because macro already interprets it) -(let [props #js {:data-foo "bar" - :className "myclass"}] - [:> my-component* props]) - -;; Using the spread helper -(let [props (mf/spread-object base-props {:extra "data"})] - [:> my-component* props]) -``` - -#### 5. Styles - -##### Styles on component code -Styles are co-located with components. Each `.cljs` file has a corresponding -`.scss` file. - -Example of clojurescript code for reference classes defined on styles (we use -CSS modules pattern): - -```clojure -;; In the component namespace: -(require '[app.main.style :as stl]) - -;; In the render function: -[:div {:class (stl/css :container :active)}] - -;; Conditional: -[:div {:class (stl/css-case :some-class true :selected (= drawtool :rect))}] - -;; When you need concat an existing class: -[:div {:class [existing-class (stl/css-case :some-class true :selected (= drawtool :rect))]}] -``` - -##### General rules for styling - -- Prefer CSS custom properties ( `margin: var(--sp-xs);`) instead of scss - variables and get the already defined properties from `_sizes.scss`. The SCSS - variables are allowed and still used, just prefer properties if they are - already defined. -- If a value isn't in the DS, use the `px2rem(n)` mixin: `@use "ds/_utils.scss" - as *; padding: px2rem(23);`. -- Do **not** create new SCSS variables for one-off values. -- Use physical directions with logical ones to support RTL/LTR naturally: - - Avoid: `margin-left`, `padding-right`, `left`, `right`. - - Prefer: `margin-inline-start`, `padding-inline-end`, `inset-inline-start`. -- Always use the `use-typography` mixin from `ds/typography.scss`: - - Example: `@include t.use-typography("title-small");` -- Use `$br-*` for radius and `$b-*` for thickness from `ds/_borders.scss`. -- Use only tokens from `ds/colors.scss`. Do **NOT** use `design-tokens.scss` or - legacy color variables. -- Use mixins only from `ds/mixins.scss`. Avoid legacy mixins like - `@include flexCenter;`. Write standard CSS (flex/grid) instead. -- Use the `@use` instead of `@import`. If you go to refactor existing SCSS file, - try to replace all `@import` with `@use`. Example: `@use "ds/_sizes.scss" as - *;` (Use `as *` to expose variables directly). -- Avoid deep selector nesting or high-specificity (IDs). Flatten selectors: - - Avoid: `.card { .title { ... } }` - - Prefer: `.card-title { ... }` -- Leverage component-level CSS variables for state changes (hover/focus) instead - of rewriting properties. - -##### Checklist - -- [ ] No references to `common/refactor/` -- [ ] All `@import` converted to `@use` (only if refactoring) -- [ ] Physical properties (left/right) using logical properties (inline-start/end). -- [ ] Typography implemented via `use-typography()` mixin. -- [ ] Hardcoded pixel values wrapped in `px2rem()`. -- [ ] Selectors are flat (no deep nesting). - - -### Translations (`tr`) and Memoization - -`(tr "some.key")` resolves the translation string from the **currently active -locale at call time**. This has two consequences: - -- **Never call `(tr ...)` at namespace level** (inside a `def` or `defonce`). - Doing so would freeze the label to the locale active at module load time and - break runtime language switching. -- **Always call `(tr ...)` at render time** — either directly in the component - body or inside a `mf/with-memo` / `mf/use-memo` block. - -When a component renders a **static list of options** whose labels come from -`(tr ...)` (e.g. radio button options, select options), wrap the vector in -`mf/with-memo []` with no dependencies. This ensures the vector and its -`(tr ...)` calls are evaluated once per component mount instead of on every -render, while still respecting the render-time requirement: - -```clojure -(let [options (mf/with-memo [] - [{:value "top" :label (tr "some.key.top")} - {:value "center" :label (tr "some.key.center")} - {:value "bottom" :label (tr "some.key.bottom")}])] - ...) -``` - -### Performance Macros (`app.common.data.macros`) - -Always prefer these macros over their `clojure.core` equivalents — they compile to faster JavaScript: - -```clojure -(dm/select-keys m [:a :b]) ;; ~6x faster than core/select-keys -(dm/get-in obj [:a :b :c]) ;; faster than core/get-in -(dm/str "a" "b" "c") ;; string concatenation -``` - -### Configuration - -`src/app/config.clj` reads globally defined variables and exposes precomputed -configuration values ready to be used from other parts of the application. - diff --git a/frontend/package.json b/frontend/package.json index 82047594c1..289e92c734 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,9 +31,10 @@ "fmt:scss": "prettier -c resources/styles -c src/**/*.scss -w", "lint:clj": "clj-kondo --parallel --lint ../common/src src/", "lint:js": "exit 0", - "lint:scss": "pnpx stylelint '{src,resources}/**/*.scss'", + "lint:scss": "pnpm exec stylelint '{src,resources}/**/*.scss'", "build:test": "clojure -M:dev:shadow-cljs compile test", "test": "pnpm run build:wasm && pnpm run build:test && node target/tests/test.js", + "test:quiet": "node ./scripts/test-quiet.js", "test:storybook": "vitest run --project=storybook", "watch:test": "mkdir -p target/tests && concurrently \"clojure -M:dev:shadow-cljs watch test\" \"nodemon -C -d 2 -w target/tests --exec 'node target/tests/test.js'\"", "test:e2e": "playwright test --project default", @@ -56,7 +57,7 @@ "@penpot/text-editor": "workspace:./text-editor", "@penpot/tokenscript": "workspace:./packages/tokenscript", "@penpot/ui": "workspace:./packages/ui", - "@playwright/test": "1.59.1", + "@playwright/test": "1.60.0", "@storybook/addon-docs": "10.3.5", "@storybook/addon-themes": "10.3.5", "@storybook/addon-vitest": "10.3.5", @@ -72,7 +73,7 @@ "concurrently": "^9.2.1", "date-fns": "^4.1.0", "esbuild": "^0.28.0", - "eventsource-parser": "^3.0.6", + "eventsource-parser": "^3.0.8", "express": "^5.1.0", "fancy-log": "^2.0.0", "getopts": "^2.3.0", @@ -90,7 +91,7 @@ "npm-run-all": "^4.1.5", "opentype.js": "^1.3.4", "p-limit": "^7.3.0", - "playwright": "1.59.1", + "playwright": "1.60.0", "postcss": "^8.5.8", "postcss-clean": "^1.2.2", "postcss-modules": "^6.0.1", diff --git a/frontend/packages/mousetrap/index.js b/frontend/packages/mousetrap/index.js index 5a0bc3e0bc..12bcbab1b9 100644 --- a/frontend/packages/mousetrap/index.js +++ b/frontend/packages/mousetrap/index.js @@ -187,6 +187,14 @@ function _addEvent(object, type, callback) { */ function _characterFromEvent(e) { + // Numpad digits as "num0".."num9" — keeps them separate from main-row bindings across NumLock states and event types. + if (e.code && e.code.indexOf('Numpad') === 0) { + var suffix = e.code.substring(6); + if (suffix.length === 1 && suffix >= '0' && suffix <= '9') { + return 'num' + suffix; + } + } + // for keypress events we should return the character as is if (e.type == 'keypress') { var character = String.fromCharCode(e.which); diff --git a/frontend/playwright/data/render-wasm/get-file-paths-evenodd.json b/frontend/playwright/data/render-wasm/get-file-paths-evenodd.json new file mode 100644 index 0000000000..fb2a5f3f40 --- /dev/null +++ b/frontend/playwright/data/render-wasm/get-file-paths-evenodd.json @@ -0,0 +1,822 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "variants/v1", + "layout/grid", + "styles/v2", + "fdata/objects-map", + "render-wasm/v1", + "text-editor-wasm/v1", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:team-id": "~u6bd7c17d-4f59-815e-8006-5c1f6882469a", + "~: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": "svg_path_evenodd", + "~:revn": 18, + "~:modified-at": "~m1776843383797", + "~:vern": 0, + "~:id": "~u3e84615b-5628-818c-8007-e7563bb081fb", + "~: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" + ] + }, + "~:version": 67, + "~:project-id": "~u6bd7c17d-4f59-815e-8006-5c1f68846e43", + "~:created-at": "~m1776779037378", + "~:backend": "legacy-db", + "~:data": { + "~:pages": ["~u3e84615b-5628-818c-8007-e7563bb081fc"], + "~:pages-index": { + "~u3e84615b-5628-818c-8007-e7563bb081fc": { + "~: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\",[\"~ud0a635f7-639e-80f3-8007-e84b7399e693\",\"~ud0a635f7-639e-80f3-8007-e84b73ad74f4\",\"~ud0a635f7-639e-80f3-8007-e84b73bdd7da\",\"~ud0a635f7-639e-80f3-8007-e84b73ce5ead\",\"~ud0a635f7-639e-80f3-8007-e84b73d4a494\"]]]", + "~ud0a635f7-639e-80f3-8007-e84b73ce5ead": "[\"~#shape\",[\"^ \",\"~:y\",-840.999998986721,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"04-venn-circles\",\"~:width\",99.99999761581421,\"~:type\",\"~:group\",\"~:svg-attrs\",[\"^ \",\"^5\",\"120\",\"~:height\",\"120\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",89.00000085433328,\"~:y\",-840.999998986721]],[\"^;\",[\"^ \",\"~:x\",188.9999984701475,\"~:y\",-840.999998986721]],[\"^;\",[\"^ \",\"~:x\",188.9999984701475,\"~:y\",-741.0000013709068]],[\"^;\",[\"^ \",\"~:x\",89.00000085433328,\"~:y\",-741.0000013709068]]],\"~: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\",\"~ud0a635f7-639e-80f3-8007-e84b73ce5ead\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",89.00000085433328,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",89.00000085433328,\"~:y\",-840.999998986721,\"^5\",99.99999761581421,\"^9\",99.99999761581421,\"~:x1\",89.00000085433328,\"~:y1\",-840.999998986721,\"~:x2\",188.9999984701475,\"~:y2\",-741.0000013709068]],\"~:fills\",[],\"~:flip-x\",null,\"^9\",99.99999761581421,\"~:flip-y\",null,\"~:shapes\",[\"~ud0a635f7-639e-80f3-8007-e84b73ce9306\",\"~ud0a635f7-639e-80f3-8007-e84b73cf1ec2\"]]]", + "~ud0a635f7-639e-80f3-8007-e84b739a1146": "[\"~#shape\",[\"^ \",\"~:y\",-725.9999912977219,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"base-background\",\"~:width\",100,\"~:type\",\"~:rect\",\"~:svg-attrs\",[\"^ \",\"~:fill\",\"none\",\"~:id\",\"base-background\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",257.9999885559082,\"~:y\",-725.9999912977219]],[\"^<\",[\"^ \",\"~:x\",357.9999885559082,\"~:y\",-725.9999912977219]],[\"^<\",[\"^ \",\"~:x\",357.9999885559082,\"~:y\",-625.9999912977219]],[\"^<\",[\"^ \",\"~:x\",257.9999885559082,\"~:y\",-625.9999912977219]]],\"~: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,\"~:hidden\",true,\"^:\",\"~ud0a635f7-639e-80f3-8007-e84b739a1146\",\"~:parent-id\",\"~ud0a635f7-639e-80f3-8007-e84b7399e693\",\"~:svg-viewbox\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^5\",100,\"~:height\",100,\"~:x1\",0,\"~:y1\",0,\"~:x2\",100,\"~:y2\",100]],\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",257.9999885559082,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"^E\",[\"^ \",\"~:x\",257.9999885559082,\"~:y\",-725.9999912977219,\"^5\",100,\"^F\",100,\"^G\",257.9999885559082,\"^H\",-725.9999912977219,\"^I\",357.9999885559082,\"^J\",-625.9999912977219]],\"~:fills\",[],\"~:flip-x\",null,\"^F\",100,\"~:flip-y\",null]]", + "~ud0a635f7-639e-80f3-8007-e84b73ce9306": "[\"~#shape\",[\"^ \",\"~:y\",-840.999998986721,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"base-background\",\"~:width\",99.99999761581421,\"~:type\",\"~:rect\",\"~:svg-attrs\",[\"^ \",\"~:fill\",\"none\",\"~:id\",\"base-background\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",89.00000085433328,\"~:y\",-840.999998986721]],[\"^<\",[\"^ \",\"~:x\",188.9999984701475,\"~:y\",-840.999998986721]],[\"^<\",[\"^ \",\"~:x\",188.9999984701475,\"~:y\",-741.0000013709068]],[\"^<\",[\"^ \",\"~:x\",89.00000085433328,\"~:y\",-741.0000013709068]]],\"~: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,\"~:hidden\",true,\"^:\",\"~ud0a635f7-639e-80f3-8007-e84b73ce9306\",\"~:parent-id\",\"~ud0a635f7-639e-80f3-8007-e84b73ce5ead\",\"~:svg-viewbox\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^5\",120,\"~:height\",120,\"~:x1\",0,\"~:y1\",0,\"~:x2\",120,\"~:y2\",120]],\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",89.00000085433328,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"^E\",[\"^ \",\"~:x\",89.00000085433328,\"~:y\",-840.999998986721,\"^5\",99.99999761581421,\"^F\",99.99999761581421,\"^G\",89.00000085433328,\"^H\",-840.999998986721,\"^I\",188.9999984701475,\"^J\",-741.0000013709068]],\"~:fills\",[],\"~:flip-x\",null,\"^F\",99.99999761581421,\"~:flip-y\",null]]", + "~ud0a635f7-639e-80f3-8007-e84b73be6505": "[\"~#shape\",[\"^ \",\"~:y\",-841,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"base-background\",\"~:width\",99.99998211860657,\"~:type\",\"~:rect\",\"~:svg-attrs\",[\"^ \",\"~:fill\",\"none\",\"~:id\",\"base-background\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",188.9999989271164,\"~:y\",-841]],[\"^<\",[\"^ \",\"~:x\",288.99998104572296,\"~:y\",-841]],[\"^<\",[\"^ \",\"~:x\",288.99998104572296,\"~:y\",-741]],[\"^<\",[\"^ \",\"~:x\",188.9999989271164,\"~:y\",-741]]],\"~: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,\"~:hidden\",true,\"^:\",\"~ud0a635f7-639e-80f3-8007-e84b73be6505\",\"~:parent-id\",\"~ud0a635f7-639e-80f3-8007-e84b73bdd7da\",\"~:svg-viewbox\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^5\",100,\"~:height\",100,\"~:x1\",0,\"~:y1\",0,\"~:x2\",100,\"~:y2\",100]],\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",188.9999989271164,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"^E\",[\"^ \",\"~:x\",188.9999989271164,\"~:y\",-841,\"^5\",99.99998211860657,\"^F\",100,\"^G\",188.9999989271164,\"^H\",-841,\"^I\",288.99998104572296,\"^J\",-741]],\"~:fills\",[],\"~:flip-x\",null,\"^F\",100,\"~:flip-y\",null]]", + "~ud0a635f7-639e-80f3-8007-e84b73ae0f65": "[\"~#shape\",[\"^ \",\"~:y\",null,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:content\",[\"~#penpot/path-data\",\"~bAQAAAAAAAAAAAAAAAAAAAAAAAAAAgJpDAMBPxAIAAAAAAAAAAAAAAAAAAAAAAAAAAIDCQwDAT8QCAAAAAAAAAAAAAAAAAAAAAAAAAACAwkMAwDvEAgAAAAAAAAAAAAAAAAAAAAAAAAAAgJpDAMA7xAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAokMAAEzEAgAAAAAAAAAAAAAAAAAAAAAAAAAAALtDAABMxAIAAAAAAAAAAAAAAAAAAAAAAAAAAAC7QwCAP8QCAAAAAAAAAAAAAAAAAAAAAAAAAAAAokMAgD/EBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAICpQwBASMQCAAAAAAAAAAAAAAAAAAAAAAAAAACAs0MAQEjEAgAAAAAAAAAAAAAAAAAAAAAAAAAAgLNDAEBDxAIAAAAAAAAAAAAAAAAAAAAAAAAAAICpQwBAQ8QEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"],\"~:name\",\"svg-path\",\"~:width\",null,\"~:type\",\"~:path\",\"~:svg-attrs\",[\"^ \",\"~:fillRule\",\"evenodd\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",309,\"~:y\",-831]],[\"^=\",[\"^ \",\"~:x\",389,\"~:y\",-831]],[\"^=\",[\"^ \",\"~:x\",389,\"~:y\",-751]],[\"^=\",[\"^ \",\"~:x\",309,\"~:y\",-751]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:svg-transform\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~ud0a635f7-639e-80f3-8007-e84b73ae0f65\",\"~:parent-id\",\"~ud0a635f7-639e-80f3-8007-e84b73ad74f4\",\"~:svg-viewbox\",[\"~#rect\",[\"^ \",\"~:x\",10,\"~:y\",10,\"^7\",80,\"~:height\",80,\"~:x1\",10,\"~:y1\",10,\"~:x2\",90,\"~:y2\",90]],\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#1a6a3a\",\"~:stroke-opacity\",1,\"~:stroke-width\",3]],\"~:x\",null,\"~:proportion\",1,\"~:selrect\",[\"^D\",[\"^ \",\"~:x\",309,\"~:y\",-831,\"^7\",80,\"^E\",80,\"^F\",309,\"^G\",-831,\"^H\",389,\"^I\",-751]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#4ae290\"]],\"~:flip-x\",null,\"^E\",null,\"~:flip-y\",null]]", + "~ud0a635f7-639e-80f3-8007-e84b73d53705": "[\"~#shape\",[\"^ \",\"~:y\",null,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:content\",[\"~#penpot/path-data\",\"~bAQAAAAAAAAAAAAAAAAAAAAAAAAABACpDq2ozxAMAAAD2/BJDq2ozxFVVAEPDwC7EVVUAQwAAKcQDAAAAVVUAQz4/I8T2/BJDVpUexAEAKkNWlR7EAwAAAAoDQUNWlR7EqqpTQz4/I8SqqlNDAAApxAMAAACqqlNDw8AuxAoDQUOrajPEAQAqQ6tqM8QCAAAAAAAAAAAAAAAAAAAAAAAAAAEAKkOrajPEAQAAAAAAAAAAAAAAAAAAAAAAAAABACpDAEAvxAMAAAAmeiRDAEAvxAEAIEN3IS7EAQAgQwDALMQDAAAAAQAgQ4leK8QmeiRDAEAqxAEAKkMAQCrEAwAAANuFL0MAQCrEAQA0Q4leK8QBADRDAMAsxAMAAAABADRDdyEuxNuFL0MAQC/EAQAqQwBAL8QCAAAAAAAAAAAAAAAAAAAAAAAAAAEAKkMAQC/EAQAAAAAAAAAAAAAAAAAAAAAAAABWVRRDrKogxAMAAABWVRRDrGokxKuqHEOr6ibEAQAqQ6vqJsQDAAAAVVU3Q6vqJsSqqj9DrGokxKqqP0OsqiDEAwAAAKqqP0MBQCDE//89Q1bVH8RVVTxDVtUfxAIAAAAAAAAAAAAAAAAAAAAAAAAAq6oXQ1bVH8QDAAAAAQAWQ1bVH8RWVRRDAUAgxFZVFEOsqiDEAgAAAAAAAAAAAAAAAAAAAAAAAABWVRRDrKogxA==\"],\"~:name\",\"svg-path\",\"~:width\",null,\"~:type\",\"~:path\",\"~:svg-attrs\",[\"^ \",\"~:fillRule\",\"evenodd\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",128.33333949247913,\"~:y\",-717.6666545867935]],[\"^=\",[\"^ \",\"~:x\",211.6666658719356,\"~:y\",-717.6666545867935]],[\"^=\",[\"^ \",\"~:x\",211.6666658719356,\"~:y\",-634.3333282073353]],[\"^=\",[\"^ \",\"~:x\",128.33333949247913,\"~:y\",-634.3333282073353]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:svg-transform\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~ud0a635f7-639e-80f3-8007-e84b73d53705\",\"~:parent-id\",\"~ud0a635f7-639e-80f3-8007-e84b73d4a494\",\"~:svg-viewbox\",[\"~#rect\",[\"^ \",\"~:x\",5,\"~:y\",5,\"^7\",50,\"~:height\",50,\"~:x1\",5,\"~:y1\",5,\"~:x2\",55,\"~:y2\",55]],\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#6a4a1a\",\"~:stroke-opacity\",1,\"~:stroke-width\",2]],\"~:x\",null,\"~:proportion\",1,\"~:selrect\",[\"^D\",[\"^ \",\"~:x\",128.33333949247913,\"~:y\",-717.6666545867935,\"^7\",83.33332637945648,\"^E\",83.33332637945819,\"^F\",128.33333949247913,\"^G\",-717.6666545867935,\"^H\",211.6666658719356,\"^I\",-634.3333282073353]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#e2a04a\"]],\"~:flip-x\",null,\"^E\",null,\"~:flip-y\",null]]", + "~ud0a635f7-639e-80f3-8007-e84b73cf1ec2": "[\"~#shape\",[\"^ \",\"~:y\",null,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:content\",[\"~#penpot/path-data\",\"~bAQAAAAAAAAAAAAAAAAAAAAAAAAAAAMtCAOBIxAMAAAAAAMtCnv9FxKKn3UKqqkPEqqr0QqqqQ8QDAAAA29YFQ6qqQ8SrKg9Dnv9FxKsqD0MA4EjEAwAAAKsqD0NhwEvE29YFQ1UVTsSqqvRCVRVOxAMAAACip91CVRVOxAAAy0JhwEvEAADLQgDgSMQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAABV1QZDAOBIxAMAAABV1QZDnv9FxCUpEEOqqkPEqqobQ6qqQ8QDAAAAMCwnQ6qqQ8QAgDBDnv9FxACAMEMA4EjEAwAAAACAMENhwEvEMCwnQ1UVTsSqqhtDVRVOxAMAAAAlKRBDVRVOxFXVBkNhwEvEVdUGQwDgSMQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAABWVexCVZVBxAMAAABWVexC9LQ+xPb8/kIAYDzEAAALQwBgPMQDAAAAhYEWQwBgPMRV1R9D9LQ+xFXVH0NVlUHEAwAAAFXVH0O2dUTEhYEWQ6rKRsQAAAtDqspGxAMAAAD2/P5CqspGxFZV7EK2dUTEVlXsQlWVQcQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"],\"~:name\",\"svg-path\",\"~:width\",null,\"~:type\",\"~:path\",\"~:svg-attrs\",[\"^ \",\"~:fillRule\",\"evenodd\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",101.50000055631006,\"~:y\",-824.3333327174187]],[\"^=\",[\"^ \",\"~:x\",176.4999987681707,\"~:y\",-824.3333327174187]],[\"^=\",[\"^ \",\"~:x\",176.4999987681707,\"~:y\",-753.5000010728836]],[\"^=\",[\"^ \",\"~:x\",101.50000055631006,\"~:y\",-753.5000010728836]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:svg-transform\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~ud0a635f7-639e-80f3-8007-e84b73cf1ec2\",\"~:parent-id\",\"~ud0a635f7-639e-80f3-8007-e84b73ce5ead\",\"~:svg-viewbox\",[\"~#rect\",[\"^ \",\"~:x\",15,\"~:y\",20,\"^7\",90,\"~:height\",85,\"~:x1\",15,\"~:y1\",20,\"~:x2\",105,\"~:y2\",105]],\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#3a1a6a\",\"~:stroke-opacity\",1,\"~:stroke-width\",3]],\"~:x\",null,\"~:proportion\",1,\"~:selrect\",[\"^D\",[\"^ \",\"~:x\",101.50000055631006,\"~:y\",-824.3333327174187,\"^7\",74.99999821186066,\"^E\",70.83333164453506,\"^F\",101.50000055631006,\"^G\",-824.3333327174187,\"^H\",176.4999987681707,\"^I\",-753.5000010728836]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#9a4ae2\"]],\"~:flip-x\",null,\"^E\",null,\"~:flip-y\",null]]", + "~ud0a635f7-639e-80f3-8007-e84b73bea73d": "[\"~#shape\",[\"^ \",\"~:y\",null,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:content\",[\"~#penpot/path-data\",\"~bAQAAAAAAAAAAAAAAAAAAAAAAAAD//25DAABRxAIAAAAAAAAAAAAAAAAAAAAAAAAAAABVQwDAPMQCAAAAAAAAAAAAAAAAAAAAAAAAAP//jEMAQEnEAgAAAAAAAAAAAAAAAAAAAAAAAAAAAERDAEBJxAIAAAAAAAAAAAAAAAAAAAAAAAAAAICEQwDAPMQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"],\"~:name\",\"svg-path\",\"~:width\",null,\"~:type\",\"~:path\",\"~:svg-attrs\",[\"^ \",\"~:fillRule\",\"evenodd\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",195.99999767541885,\"~:y\",-836]],[\"^=\",[\"^ \",\"~:x\",281.9999822974205,\"~:y\",-836]],[\"^=\",[\"^ \",\"~:x\",281.9999822974205,\"~:y\",-755]],[\"^=\",[\"^ \",\"~:x\",195.99999767541885,\"~:y\",-755]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:svg-transform\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~ud0a635f7-639e-80f3-8007-e84b73bea73d\",\"~:parent-id\",\"~ud0a635f7-639e-80f3-8007-e84b73bdd7da\",\"~:svg-viewbox\",[\"~#rect\",[\"^ \",\"~:x\",7,\"~:y\",5,\"^7\",86,\"~:height\",81,\"~:x1\",7,\"~:y1\",5,\"~:x2\",93,\"~:y2\",86]],\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#6a1a1a\",\"~:stroke-opacity\",1,\"~:stroke-width\",3]],\"~:x\",null,\"~:proportion\",1,\"~:selrect\",[\"^D\",[\"^ \",\"~:x\",195.99999767541885,\"~:y\",-836,\"^7\",85.99998462200165,\"^E\",81,\"^F\",195.99999767541885,\"^G\",-836,\"^H\",281.9999822974205,\"^I\",-755]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#e24a4a\"]],\"~:flip-x\",null,\"^E\",null,\"~:flip-y\",null]]", + "~ud0a635f7-639e-80f3-8007-e84b73d4e3fc": "[\"~#shape\",[\"^ \",\"~:y\",-725.9999872247394,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"base-background\",\"~:width\",99.99999165534774,\"~:type\",\"~:rect\",\"~:svg-attrs\",[\"^ \",\"~:fill\",\"none\",\"~:id\",\"base-background\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",120.0000068545335,\"~:y\",-725.9999872247394]],[\"^<\",[\"^ \",\"~:x\",219.99999850988124,\"~:y\",-725.9999872247394]],[\"^<\",[\"^ \",\"~:x\",219.99999850988124,\"~:y\",-625.9999955693894]],[\"^<\",[\"^ \",\"~:x\",120.0000068545335,\"~:y\",-625.9999955693894]]],\"~: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,\"~:hidden\",true,\"^:\",\"~ud0a635f7-639e-80f3-8007-e84b73d4e3fc\",\"~:parent-id\",\"~ud0a635f7-639e-80f3-8007-e84b73d4a494\",\"~:svg-viewbox\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^5\",60,\"~:height\",60,\"~:x1\",0,\"~:y1\",0,\"~:x2\",60,\"~:y2\",60]],\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",120.0000068545335,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"^E\",[\"^ \",\"~:x\",120.0000068545335,\"~:y\",-725.9999872247394,\"^5\",99.99999165534774,\"^F\",99.99999165534996,\"^G\",120.0000068545335,\"^H\",-725.9999872247394,\"^I\",219.99999850988124,\"^J\",-625.9999955693894]],\"~:fills\",[],\"~:flip-x\",null,\"^F\",99.99999165534996,\"~:flip-y\",null]]", + "~ud0a635f7-639e-80f3-8007-e84b73ada09b": "[\"~#shape\",[\"^ \",\"~:y\",-841,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"base-background\",\"~:width\",100,\"~:type\",\"~:rect\",\"~:svg-attrs\",[\"^ \",\"~:fill\",\"none\",\"~:id\",\"base-background\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",299,\"~:y\",-841]],[\"^<\",[\"^ \",\"~:x\",399,\"~:y\",-841]],[\"^<\",[\"^ \",\"~:x\",399,\"~:y\",-741]],[\"^<\",[\"^ \",\"~:x\",299,\"~:y\",-741]]],\"~: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,\"~:hidden\",true,\"^:\",\"~ud0a635f7-639e-80f3-8007-e84b73ada09b\",\"~:parent-id\",\"~ud0a635f7-639e-80f3-8007-e84b73ad74f4\",\"~:svg-viewbox\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^5\",100,\"~:height\",100,\"~:x1\",0,\"~:y1\",0,\"~:x2\",100,\"~:y2\",100]],\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",299,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"^E\",[\"^ \",\"~:x\",299,\"~:y\",-841,\"^5\",100,\"^F\",100,\"^G\",299,\"^H\",-841,\"^I\",399,\"^J\",-741]],\"~:fills\",[],\"~:flip-x\",null,\"^F\",100,\"~:flip-y\",null]]", + "~ud0a635f7-639e-80f3-8007-e84b73bdd7da": "[\"~#shape\",[\"^ \",\"~:y\",-841,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"03-pentagram\",\"~:width\",99.99998211860657,\"~:type\",\"~:group\",\"~:svg-attrs\",[\"^ \",\"^5\",\"100\",\"~:height\",\"100\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",188.9999989271164,\"~:y\",-841]],[\"^;\",[\"^ \",\"~:x\",288.99998104572296,\"~:y\",-841]],[\"^;\",[\"^ \",\"~:x\",288.99998104572296,\"~:y\",-741]],[\"^;\",[\"^ \",\"~:x\",188.9999989271164,\"~:y\",-741]]],\"~: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\",\"~ud0a635f7-639e-80f3-8007-e84b73bdd7da\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",188.9999989271164,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",188.9999989271164,\"~:y\",-841,\"^5\",99.99998211860657,\"^9\",100,\"~:x1\",188.9999989271164,\"~:y1\",-841,\"~:x2\",288.99998104572296,\"~:y2\",-741]],\"~:fills\",[],\"~:flip-x\",null,\"^9\",100,\"~:flip-y\",null,\"~:shapes\",[\"~ud0a635f7-639e-80f3-8007-e84b73be6505\",\"~ud0a635f7-639e-80f3-8007-e84b73bea73d\"]]]", + "~ud0a635f7-639e-80f3-8007-e84b739aa576": "[\"~#shape\",[\"^ \",\"~:y\",null,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:content\",[\"~#penpot/path-data\",\"~bAQAAAAAAAAAAAAAAAAAAAAAAAAAAAJpDAAAzxAMAAABN9I5DAAAzxAAAhkPZhS7EAACGQwAAKcQDAAAAAACGQyd6I8RN9I5DAAAfxAAAmkMAAB/EAwAAALMLpUMAAB/EAACuQyd6I8QAAK5DAAApxAMAAAAAAK5D2YUuxLMLpUMAADPEAACaQwAAM8QEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAJpDAAAuxAMAAAAnepRDAAAuxAAAkEPtwivEAACQQwAAKcQDAAAAAACQQxM9JsQnepRDAAAkxAAAmkMAACTEAwAAANmFn0MAACTEAACkQxM9JsQAAKRDAAApxAMAAAAAAKRD7cIrxNmFn0MAAC7EAACaQwAALsQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"],\"~:name\",\"svg-path\",\"~:width\",null,\"~:type\",\"~:path\",\"~:svg-attrs\",[\"^ \",\"~:fillRule\",\"evenodd\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",267.9999885559082,\"~:y\",-715.9999912977219]],[\"^=\",[\"^ \",\"~:x\",347.9999885559082,\"~:y\",-715.9999912977219]],[\"^=\",[\"^ \",\"~:x\",347.9999885559082,\"~:y\",-635.9999912977219]],[\"^=\",[\"^ \",\"~:x\",267.9999885559082,\"~:y\",-635.9999912977219]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:svg-transform\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~ud0a635f7-639e-80f3-8007-e84b739aa576\",\"~:parent-id\",\"~ud0a635f7-639e-80f3-8007-e84b7399e693\",\"~:svg-viewbox\",[\"~#rect\",[\"^ \",\"~:x\",10,\"~:y\",10,\"^7\",80,\"~:height\",80,\"~:x1\",10,\"~:y1\",10,\"~:x2\",90,\"~:y2\",90]],\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#1a3a6a\",\"~:stroke-opacity\",1,\"~:stroke-width\",3]],\"~:x\",null,\"~:proportion\",1,\"~:selrect\",[\"^D\",[\"^ \",\"~:x\",267.9999885559082,\"~:y\",-715.9999912977219,\"^7\",80,\"^E\",80,\"^F\",267.9999885559082,\"^G\",-715.9999912977219,\"^H\",347.9999885559082,\"^I\",-635.9999912977219]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#4a90e2\"]],\"~:flip-x\",null,\"^E\",null,\"~:flip-y\",null]]", + "~ud0a635f7-639e-80f3-8007-e84b73ad74f4": "[\"~#shape\",[\"^ \",\"~:y\",-841,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"02-nested-squares\",\"~:width\",100,\"~:type\",\"~:group\",\"~:svg-attrs\",[\"^ \",\"^5\",\"100\",\"~:height\",\"100\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",299,\"~:y\",-841]],[\"^;\",[\"^ \",\"~:x\",399,\"~:y\",-841]],[\"^;\",[\"^ \",\"~:x\",399,\"~:y\",-741]],[\"^;\",[\"^ \",\"~:x\",299,\"~:y\",-741]]],\"~: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\",\"~ud0a635f7-639e-80f3-8007-e84b73ad74f4\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",299,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",299,\"~:y\",-841,\"^5\",100,\"^9\",100,\"~:x1\",299,\"~:y1\",-841,\"~:x2\",399,\"~:y2\",-741]],\"~:fills\",[],\"~:flip-x\",null,\"^9\",100,\"~:flip-y\",null,\"~:shapes\",[\"~ud0a635f7-639e-80f3-8007-e84b73ada09b\",\"~ud0a635f7-639e-80f3-8007-e84b73ae0f65\"]]]", + "~ud0a635f7-639e-80f3-8007-e84b73d4a494": "[\"~#shape\",[\"^ \",\"~:y\",-725.9999872247394,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"05-person-icon\",\"~:width\",99.99999165534774,\"~:type\",\"~:group\",\"~:svg-attrs\",[\"^ \",\"^5\",\"60\",\"~:height\",\"60\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",120.0000068545335,\"~:y\",-725.9999872247394]],[\"^;\",[\"^ \",\"~:x\",219.99999850988124,\"~:y\",-725.9999872247394]],[\"^;\",[\"^ \",\"~:x\",219.99999850988124,\"~:y\",-625.9999955693894]],[\"^;\",[\"^ \",\"~:x\",120.0000068545335,\"~:y\",-625.9999955693894]]],\"~: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\",\"~ud0a635f7-639e-80f3-8007-e84b73d4a494\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",120.0000068545335,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",120.0000068545335,\"~:y\",-725.9999872247394,\"^5\",99.99999165534774,\"^9\",99.99999165534996,\"~:x1\",120.0000068545335,\"~:y1\",-725.9999872247394,\"~:x2\",219.99999850988124,\"~:y2\",-625.9999955693894]],\"~:fills\",[],\"~:flip-x\",null,\"^9\",99.99999165534996,\"~:flip-y\",null,\"~:shapes\",[\"~ud0a635f7-639e-80f3-8007-e84b73d4e3fc\",\"~ud0a635f7-639e-80f3-8007-e84b73d53705\"]]]", + "~ud0a635f7-639e-80f3-8007-e84b7399e693": "[\"~#shape\",[\"^ \",\"~:y\",-725.9999912977219,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"01-ring\",\"~:width\",100,\"~:type\",\"~:group\",\"~:svg-attrs\",[\"^ \",\"^5\",\"100\",\"~:height\",\"100\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",257.9999885559082,\"~:y\",-725.9999912977219]],[\"^;\",[\"^ \",\"~:x\",357.9999885559082,\"~:y\",-725.9999912977219]],[\"^;\",[\"^ \",\"~:x\",357.9999885559082,\"~:y\",-625.9999912977219]],[\"^;\",[\"^ \",\"~:x\",257.9999885559082,\"~:y\",-625.9999912977219]]],\"~: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\",\"~ud0a635f7-639e-80f3-8007-e84b7399e693\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",257.9999885559082,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",257.9999885559082,\"~:y\",-725.9999912977219,\"^5\",100,\"^9\",100,\"~:x1\",257.9999885559082,\"~:y1\",-725.9999912977219,\"~:x2\",357.9999885559082,\"~:y2\",-625.9999912977219]],\"~:fills\",[],\"~:flip-x\",null,\"^9\",100,\"~:flip-y\",null,\"~:shapes\",[\"~ud0a635f7-639e-80f3-8007-e84b739a1146\",\"~ud0a635f7-639e-80f3-8007-e84b739aa576\"]]]" + } + }, + "~:id": "~u3e84615b-5628-818c-8007-e7563bb081fc", + "~:name": "Page 1" + } + }, + "~:id": "~u3e84615b-5628-818c-8007-e7563bb081fb", + "~:options": { "~:components-v2": true, "~:base-font-size": "16px" }, + "~:components": { + "~u7c8614ca-087a-80b1-8007-e75c161f105c": { + "~:path": "Icons / 16", + "~:deleted": true, + "~:main-instance-id": "~u7c8614ca-087a-80b1-8007-e75c161ef12f", + "~:objects": { + "~u7c8614ca-087a-80b1-8007-e75c161ef12f": { + "~#shape": { + "~:y": -622.2015816167936, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": true, + "~:name": "Icons / 16 / profile", + "~:width": 16, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 185.68057966317429, + "~:y": -622.2015816167936 + } + }, + { + "~#point": { + "~:x": 201.68057966317429, + "~:y": -622.2015816167936 + } + }, + { + "~#point": { + "~:x": 201.68057966317429, + "~:y": -606.2015816167934 + } + }, + { + "~#point": { + "~:x": 185.68057966317429, + "~:y": -606.2015816167934 + } + } + ], + "~:component-root": true, + "~:show-content": true, + "~:proportion-lock": true, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:page-id": "~u34c33767-b561-80aa-8007-e7024082d3b1", + "~:id": "~u7c8614ca-087a-80b1-8007-e75c161ef12f", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:applied-tokens": { + "~:width": "xx.alias.icon.size.s", + "~:height": "xx.alias.icon.size.s" + }, + "~:component-id": "~u7c8614ca-087a-80b1-8007-e75c161f105c", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 185.68057966317429, + "~:main-instance": true, + "~:proportion": 0.999999999999981, + "~:selrect": { + "~#rect": { + "~:x": 185.68057966317429, + "~:y": -622.2015816167936, + "~:width": 16, + "~:height": 16.000000000000227, + "~:x1": 185.68057966317429, + "~:y1": -622.2015816167936, + "~:x2": 201.68057966317429, + "~:y2": -606.2015816167934 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 16.000000000000227, + "~:component-file": "~u3e84615b-5628-818c-8007-e7563bb081fb", + "~:flip-y": null, + "~:shapes": [ + "~u7c8614ca-087a-80b1-8007-e75c161ef130", + "~u7c8614ca-087a-80b1-8007-e75c161ef131" + ] + } + }, + "~u7c8614ca-087a-80b1-8007-e75c161ef130": { + "~#shape": { + "~:y": null, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:content": { + "~#penpot/path-data": "~bAQAAAAAAAAAAAAAAAAAAAAAAAADmWERD5wwaxAMAAADmWERDpa4ZxD4nQ0M9YhnEOq5BQz1iGcQDAAAAMjVAQz1iGcSOAz9Dpa4ZxI4DP0PnDBrEAwAAAI4DP0MpaxrEMjVAQ5G3GsQ6rkFDkbcaxAMAAAA+J0NDkbcaxOZYREMpaxrE5lhEQ+cMGsQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAACOA0ND5wwaxAMAAACOA0NDxd0ZxL5qQkORtxnEOq5BQ5G3GcQDAAAAtvFAQ5G3GcTmWEBDxd0ZxOZYQEPnDBrEAwAAAOZYQEMHPBrEtvFAQz1iGsQ6rkFDPWIaxAMAAAC+akJDPWIaxI4DQ0MHPBrEjgNDQ+cMGsQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + "~:name": "svg-path", + "~:width": null, + "~:type": "~:path", + "~:svg-attrs": { + "~:fill-rule": "evenodd", + "~:clip-rule": "evenodd" + }, + "~:points": [ + { + "~#point": { + "~:x": 191.01391299650777, + "~:y": -618.8682482834602 + } + }, + { + "~#point": { + "~:x": 196.34724632984125, + "~:y": -618.8682482834602 + } + }, + { + "~#point": { + "~:x": 196.34724632984125, + "~:y": -613.5349149501269 + } + }, + { + "~#point": { + "~:x": 191.01391299650777, + "~:y": -613.5349149501269 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:page-id": "~u34c33767-b561-80aa-8007-e7024082d3b1", + "~:constraints-v": "~:scale", + "~:svg-transform": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + }, + "~:constraints-h": "~:scale", + "~:id": "~u7c8614ca-087a-80b1-8007-e75c161ef130", + "~:parent-id": "~u7c8614ca-087a-80b1-8007-e75c161ef12f", + "~:svg-viewbox": { + "~:y": 5, + "~:y1": 5, + "~:width": 8, + "~:x": 8, + "~:x1": 8, + "~:y2": 13, + "~:x2": 16, + "~:height": 8 + }, + "~:applied-tokens": {}, + "~:svg-defs": {}, + "~:frame-id": "~u7c8614ca-087a-80b1-8007-e75c161ef12f", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 1, + "~:stroke-color": "#121270", + "~:stroke-opacity": 1 + } + ], + "~:x": null, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 191.01391299650777, + "~:y": -618.8682482834604, + "~:width": 5.333333333333485, + "~:height": 5.3333333333332575, + "~:x1": 191.01391299650777, + "~:y1": -618.8682482834604, + "~:x2": 196.34724632984125, + "~:y2": -613.5349149501271 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": null, + "~:flip-y": null + } + }, + "~u7c8614ca-087a-80b1-8007-e75c161ef131": { + "~#shape": { + "~:y": null, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:content": { + "~#penpot/path-data": "~bAQAAAAAAAAAAAAAAAAAAAAAAAAA6rkFDPWIbxAMAAABmoT1DPWIbxOZYOkMbkBrE5lg6Q+eMGcQDAAAA5lg6Q7OJGMRmoT1DkbcXxDquQUORtxfEAwAAAAq7RUORtxfEjgNJQ7OJGMSOA0lD54wZxAMAAACOA0lDG5AaxAq7RUM9YhvEOq5BQz1iG8QCAAAAAAAAAAAAAAAAAAAAAAAAADquQUM9YhvEAQAAAAAAAAAAAAAAAAAAAAAAAAA6rjtD54wZxAMAAAA6rjtDuTMZxNYnPEOj4RjE2vM8Q3WgGMQDAAAAggw+Q0/8GMQyxz9DkTcZxD65QUORNxnEAwAAAM6kQ0ORNxnEdlpFQ9f9GMSac0ZDA6QYxAMAAAD6OEdDneQYxDquR0NVNRnEOq5HQ+eMGcQDAAAAOq5HQ/tgGsSK/kRD5wwbxDquQUPnDBvEAwAAAOpdPkPnDBvEOq47Q/tgGsQ6rjtD54wZxAIAAAAAAAAAAAAAAAAAAAAAAAAAOq47Q+eMGcQBAAAAAAAAAAAAAAAAAAAAAAAAADquQUPnDBjEAwAAAEI/QEPnDBjEVu4+QxMtGMQm5j1DwWIYxAMAAAAuvj5D168YxCokQEM94hjEPrlBQz3iGMQDAAAAQklDQz3iGMRiq0RDFbEYxDaERUOdZRjEAwAAAP55REM5LhjEqiNDQ+cMGMQ6rkFD5wwYxAIAAAAAAAAAAAAAAAAAAAAAAAAAOq5BQ+cMGMQ=" + }, + "~:name": "svg-path", + "~:width": null, + "~:type": "~:path", + "~:svg-attrs": { + "~:fill-rule": "evenodd", + "~:clip-rule": "evenodd" + }, + "~:points": [ + { + "~#point": { + "~:x": 186.34724632984103, + "~:y": -621.5349149501271 + } + }, + { + "~#point": { + "~:x": 201.01391299650777, + "~:y": -621.5349149501271 + } + }, + { + "~#point": { + "~:x": 201.01391299650777, + "~:y": -606.8682482834602 + } + }, + { + "~#point": { + "~:x": 186.34724632984103, + "~:y": -606.8682482834602 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:page-id": "~u34c33767-b561-80aa-8007-e7024082d3b1", + "~:constraints-v": "~:scale", + "~:svg-transform": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + }, + "~:constraints-h": "~:scale", + "~:id": "~u7c8614ca-087a-80b1-8007-e75c161ef131", + "~:parent-id": "~u7c8614ca-087a-80b1-8007-e75c161ef12f", + "~:svg-viewbox": { + "~:y": 1, + "~:y1": 1, + "~:width": 22, + "~:x": 1, + "~:x1": 1, + "~:y2": 23, + "~:x2": 23, + "~:height": 22 + }, + "~:applied-tokens": {}, + "~:svg-defs": {}, + "~:frame-id": "~u7c8614ca-087a-80b1-8007-e75c161ef12f", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 1, + "~:stroke-color": "#121270", + "~:stroke-opacity": 1 + } + ], + "~:x": null, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 186.3472463298408, + "~:y": -621.5349149501271, + "~:width": 14.666666666666742, + "~:height": 14.66666666666697, + "~:x1": 186.3472463298408, + "~:y1": -621.5349149501271, + "~:x2": 201.01391299650754, + "~:y2": -606.8682482834602 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": null, + "~:flip-y": null + } + } + }, + "~:name": "profile", + "~:modified-at": "~m1776843284702", + "~:main-instance-page": "~u3e84615b-5628-818c-8007-e7563bb081fc", + "~:id": "~u7c8614ca-087a-80b1-8007-e75c161f105c" + }, + "~u2094e2d4-1854-804d-8007-e761fd29d15c": { + "~:path": "Icons / 16", + "~:deleted": true, + "~:main-instance-id": "~u2094e2d4-1854-804d-8007-e761fd24f93e", + "~:objects": { + "~u2094e2d4-1854-804d-8007-e761fd24f93e": { + "~#shape": { + "~:y": -623.0000079548252, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": true, + "~:name": "Icons / 16 / profile", + "~:width": 16, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 214.0000008841555, + "~:y": -623.0000079548252 + } + }, + { + "~#point": { + "~:x": 230.0000008841555, + "~:y": -623.0000079548252 + } + }, + { + "~#point": { + "~:x": 230.0000008841555, + "~:y": -607.0000079548249 + } + }, + { + "~#point": { + "~:x": 214.0000008841555, + "~:y": -607.0000079548249 + } + } + ], + "~:component-root": true, + "~:show-content": true, + "~:proportion-lock": true, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:page-id": "~u34c33767-b561-80aa-8007-e7024082d3b1", + "~:id": "~u2094e2d4-1854-804d-8007-e761fd24f93e", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:applied-tokens": { + "~:width": "xx.alias.icon.size.s", + "~:height": "xx.alias.icon.size.s" + }, + "~:component-id": "~u2094e2d4-1854-804d-8007-e761fd29d15c", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 214.0000008841555, + "~:main-instance": true, + "~:proportion": 0.999999999999981, + "~:selrect": { + "~#rect": { + "~:x": 214.0000008841555, + "~:y": -623.0000079548252, + "~:width": 16, + "~:height": 16.000000000000227, + "~:x1": 214.0000008841555, + "~:y1": -623.0000079548252, + "~:x2": 230.0000008841555, + "~:y2": -607.0000079548249 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 16.000000000000227, + "~:component-file": "~u3e84615b-5628-818c-8007-e7563bb081fb", + "~:flip-y": null, + "~:shapes": [ + "~u2094e2d4-1854-804d-8007-e761fd24f93f", + "~u2094e2d4-1854-804d-8007-e761fd24f940" + ] + } + }, + "~u2094e2d4-1854-804d-8007-e761fd24f93f": { + "~#shape": { + "~:y": null, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:content": { + "~#penpot/path-data": "~bAQAAAAAAAAAAAAAAAAAAAAAAAACrqmBDAEAaxAMAAACrqmBDvuEZxAN5X0NWlRnE//9dQ1aVGcQDAAAA94ZcQ1aVGcRTVVtDvuEZxFNVW0MAQBrEAwAAAFNVW0NCnhrE94ZcQ6rqGsT//11DquoaxAMAAAADeV9DquoaxKuqYENCnhrEq6pgQwBAGsQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAABTVV9DAEAaxAMAAABTVV9D3hAaxIO8XkOq6hnE//9dQ6rqGcQDAAAAe0NdQ6rqGcSrqlxD3hAaxKuqXEMAQBrEAwAAAKuqXEMgbxrEe0NdQ1aVGsT//11DVpUaxAMAAACDvF5DVpUaxFNVX0MgbxrEU1VfQwBAGsQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + "~:name": "svg-path", + "~:width": null, + "~:type": "~:path", + "~:svg-attrs": { + "~:fill-rule": "evenodd", + "~:clip-rule": "evenodd" + }, + "~:points": [ + { + "~#point": { + "~:x": 219.33333421748898, + "~:y": -619.6666746214917 + } + }, + { + "~#point": { + "~:x": 224.66666755082247, + "~:y": -619.6666746214917 + } + }, + { + "~#point": { + "~:x": 224.66666755082247, + "~:y": -614.3333412881584 + } + }, + { + "~#point": { + "~:x": 219.33333421748898, + "~:y": -614.3333412881584 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:page-id": "~u34c33767-b561-80aa-8007-e7024082d3b1", + "~:constraints-v": "~:scale", + "~:svg-transform": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + }, + "~:constraints-h": "~:scale", + "~:id": "~u2094e2d4-1854-804d-8007-e761fd24f93f", + "~:parent-id": "~u2094e2d4-1854-804d-8007-e761fd24f93e", + "~:svg-viewbox": { + "~:y": 5, + "~:y1": 5, + "~:width": 8, + "~:x": 8, + "~:x1": 8, + "~:y2": 13, + "~:x2": 16, + "~:height": 8 + }, + "~:applied-tokens": {}, + "~:svg-defs": {}, + "~:frame-id": "~u2094e2d4-1854-804d-8007-e761fd24f93e", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 1, + "~:stroke-color": "#121270", + "~:stroke-opacity": 1 + } + ], + "~:x": null, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 219.33333421748898, + "~:y": -619.6666746214919, + "~:width": 5.333333333333485, + "~:height": 5.3333333333332575, + "~:x1": 219.33333421748898, + "~:y1": -619.6666746214919, + "~:x2": 224.66666755082247, + "~:y2": -614.3333412881586 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": null, + "~:flip-y": null + } + }, + "~u2094e2d4-1854-804d-8007-e761fd24f940": { + "~#shape": { + "~:y": null, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:content": { + "~#penpot/path-data": "~bAQAAAAAAAAAAAAAAAAAAAAAAAAD//11DVpUbxAMAAAAr81lDVpUbxKuqVkM0wxrEq6pWQwDAGcQDAAAAq6pWQ8y8GMQr81lDquoXxP//XUOq6hfEAwAAAM8MYkOq6hfEU1VlQ8y8GMRTVWVDAMAZxAMAAABTVWVDNMMaxM8MYkNWlRvE//9dQ1aVG8QCAAAAAAAAAAAAAAAAAAAAAAAAAP//XUNWlRvEAQAAAAAAAAAAAAAAAAAAAAAAAAD//1dDAMAZxAMAAAD//1dD0mYZxJt5WEO8FBnEn0VZQ47TGMQDAAAAR15aQ2gvGcT3GFxDqmoZxAMLXkOqahnEAwAAAJP2X0OqahnEO6xhQ/AwGcRfxWJDHNcYxAMAAAC/imNDthcZxP//Y0NuaBnE//9jQwDAGcQDAAAA//9jQxSUGsRPUGFDAEAbxP//XUMAQBvEAwAAAK+vWkMAQBvE//9XQxSUGsT//1dDAMAZxAIAAAAAAAAAAAAAAAAAAAAAAAAA//9XQwDAGcQBAAAAAAAAAAAAAAAAAAAAAAAAAP//XUMAQBjEAwAAAAeRXEMAQBjEG0BbQyxgGMTrN1pD2pUYxAMAAADzD1tD8OIYxO91XENWFRnEAwteQ1YVGcQDAAAAB5tfQ1YVGcQn/WBDLuQYxPvVYUO2mBjEAwAAAMPLYENSYRjEb3VfQwBAGMT//11DAEAYxAIAAAAAAAAAAAAAAAAAAAAAAAAA//9dQwBAGMQ=" + }, + "~:name": "svg-path", + "~:width": null, + "~:type": "~:path", + "~:svg-attrs": { + "~:fill-rule": "evenodd", + "~:clip-rule": "evenodd" + }, + "~:points": [ + { + "~#point": { + "~:x": 214.66666755082224, + "~:y": -622.3333412881586 + } + }, + { + "~#point": { + "~:x": 229.33333421748898, + "~:y": -622.3333412881586 + } + }, + { + "~#point": { + "~:x": 229.33333421748898, + "~:y": -607.6666746214917 + } + }, + { + "~#point": { + "~:x": 214.66666755082224, + "~:y": -607.6666746214917 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:page-id": "~u34c33767-b561-80aa-8007-e7024082d3b1", + "~:constraints-v": "~:scale", + "~:svg-transform": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + }, + "~:constraints-h": "~:scale", + "~:id": "~u2094e2d4-1854-804d-8007-e761fd24f940", + "~:parent-id": "~u2094e2d4-1854-804d-8007-e761fd24f93e", + "~:svg-viewbox": { + "~:y": 1, + "~:y1": 1, + "~:width": 22, + "~:x": 1, + "~:x1": 1, + "~:y2": 23, + "~:x2": 23, + "~:height": 22 + }, + "~:applied-tokens": {}, + "~:svg-defs": {}, + "~:frame-id": "~u2094e2d4-1854-804d-8007-e761fd24f93e", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 1, + "~:stroke-color": "#121270", + "~:stroke-opacity": 1 + } + ], + "~:x": null, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 214.666667550822, + "~:y": -622.3333412881586, + "~:width": 14.666666666666742, + "~:height": 14.66666666666697, + "~:x1": 214.666667550822, + "~:y1": -622.3333412881586, + "~:x2": 229.33333421748875, + "~:y2": -607.6666746214917 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": null, + "~:flip-y": null + } + } + }, + "~:name": "profile", + "~:modified-at": "~m1776782297854", + "~:main-instance-page": "~u3e84615b-5628-818c-8007-e7563bb081fc", + "~:id": "~u2094e2d4-1854-804d-8007-e761fd29d15c" + } + } + } +} diff --git a/frontend/playwright/data/workspace/get-file-13958.json b/frontend/playwright/data/workspace/get-file-13958.json new file mode 100644 index 0000000000..77b9815d1a --- /dev/null +++ b/frontend/playwright/data/workspace/get-file-13958.json @@ -0,0 +1,4321 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "design-tokens/v1", + "variants/v1", + "layout/grid", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:team-id": "~uc6b102e2-5aaa-809c-8007-dcd1eab2135d", + "~: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": "color tokens detach (copy)", + "~:revn": 11, + "~:modified-at": "~m1778577544627", + "~:vern": 151920609, + "~:id": "~u1bac06a1-a942-80a6-8008-0222e1ec38d5", + "~: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-repair-bad-tokens" + ] + }, + "~:version": 67, + "~:project-id": "~u4cdd76d8-0e6d-8168-8008-0118118e1a1a", + "~:created-at": "~m1778507716647", + "~:backend": "legacy-db", + "~:data": { + "~:pages": [ + "~u1919607f-7059-80e9-8007-d7b6ba437f17" + ], + "~:pages-index": { + "~u1919607f-7059-80e9-8007-d7b6ba437f17": { + "~:id": "~u1919607f-7059-80e9-8007-d7b6ba437f17", + "~:name": "Page 1", + "~:objects": { + "~#penpot/objects-map/v2": { + "~ufb6e1db9-e999-8049-8007-d7b7ac11fbac": "[\"~#shape\",[\"^ \",\"~:y\",603,\"~:layout-item-hsizing\",\"fill\",\"~: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-height\",\"~:content\",[\"^ \",\"~:type\",\"root\",\"~:children\",[[\"^ \",\"^8\",\"paragraph-set\",\"^9\",[[\"^ \",\"~:line-height\",\"1.5\",\"~:path\",\"font-screen-lg / body\",\"~:font-style\",\"normal\",\"^9\",[[\"^ \",\"^:\",\"1.5\",\"^;\",\"font-screen-lg / body\",\"^<\",\"normal\",\"~:text-transform\",\"none\",\"~:text-align\",\"left\",\"~:font-id\",\"gfont-dm-sans\",\"~:text-style-id\",\"S:61089755d06cc251915221fee7a45ae2841996c6,\",\"~:font-size\",\"16\",\"~:font-weight\",\"400\",\"~:modified-at\",\"2025-01-24T18:57:32.766Z\",\"~:font-variant-id\",\"regular\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#2f2c35\",\"~:fill-opacity\",1]],\"~:font-family\",\"DM Sans\",\"~:text\",\"Design tokens are a set of codified design decisions that store raw design values as named entities, making them core pieces of a design system. \\n\"],[\"^ \",\"^:\",\"1\",\"^<\",\"normal\",\"^?\",\"sourcesanspro\",\"^A\",\"16\",\"^B\",\"400\",\"^D\",\"regular\",\"^F\",\"0\",\"^G\",[[\"^ \",\"^H\",\"#2f2c35\",\"^I\",1]],\"^K\",\"\\n\"],[\"^ \",\"^:\",\"1.5\",\"^;\",\"font-screen-lg / body\",\"^<\",\"normal\",\"^=\",\"none\",\"^>\",\"left\",\"^?\",\"gfont-dm-sans\",\"^@\",\"S:61089755d06cc251915221fee7a45ae2841996c6,\",\"^A\",\"16\",\"^B\",\"400\",\"^C\",\"2025-01-24T18:57:32.766Z\",\"^D\",\"regular\",\"^E\",\"none\",\"^F\",\"0\",\"^G\",[[\"^ \",\"^H\",\"#2f2c35\",\"^I\",1]],\"^J\",\"DM Sans\",\"^K\",\"\\n\"],[\"^ \",\"^:\",\"1\",\"^<\",\"normal\",\"^?\",\"sourcesanspro\",\"^A\",\"16\",\"^B\",\"400\",\"^D\",\"regular\",\"^F\",\"0\",\"^G\",[[\"^ \",\"^H\",\"#2f2c35\",\"^I\",1]],\"^K\",\"\\n\"],[\"^ \",\"^:\",\"1.5\",\"^;\",\"font-screen-lg / body\",\"^<\",\"normal\",\"^=\",\"none\",\"^>\",\"left\",\"^?\",\"gfont-dm-sans\",\"^@\",\"S:61089755d06cc251915221fee7a45ae2841996c6,\",\"^A\",\"16\",\"^B\",\"400\",\"^C\",\"2025-01-24T18:57:32.766Z\",\"^D\",\"regular\",\"^E\",\"none\",\"^F\",\"0\",\"^G\",[[\"^ \",\"^H\",\"#2f2c35\",\"^I\",1]],\"^J\",\"DM Sans\",\"^K\",\"They are adaptable to any platform and are used to replace hard-coded variables.\"]],\"^=\",\"none\",\"^>\",\"left\",\"^?\",\"gfont-dm-sans\",\"~:key\",\"6m627\",\"^@\",\"S:61089755d06cc251915221fee7a45ae2841996c6,\",\"^A\",\"16\",\"^B\",\"400\",\"^8\",\"paragraph\",\"^C\",\"2025-01-24T18:57:32.766Z\",\"^D\",\"regular\",\"^E\",\"none\",\"^F\",\"0\",\"^G\",[[\"^ \",\"^H\",\"#2f2c35\",\"^I\",1]],\"^J\",\"DM Sans\"]]]],\"~:vertical-align\",\"top\",\"^G\",[]],\"~:blend-mode\",\"~:normal\",\"~:name\",\"Design tokens are a set\",\"~:width\",704,\"^8\",\"^K\",\"~:svg-attrs\",[\"^ \"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",727,\"~:y\",603]],[\"^T\",[\"^ \",\"~:x\",1431,\"~:y\",603]],[\"^T\",[\"^ \",\"~:x\",1431,\"~:y\",747]],[\"^T\",[\"^ \",\"~:x\",727,\"~:y\",747]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u1919607f-7059-80e9-8007-d7b6ba437f17\",\"~:hidden\",false,\"~:opacity\",1,\"~:id\",\"~ufb6e1db9-e999-8049-8007-d7b7ac11fbac\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:applied-tokens\",[\"^ \",\"~:fill\",\"xx.alias.color.text.default\"],\"~:layout-item-vsizing\",\"auto\",\"~:position-data\",[[\"^ \",\"~:y\",625.4199829101562,\"^:\",\"1.2\",\"^<\",\"normal\",\"~:typography-ref-id\",null,\"^=\",\"none\",\"^>\",\"left\",\"^?\",\"gfont-dm-sans\",\"^A\",\"16px\",\"^B\",\"400\",\"~:typography-ref-file\",null,\"~:text-direction\",\"ltr\",\"^Q\",676.6099853515625,\"^D\",\"regular\",\"^E\",\"none\",\"^F\",\"0px\",\"~:x\",727,\"^G\",[[\"^ \",\"^H\",\"#2f2c35\",\"^I\",1]],\"~:direction\",\"ltr\",\"^J\",\"DM Sans\",\"~:height\",20.8399658203125,\"^K\",\"Design tokens are a set of codified design decisions that store raw design values as named \"],[\"^ \",\"~:y\",649.4199829101562,\"^:\",\"1.2\",\"^<\",\"normal\",\"^14\",null,\"^=\",\"none\",\"^>\",\"left\",\"^?\",\"gfont-dm-sans\",\"^A\",\"16px\",\"^B\",\"400\",\"^15\",null,\"^16\",\"ltr\",\"^Q\",400.2900390625,\"^D\",\"regular\",\"^E\",\"none\",\"^F\",\"0px\",\"~:x\",727,\"^G\",[[\"^ \",\"^H\",\"#2f2c35\",\"^I\",1]],\"^17\",\"ltr\",\"^J\",\"DM Sans\",\"^18\",20.8399658203125,\"^K\",\"entities, making them core pieces of a design system. \"],[\"^ \",\"~:y\",673.3599853515625,\"^:\",\"1.2\",\"^<\",\"normal\",\"^14\",null,\"^=\",\"none\",\"^>\",\"left\",\"^?\",\"sourcesanspro\",\"^A\",\"16px\",\"^B\",\"400\",\"^15\",null,\"^16\",\"ltr\",\"^Q\",0,\"^D\",\"regular\",\"^E\",\"none\",\"^F\",\"0px\",\"~:x\",727,\"^G\",[[\"^ \",\"^H\",\"#2f2c35\",\"^I\",1]],\"^17\",\"ltr\",\"^J\",\"sourcesanspro\",\"^18\",20.719970703125,\"^K\",\"\"],[\"^ \",\"~:y\",697.4199829101562,\"^:\",\"1.2\",\"^<\",\"normal\",\"^14\",null,\"^=\",\"none\",\"^>\",\"left\",\"^?\",\"gfont-dm-sans\",\"^A\",\"16px\",\"^B\",\"400\",\"^15\",null,\"^16\",\"ltr\",\"^Q\",0,\"^D\",\"regular\",\"^E\",\"none\",\"^F\",\"0px\",\"~:x\",727,\"^G\",[[\"^ \",\"^H\",\"#2f2c35\",\"^I\",1]],\"^17\",\"ltr\",\"^J\",\"DM Sans\",\"^18\",20.8399658203125,\"^K\",\"\"],[\"^ \",\"~:y\",721.3599853515625,\"^:\",\"1.2\",\"^<\",\"normal\",\"^14\",null,\"^=\",\"none\",\"^>\",\"left\",\"^?\",\"sourcesanspro\",\"^A\",\"16px\",\"^B\",\"400\",\"^15\",null,\"^16\",\"ltr\",\"^Q\",0,\"^D\",\"regular\",\"^E\",\"none\",\"^F\",\"0px\",\"~:x\",727,\"^G\",[[\"^ \",\"^H\",\"#2f2c35\",\"^I\",1]],\"^17\",\"ltr\",\"^J\",\"sourcesanspro\",\"^18\",20.719970703125,\"^K\",\"\"],[\"^ \",\"~:y\",745.4199829101562,\"^:\",\"1.2\",\"^<\",\"normal\",\"^14\",null,\"^=\",\"none\",\"^>\",\"left\",\"^?\",\"gfont-dm-sans\",\"^A\",\"16px\",\"^B\",\"400\",\"^15\",null,\"^16\",\"ltr\",\"^Q\",607.7900390625,\"^D\",\"regular\",\"^E\",\"none\",\"^F\",\"0px\",\"~:x\",727,\"^G\",[[\"^ \",\"^H\",\"#2f2c35\",\"^I\",1]],\"^17\",\"ltr\",\"^J\",\"DM Sans\",\"^18\",20.8399658203125,\"^K\",\"They are adaptable to any platform and are used to replace hard-coded variables.\"]],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",727,\"~:blocked\",false,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",727,\"~:y\",603,\"^Q\",704,\"^18\",144,\"~:x1\",727,\"~:y1\",603,\"~:x2\",1431,\"~:y2\",747]],\"^G\",[],\"~:flip-x\",null,\"^18\",144,\"~:flip-y\",null]]", + "~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]],\"~:page-id\",\"~u1919607f-7059-80e9-8007-d7b6ba437f17\",\"~: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,\"^I\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~ufb6e1db9-e999-8049-8007-d7b7ac11fbac\"]]]" + } + } + } + }, + "~:tokens-lib": { + "~#penpot/tokens-lib": { + "~:sets": { + "~#ordered-map": [ + [ + "G-global", + { + "~#ordered-map": [ + [ + "S-color", + { + "~#penpot/token-set": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0cf6d3", + "~:name": "global/color", + "~:description": "", + "~:modified-at": "~m1778507716659", + "~:tokens": { + "~#ordered-map": [ + [ + "xx.global.color.mint.500", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dcb", + "~:name": "xx.global.color.mint.500", + "~:type": "~:color", + "~:value": "#188675", + "~:description": "global color mint of weight 500", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.violet.200", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca44a", + "~:name": "xx.global.color.violet.200", + "~:type": "~:color", + "~:value": "#D0D2F3", + "~:description": "global color violet of weight 200", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.neutral.300", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dba", + "~:name": "xx.global.color.neutral.300", + "~:type": "~:color", + "~:value": "#DCDCDD", + "~:description": "global color neutral of weight 300", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.mint.600", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dc5", + "~:name": "xx.global.color.mint.600", + "~:type": "~:color", + "~:value": "#1A6A5D", + "~:description": "global color mint of weight 600", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.neutral.50", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4db8", + "~:name": "xx.global.color.neutral.50", + "~:type": "~:color", + "~:value": "#FCFCFC", + "~:description": "global color neutral of weight 50", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.orange.500", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dd5", + "~:name": "xx.global.color.orange.500", + "~:type": "~:color", + "~:value": "#C07B4B", + "~:description": "global color orange of weight 500", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.neutral.600", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4db9", + "~:name": "xx.global.color.neutral.600", + "~:type": "~:color", + "~:value": "#747279", + "~:description": "global color neutral of weight 600", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.blue.200", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4db4", + "~:name": "xx.global.color.blue.200", + "~:type": "~:color", + "~:value": "#B1DBEF", + "~:description": "global color blue of weight 200", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.red.400", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca466", + "~:name": "xx.global.color.red.400", + "~:type": "~:color", + "~:value": "#F37A73", + "~:description": "global color red of weight 400", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.blue.50", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dae", + "~:name": "xx.global.color.blue.50", + "~:type": "~:color", + "~:value": "#F6FBFD", + "~:description": "global color blue of weight 50", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.violet.800", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca44d", + "~:name": "xx.global.color.violet.800", + "~:type": "~:color", + "~:value": "#39345A", + "~:description": "global color violet of weight 800", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.green.200", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca45e", + "~:name": "xx.global.color.green.200", + "~:type": "~:color", + "~:value": "#B0E1AA", + "~:description": "global color green of weight 200", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.orange.800", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dd7", + "~:name": "xx.global.color.orange.800", + "~:type": "~:color", + "~:value": "#6B432B", + "~:description": "global color orange of weight 800", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.violet.500", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca44b", + "~:name": "xx.global.color.violet.500", + "~:type": "~:color", + "~:value": "#686FC8", + "~:description": "global color violet of weight 500", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.blue.700", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4db1", + "~:name": "xx.global.color.blue.700", + "~:type": "~:color", + "~:value": "#006596", + "~:description": "global color blue of weight 700", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.blue.900", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4db6", + "~:name": "xx.global.color.blue.900", + "~:type": "~:color", + "~:value": "#003C5A", + "~:description": "global color blue of weight 900", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.green.50", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca458", + "~:name": "xx.global.color.green.50", + "~:type": "~:color", + "~:value": "#f7fbeb", + "~:description": "global color green of weight 50", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.red.600", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca463", + "~:name": "xx.global.color.red.600", + "~:type": "~:color", + "~:value": "#934846", + "~:description": "global color red of weight 600", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.yellow.300", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca450", + "~:name": "xx.global.color.yellow.300", + "~:type": "~:color", + "~:value": "#D4BB5F", + "~:description": "global color yellow of weight 300", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.orange.200", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dd4", + "~:name": "xx.global.color.orange.200", + "~:type": "~:color", + "~:value": "#F9CCA9", + "~:description": "global color orange of weight 200", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.violet.700", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca447", + "~:name": "xx.global.color.violet.700", + "~:type": "~:color", + "~:value": "#49457A", + "~:description": "global color violet of weight 700", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.violet.50", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca444", + "~:name": "xx.global.color.violet.50", + "~:type": "~:color", + "~:value": "#FCFCFE", + "~:description": "global color violet of weight 50", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.yellow.500", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca455", + "~:name": "xx.global.color.yellow.500", + "~:type": "~:color", + "~:value": "#84773F", + "~:description": "global color yellow of weight 500", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.yellow.50", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca44e", + "~:name": "xx.global.color.yellow.50", + "~:type": "~:color", + "~:value": "#FFFCF5", + "~:description": "global color yellow of weight 50", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.violet.400", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca448", + "~:name": "xx.global.color.violet.400", + "~:type": "~:color", + "~:value": "#9598E0", + "~:description": "global color violet of weight 400", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.red.300", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca464", + "~:name": "xx.global.color.red.300", + "~:type": "~:color", + "~:value": "#FCA69C", + "~:description": "global color red of weight 300", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.orange.700", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dd1", + "~:name": "xx.global.color.orange.700", + "~:type": "~:color", + "~:value": "#855435", + "~:description": "global color orange of weight 700", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.green.400", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca45c", + "~:name": "xx.global.color.green.400", + "~:type": "~:color", + "~:value": "#69AD65", + "~:description": "global color green of weight 400", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.blue.300", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4db0", + "~:name": "xx.global.color.blue.300", + "~:type": "~:color", + "~:value": "#84C5E6", + "~:description": "global color blue of weight 300", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.blue.100", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4db3", + "~:name": "xx.global.color.blue.100", + "~:type": "~:color", + "~:value": "#E0F0F9", + "~:description": "global color blue of weight 100", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.orange.600", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dcf", + "~:name": "xx.global.color.orange.600", + "~:type": "~:color", + "~:value": "#9D633E", + "~:description": "global color orange of weight 600", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.yellow.200", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca454", + "~:name": "xx.global.color.yellow.200", + "~:type": "~:color", + "~:value": "#F0D36B", + "~:description": "global color yellow of weight 200", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.red.50", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca462", + "~:name": "xx.global.color.red.50", + "~:type": "~:color", + "~:value": "#FFFBFB", + "~:description": "global color red of weight 50", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.neutral.500", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dbf", + "~:name": "xx.global.color.neutral.500", + "~:type": "~:color", + "~:value": "#8B898F", + "~:description": "global color neutral of weight 500", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.green.300", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca45a", + "~:name": "xx.global.color.green.300", + "~:type": "~:color", + "~:value": "#7FCE7A", + "~:description": "global color green of weight 300", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.green.500", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca45f", + "~:name": "xx.global.color.green.500", + "~:type": "~:color", + "~:value": "#51834E", + "~:description": "global color green of weight 500", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.red.200", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca468", + "~:name": "xx.global.color.red.200", + "~:type": "~:color", + "~:value": "#FFC7BF", + "~:description": "global color red of weight 200", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.yellow.700", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca451", + "~:name": "xx.global.color.yellow.700", + "~:type": "~:color", + "~:value": "#534B2B", + "~:description": "global color yellow of weight 700", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.blue.800", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4db7", + "~:name": "xx.global.color.blue.800", + "~:type": "~:color", + "~:value": "#005179", + "~:description": "global color blue of weight 800", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.red.500", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca469", + "~:name": "xx.global.color.red.500", + "~:type": "~:color", + "~:value": "#BA5A56", + "~:description": "global color red of weight 500", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.blue.600", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4daf", + "~:name": "xx.global.color.blue.600", + "~:type": "~:color", + "~:value": "#0077B2", + "~:description": "global color blue of weight 600", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.yellow.600", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca44f", + "~:name": "xx.global.color.yellow.600", + "~:type": "~:color", + "~:value": "#685E34", + "~:description": "global color yellow of weight 600", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.yellow.100", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca453", + "~:name": "xx.global.color.yellow.100", + "~:type": "~:color", + "~:value": "#FDECC3", + "~:description": "global color yellow of weight 100", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.red.700", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca465", + "~:name": "xx.global.color.red.700", + "~:type": "~:color", + "~:value": "#743A3A", + "~:description": "global color red of weight 700", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.violet.300", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca446", + "~:name": "xx.global.color.violet.300", + "~:type": "~:color", + "~:value": "#B6B8EB", + "~:description": "global color violet of weight 300", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.orange.900", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dd6", + "~:name": "xx.global.color.orange.900", + "~:type": "~:color", + "~:value": "#4F3120", + "~:description": "global color orange of weight 900", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.mint.50", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dc4", + "~:name": "xx.global.color.mint.50", + "~:type": "~:color", + "~:value": "#F6FEFB", + "~:description": "global color mint of weight 50", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.yellow.800", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca457", + "~:name": "xx.global.color.yellow.800", + "~:type": "~:color", + "~:value": "#3E3922", + "~:description": "global color yellow of weight 800", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.black", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca46c", + "~:name": "xx.global.color.black", + "~:type": "~:color", + "~:value": "#000000", + "~:description": "", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.blue.500", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4db5", + "~:name": "xx.global.color.blue.500", + "~:type": "~:color", + "~:value": "#1993D0", + "~:description": "global color blue of weight 500", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.mint.800", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dcd", + "~:name": "xx.global.color.mint.800", + "~:type": "~:color", + "~:value": "#174039", + "~:description": "global color mint of weight 800", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.neutral.950", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dc2", + "~:name": "xx.global.color.neutral.950", + "~:type": "~:color", + "~:value": "#1C1C1E", + "~:description": "global color neutral of weight 950", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.violet.600", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca445", + "~:name": "xx.global.color.violet.600", + "~:type": "~:color", + "~:value": "#57579C", + "~:description": "global color violet of weight 600", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.neutral.200", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dbe", + "~:name": "xx.global.color.neutral.200", + "~:type": "~:color", + "~:value": "#F3F3F4", + "~:description": "global color neutral of weight 200", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.neutral.800", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dc1", + "~:name": "xx.global.color.neutral.800", + "~:type": "~:color", + "~:value": "#3B3741", + "~:description": "global color neutral of weight 800", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.red.900", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca46a", + "~:name": "xx.global.color.red.900", + "~:type": "~:color", + "~:value": "#3B2020", + "~:description": "global color red of weight 900", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.yellow.900", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca456", + "~:name": "xx.global.color.yellow.900", + "~:type": "~:color", + "~:value": "#2B2819", + "~:description": "global color yellow of weight 900", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.orange.100", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dd3", + "~:name": "xx.global.color.orange.100", + "~:type": "~:color", + "~:value": "#FCEBDC", + "~:description": "global color orange of weight 100", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.red.100", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca467", + "~:name": "xx.global.color.red.100", + "~:type": "~:color", + "~:value": "#FFE9E5", + "~:description": "global color red of weight 100", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.neutral.400", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dbc", + "~:name": "xx.global.color.neutral.400", + "~:type": "~:color", + "~:value": "#AEADB1", + "~:description": "global color neutral of weight 400", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.red.800", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca46b", + "~:name": "xx.global.color.red.800", + "~:type": "~:color", + "~:value": "#572D2D", + "~:description": "global color red of weight 800", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.orange.50", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dce", + "~:name": "xx.global.color.orange.50", + "~:type": "~:color", + "~:value": "#FEF9F5", + "~:description": "global color orange of weight 50", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.mint.200", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dca", + "~:name": "xx.global.color.mint.200", + "~:type": "~:color", + "~:value": "#55EBCE", + "~:description": "global color mint of weight 200", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.mint.900", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dcc", + "~:name": "xx.global.color.mint.900", + "~:type": "~:color", + "~:value": "#142C28", + "~:description": "global color mint of weight 900", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.mint.300", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dc6", + "~:name": "xx.global.color.mint.300", + "~:type": "~:color", + "~:value": "#00D4B6", + "~:description": "global color mint of weight 300", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.green.800", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca461", + "~:name": "xx.global.color.green.800", + "~:type": "~:color", + "~:value": "#2A3E28", + "~:description": "global color green of weight 800", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.green.600", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca459", + "~:name": "xx.global.color.green.600", + "~:type": "~:color", + "~:value": "#42673F", + "~:description": "global color green of weight 600", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.green.700", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca45b", + "~:name": "xx.global.color.green.700", + "~:type": "~:color", + "~:value": "#365233", + "~:description": "global color green of weight 700", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.green.100", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca45d", + "~:name": "xx.global.color.green.100", + "~:type": "~:color", + "~:value": "#DFF3DC", + "~:description": "global color green of weight 100", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.neutral.900", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dc0", + "~:name": "xx.global.color.neutral.900", + "~:type": "~:color", + "~:value": "#2F2C35", + "~:description": "global color neutral of weight 900", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.neutral.700", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dbb", + "~:name": "xx.global.color.neutral.700", + "~:type": "~:color", + "~:value": "#49454E", + "~:description": "global color neutral of weight 700", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.orange.400", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dd2", + "~:name": "xx.global.color.orange.400", + "~:type": "~:color", + "~:value": "#E39258", + "~:description": "global color orange of weight 400", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.mint.400", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dc8", + "~:name": "xx.global.color.mint.400", + "~:type": "~:color", + "~:value": "#0AB29A", + "~:description": "global color mint of weight 400", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.mint.700", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dc7", + "~:name": "xx.global.color.mint.700", + "~:type": "~:color", + "~:value": "#19544B", + "~:description": "global color mint of weight 700", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.yellow.400", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca452", + "~:name": "xx.global.color.yellow.400", + "~:type": "~:color", + "~:value": "#B19D51", + "~:description": "global color yellow of weight 400", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.neutral.100", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dbd", + "~:name": "xx.global.color.neutral.100", + "~:type": "~:color", + "~:value": "#FAFAFB", + "~:description": "global color neutral of weight 100", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.violet.100", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca449", + "~:name": "xx.global.color.violet.100", + "~:type": "~:color", + "~:value": "#ECEDFA", + "~:description": "global color violet of weight 100", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.orange.300", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dd0", + "~:name": "xx.global.color.orange.300", + "~:type": "~:color", + "~:value": "#F6AD76", + "~:description": "global color orange of weight 300", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.white", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dc3", + "~:name": "xx.global.color.white", + "~:type": "~:color", + "~:value": "#ffffff", + "~:description": "", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.mint.100", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dc9", + "~:name": "xx.global.color.mint.100", + "~:type": "~:color", + "~:value": "#C5F8EA", + "~:description": "global color mint of weight 100", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.blue.400", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4db2", + "~:name": "xx.global.color.blue.400", + "~:type": "~:color", + "~:value": "#53AFDC", + "~:description": "global color blue of weight 400", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.color.violet.900", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca44c", + "~:name": "xx.global.color.violet.900", + "~:type": "~:color", + "~:value": "#29253C", + "~:description": "global color violet of weight 900", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.global.color.green.900", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca460", + "~:name": "xx.global.color.green.900", + "~:type": "~:color", + "~:value": "#1E2B1D", + "~:description": "global color green of weight 900", + "~:modified-at": "~m1778507716658" + } + } + ] + ] + } + } + } + ], + [ + "S-dimension", + { + "~#penpot/token-set": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0e2151", + "~:name": "global/dimension", + "~:description": "", + "~:modified-at": "~m1778507716664", + "~:tokens": { + "~#ordered-map": [ + [ + "xx.global.dimensions.13", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4da8", + "~:name": "xx.global.dimensions.13", + "~:type": "~:dimensions", + "~:value": "64px", + "~:description": "13th step of global dimensions sequence", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.dimensions.11", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dab", + "~:name": "xx.global.dimensions.11", + "~:type": "~:dimensions", + "~:value": "52px", + "~:description": "11th step of global dimensions sequence", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.dimensions.14", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4da3", + "~:name": "xx.global.dimensions.14", + "~:type": "~:dimensions", + "~:value": "72px", + "~:description": "14th step of global dimensions sequence", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.dimensions.10", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dad", + "~:name": "xx.global.dimensions.10", + "~:type": "~:dimensions", + "~:value": "48px", + "~:description": "10th step of global dimensions sequence", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.dimensions.6", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4da9", + "~:name": "xx.global.dimensions.6", + "~:type": "~:dimensions", + "~:value": "20px", + "~:description": "6th step of global dimensions sequence", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.dimensions.3", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4da0", + "~:name": "xx.global.dimensions.3", + "~:type": "~:dimensions", + "~:value": "8px", + "~:description": "3rd step of global dimensions sequence", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.dimensions.2", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4dac", + "~:name": "xx.global.dimensions.2", + "~:type": "~:dimensions", + "~:value": "4px", + "~:description": "2nd step of global dimensions sequence", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.dimensions.12", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4da7", + "~:name": "xx.global.dimensions.12", + "~:type": "~:dimensions", + "~:value": "60px", + "~:description": "12th step of global dimensions sequence", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.dimensions.8", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4da2", + "~:name": "xx.global.dimensions.8", + "~:type": "~:dimensions", + "~:value": "32px", + "~:description": "8th step of global dimensions sequence", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.dimensions.7", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4da5", + "~:name": "xx.global.dimensions.7", + "~:type": "~:dimensions", + "~:value": "24px", + "~:description": "7th step of global dimensions sequence", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.dimensions.15", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4da4", + "~:name": "xx.global.dimensions.15", + "~:type": "~:dimensions", + "~:value": "80px", + "~:description": "15th step of global dimensions sequence", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.dimensions.4", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4da1", + "~:name": "xx.global.dimensions.4", + "~:type": "~:dimensions", + "~:value": "12px", + "~:description": "4th step of global dimensions sequence", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.dimensions.9", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d9f", + "~:name": "xx.global.dimensions.9", + "~:type": "~:dimensions", + "~:value": "44px", + "~:description": "9th step of global dimensions sequence", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.dimensions.1", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4daa", + "~:name": "xx.global.dimensions.1", + "~:type": "~:dimensions", + "~:value": "2px", + "~:description": "1st step of global dimensions sequence", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.global.dimensions.5", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4da6", + "~:name": "xx.global.dimensions.5", + "~:type": "~:dimensions", + "~:value": "16px", + "~:description": "5th step of global dimensions sequence", + "~:modified-at": "~m1778507716657" + } + } + ] + ] + } + } + } + ], + [ + "S-Opacity", + { + "~#penpot/token-set": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0e7da8", + "~:name": "global/Opacity", + "~:description": "", + "~:modified-at": "~m1778507716665", + "~:tokens": { + "~#ordered-map": [ + [ + "opacity.0", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca494", + "~:name": "opacity.0", + "~:type": "~:opacity", + "~:value": "0", + "~:description": "", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "opacity.10", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca495", + "~:name": "opacity.10", + "~:type": "~:opacity", + "~:value": "0.1", + "~:description": "", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "opacity.50", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca496", + "~:name": "opacity.50", + "~:type": "~:opacity", + "~:value": "0.5", + "~:description": "", + "~:modified-at": "~m1778507716658" + } + } + ] + ] + } + } + } + ] + ] + } + ], + [ + "G-alias", + { + "~#ordered-map": [ + [ + "S-border", + { + "~#penpot/token-set": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ece1d", + "~:name": "alias/border", + "~:description": "", + "~:modified-at": "~m1778507716667", + "~:tokens": { + "~#ordered-map": [ + [ + "xx.alias.border.radius.sm", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c38b0", + "~:name": "xx.alias.border.radius.sm", + "~:type": "~:border-radius", + "~:value": "{xx.global.dimensions.2}", + "~:description": "small border radius", + "~:modified-at": "~m1778507716656" + } + } + ], + [ + "xx.alias.border.radius.md", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c38b1", + "~:name": "xx.alias.border.radius.md", + "~:type": "~:border-radius", + "~:value": "{xx.global.dimensions.3}", + "~:description": "medium border radius", + "~:modified-at": "~m1778507716656" + } + } + ], + [ + "xx.alias.border.radius.lg", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c38b2", + "~:name": "xx.alias.border.radius.lg", + "~:type": "~:border-radius", + "~:value": "{xx.global.dimensions.5}", + "~:description": "large border radius", + "~:modified-at": "~m1778507716656" + } + } + ], + [ + "xx.alias.border.radius.round", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c38b3", + "~:name": "xx.alias.border.radius.round", + "~:type": "~:border-radius", + "~:value": "100%", + "~:description": "", + "~:modified-at": "~m1778507716656" + } + } + ], + [ + "stroke.outline", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c38b4", + "~:name": "stroke.outline", + "~:type": "~:stroke-width", + "~:value": "{xx.global.dimensions.1}", + "~:description": "", + "~:modified-at": "~m1778507716656" + } + } + ], + [ + "stroke.medium", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c38b5", + "~:name": "stroke.medium", + "~:type": "~:stroke-width", + "~:value": "3", + "~:description": "", + "~:modified-at": "~m1778507716656" + } + } + ], + [ + "stroke.small", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c38b6", + "~:name": "stroke.small", + "~:type": "~:stroke-width", + "~:value": "1", + "~:description": "", + "~:modified-at": "~m1778507716656" + } + } + ], + [ + "stroke.large-test", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c38b7", + "~:name": "stroke.large-test", + "~:type": "~:stroke-width", + "~:value": "25", + "~:description": "", + "~:modified-at": "~m1778507716656" + } + } + ] + ] + } + } + } + ], + [ + "S-dimension", + { + "~#penpot/token-set": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f101a3f", + "~:name": "alias/dimension", + "~:description": "", + "~:modified-at": "~m1778507716672", + "~:tokens": { + "~#ordered-map": [ + [ + "xx.alias.size.xs", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca499", + "~:name": "xx.alias.size.xs", + "~:type": "~:sizing", + "~:value": "{xx.global.dimensions.6}", + "~:description": "extra small size unit", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "rotation.45", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0cf6d1", + "~:name": "rotation.45", + "~:type": "~:rotation", + "~:value": "45", + "~:description": "", + "~:modified-at": "~m1778507716659" + } + } + ], + [ + "xx.alias.spacing.lg", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca4a7", + "~:name": "xx.alias.spacing.lg", + "~:type": "~:spacing", + "~:value": "{xx.global.dimensions.7}", + "~:description": "large spacing unit", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "dimension.top.position", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0cf6d2", + "~:name": "dimension.top.position", + "~:type": "~:dimensions", + "~:value": "{xx.global.dimensions.7}", + "~:description": "", + "~:modified-at": "~m1778507716659" + } + } + ], + [ + "xx.alias.icon.size.m", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0cf6ce", + "~:name": "xx.alias.icon.size.m", + "~:type": "~:sizing", + "~:value": "24", + "~:description": "", + "~:modified-at": "~m1778507716659" + } + } + ], + [ + "xx.alias.spacing.xxxl", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca4ab", + "~:name": "xx.alias.spacing.xxxl", + "~:type": "~:spacing", + "~:value": "{xx.global.dimensions.13}", + "~:description": "extra extra extra small spacing unit", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.size.lg", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca49d", + "~:name": "xx.alias.size.lg", + "~:type": "~:sizing", + "~:value": "{xx.global.dimensions.10}", + "~:description": "large size unit", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.spacing.xxxs", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca4a9", + "~:name": "xx.alias.spacing.xxxs", + "~:type": "~:spacing", + "~:value": "{xx.global.dimensions.1}", + "~:description": "extra extra extra small spacing unit", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.size.xxl", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca4a3", + "~:name": "xx.alias.size.xxl", + "~:type": "~:sizing", + "~:value": "{xx.global.dimensions.13}", + "~:description": "extra extra large size unit", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.size.xxxxl", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca49c", + "~:name": "xx.alias.size.xxxxl", + "~:type": "~:sizing", + "~:value": "{xx.global.dimensions.15}", + "~:description": "extra extra extra large size unit", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.spacing.xxs", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca4a8", + "~:name": "xx.alias.spacing.xxs", + "~:type": "~:spacing", + "~:value": "{xx.global.dimensions.2}", + "~:description": "extra extra small spacing unit", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.icon.size.xs", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0cf6cf", + "~:name": "xx.alias.icon.size.xs", + "~:type": "~:sizing", + "~:value": "12", + "~:description": "", + "~:modified-at": "~m1778507716659" + } + } + ], + [ + "xx.alias.size.xxs", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca49e", + "~:name": "xx.alias.size.xxs", + "~:type": "~:sizing", + "~:value": "{xx.global.dimensions.5}", + "~:description": "extra extra small size unit", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.spacing.sm", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca4ad", + "~:name": "xx.alias.spacing.sm", + "~:type": "~:spacing", + "~:value": "{xx.global.dimensions.4}", + "~:description": "small spacing unit", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.size.sm", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca4a4", + "~:name": "xx.alias.size.sm", + "~:type": "~:sizing", + "~:value": "{xx.global.dimensions.7}", + "~:description": "small size unit", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.icon.size.s", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0cf6d0", + "~:name": "xx.alias.icon.size.s", + "~:type": "~:sizing", + "~:value": "16", + "~:description": "", + "~:modified-at": "~m1778507716659" + } + } + ], + [ + "xx.alias.size.xl", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca4a1", + "~:name": "xx.alias.size.xl", + "~:type": "~:sizing", + "~:value": "{xx.global.dimensions.11}", + "~:description": "extra large size unit", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.spacing.xxl", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca4ac", + "~:name": "xx.alias.spacing.xxl", + "~:type": "~:spacing", + "~:value": "{xx.global.dimensions.10}", + "~:description": "extra extra small spacing unit", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.size.xxxxs", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca4a0", + "~:name": "xx.alias.size.xxxxs", + "~:type": "~:sizing", + "~:value": "{xx.global.dimensions.3}", + "~:description": "extra extra extra small size unit", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.size.xxxs", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca49f", + "~:name": "xx.alias.size.xxxs", + "~:type": "~:sizing", + "~:value": "{xx.global.dimensions.4}", + "~:description": "extra extra extra small size unit", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.spacing.xl", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca4aa", + "~:name": "xx.alias.spacing.xl", + "~:type": "~:spacing", + "~:value": "{xx.global.dimensions.8}", + "~:description": "extra large spacing unit", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.size.md", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca49a", + "~:name": "xx.alias.size.md", + "~:type": "~:sizing", + "~:value": "{xx.global.dimensions.8}", + "~:description": "medium size unit", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.spacing.md", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca4a6", + "~:name": "xx.alias.spacing.md", + "~:type": "~:spacing", + "~:value": "{xx.global.dimensions.5}", + "~:description": "medium spacing unit", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.size.xxxxxs", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca49b", + "~:name": "xx.alias.size.xxxxxs", + "~:type": "~:sizing", + "~:value": "{xx.global.dimensions.2}", + "~:description": "extra extra extra small size unit", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.size.xxxl", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca4a2", + "~:name": "xx.alias.size.xxxl", + "~:type": "~:sizing", + "~:value": "{xx.global.dimensions.14}", + "~:description": "extra extra extra large size unit", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.spacing.xs", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca4a5", + "~:name": "xx.alias.spacing.xs", + "~:type": "~:spacing", + "~:value": "{xx.global.dimensions.3}", + "~:description": "extra small spacing unit", + "~:modified-at": "~m1778507716658" + } + } + ] + ] + } + } + } + ], + [ + "S-color-dark", + { + "~#penpot/token-set": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f12beff", + "~:name": "alias/color-dark", + "~:description": "", + "~:modified-at": "~m1778507716682", + "~:tokens": { + "~#ordered-map": [ + [ + "xx.alias.color.purpose.onInfo", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d7e", + "~:name": "xx.alias.color.purpose.onInfo", + "~:type": "~:color", + "~:value": "{xx.global.color.blue.800}", + "~:description": "color for elements placed on informational purpose background", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.border.heavy", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d85", + "~:name": "xx.alias.color.border.heavy", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.200}", + "~:description": "heavy border color", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.state.select", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d8d", + "~:name": "xx.alias.color.state.select", + "~:type": "~:color", + "~:value": "{xx.global.color.mint.100}", + "~:description": "background color for selected elements", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.primary.onBrand", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d92", + "~:name": "xx.alias.color.primary.onBrand", + "~:type": "~:color", + "~:value": "{xx.global.color.violet.800}", + "~:description": "color for elements that are placed on brand background color", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.state.focus", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d8a", + "~:name": "xx.alias.color.state.focus", + "~:type": "~:color", + "~:value": "rgba({xx.global.color.neutral.100},0.16)", + "~:description": "Background color for state overlay of a focused element", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.state.press", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d8c", + "~:name": "xx.alias.color.state.press", + "~:type": "~:color", + "~:value": "rgba({xx.global.color.neutral.100},0.8)", + "~:description": "Background color for state overlay of a pressed element", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.purpose.warningHighEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d83", + "~:name": "xx.alias.color.purpose.warningHighEmphasis", + "~:type": "~:color", + "~:value": "{xx.global.color.yellow.100}", + "~:description": "color for elements with warning purpose of high emphasis", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.state.onSelect", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d91", + "~:name": "xx.alias.color.state.onSelect", + "~:type": "~:color", + "~:value": "{xx.global.color.mint.600}", + "~:description": "Foreground color for selected elements", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.primary.interaction", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d93", + "~:name": "xx.alias.color.primary.interaction", + "~:type": "~:color", + "~:value": "{xx.global.color.violet.600}", + "~:description": "Color that indicates interactions", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.border.focusOutline", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d86", + "~:name": "xx.alias.color.border.focusOutline", + "~:type": "~:color", + "~:value": "rgba({xx.global.color.blue.400},0.5)", + "~:description": "color for default focus outline", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.state.disable", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d8e", + "~:name": "xx.alias.color.state.disable", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.300}", + "~:description": "background color for disabled elements", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.purpose.infoHighEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d84", + "~:name": "xx.alias.color.purpose.infoHighEmphasis", + "~:type": "~:color", + "~:value": "{xx.global.color.blue.200}", + "~:description": "color for elements with informational purpose of high emphasis", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.purpose.infoLowEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d79", + "~:name": "xx.alias.color.purpose.infoLowEmphasis", + "~:type": "~:color", + "~:value": "{xx.global.color.blue.400}", + "~:description": "color for elements with informational purpose of low emphasis", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.primary.brand", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d94", + "~:name": "xx.alias.color.primary.brand", + "~:type": "~:color", + "~:value": "{xx.global.color.violet.400}", + "~:description": "background color for primary brand elements ", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.text.default", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d96", + "~:name": "xx.alias.color.text.default", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.50}", + "~:description": "Default text color ", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.text.inverted", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d97", + "~:name": "xx.alias.color.text.inverted", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.900}", + "~:description": "Inverted text color", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.purpose.onSuccess", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d81", + "~:name": "xx.alias.color.purpose.onSuccess", + "~:type": "~:color", + "~:value": "{xx.global.color.green.800}", + "~:description": "color for elements placed on success purpose background", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.purpose.criticalLowEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d7f", + "~:name": "xx.alias.color.purpose.criticalLowEmphasis", + "~:type": "~:color", + "~:value": "{xx.global.color.red.400}", + "~:description": "color for elements with critical purpose of low emphasis", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.background.mediumEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d9b", + "~:name": "xx.alias.color.background.mediumEmphasis", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.800}", + "~:description": "Background Color for components with medium emphasis", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.text.emphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d98", + "~:name": "xx.alias.color.text.emphasis", + "~:type": "~:color", + "~:value": "{xx.global.color.white}", + "~:description": "Default text color ", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.background.highEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d9c", + "~:name": "xx.alias.color.background.highEmphasis", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.700}", + "~:description": "Background Color for components with high emphasis", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.state.hover", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d90", + "~:name": "xx.alias.color.state.hover", + "~:type": "~:color", + "~:value": "rgba({xx.global.color.neutral.100},0.16)", + "~:description": "Background color for state overlay of a hovered element", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.background.body", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d9d", + "~:name": "xx.alias.color.background.body", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.950}", + "~:description": "Body background color", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.purpose.successLowEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d7d", + "~:name": "xx.alias.color.purpose.successLowEmphasis", + "~:type": "~:color", + "~:value": "{xx.global.color.green.400}", + "~:description": "color for elements with success purpose of low emphasis", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.primary.onInteraction", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d95", + "~:name": "xx.alias.color.primary.onInteraction", + "~:type": "~:color", + "~:value": "{xx.global.color.violet.900}", + "~:description": "color for elements that are placed on interactive background colors", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.purpose.onCritical", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d7a", + "~:name": "xx.alias.color.purpose.onCritical", + "~:type": "~:color", + "~:value": "{xx.global.color.red.900}", + "~:description": "color for elements placed on critical purpose background", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.text.medium", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d99", + "~:name": "xx.alias.color.text.medium", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.600}", + "~:description": "Text color in subtle weight", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.purpose.criticalHighEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d7b", + "~:name": "xx.alias.color.purpose.criticalHighEmphasis", + "~:type": "~:color", + "~:value": "{xx.global.color.red.200}", + "~:description": "color for elements with critical purpose of high emphasis", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.border.medium", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d87", + "~:name": "xx.alias.color.border.medium", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.500}", + "~:description": "medium border color", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.border.subtle", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d88", + "~:name": "xx.alias.color.border.subtle", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.700}", + "~:description": "subtle border color", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.background.lowEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d9e", + "~:name": "xx.alias.color.background.lowEmphasis", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.900}", + "~:description": "background color for elements with a low emphasis", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.purpose.onWarning", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d80", + "~:name": "xx.alias.color.purpose.onWarning", + "~:type": "~:color", + "~:value": "{xx.global.color.yellow.800}", + "~:description": "color for elements placed on warning purpose background", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.text.subtle", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d9a", + "~:name": "xx.alias.color.text.subtle", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.400}", + "~:description": "Text color in subtle weight", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.purpose.successHighEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d7c", + "~:name": "xx.alias.color.purpose.successHighEmphasis", + "~:type": "~:color", + "~:value": "{xx.global.color.green.100}", + "~:description": "color for elements with success purpose of high emphasis", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.state.onDisable", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d8b", + "~:name": "xx.alias.color.state.onDisable", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.700}", + "~:description": "color for elements that are palced on disabled background color", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.state.selectInverted", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d8f", + "~:name": "xx.alias.color.state.selectInverted", + "~:type": "~:color", + "~:value": "{xx.global.color.mint.600}", + "~:description": "Inverted background color for selected elements", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.purpose.warningLowEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d82", + "~:name": "xx.alias.color.purpose.warningLowEmphasis", + "~:type": "~:color", + "~:value": "{xx.global.color.yellow.400}", + "~:description": "color for elements with warning purpose of low emphasis", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.state.onSelectInverted", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d89", + "~:name": "xx.alias.color.state.onSelectInverted", + "~:type": "~:color", + "~:value": "{xx.global.color.mint.100}", + "~:description": "inverted color for elements on selected backgrounds", + "~:modified-at": "~m1778507716657" + } + } + ] + ] + } + } + } + ], + [ + "S-color-light", + { + "~#penpot/token-set": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f161e35", + "~:name": "alias/color-light", + "~:description": "", + "~:modified-at": "~m1778507716696", + "~:tokens": { + "~#ordered-map": [ + [ + "xx.alias.color.purpose.onInfo", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca472", + "~:name": "xx.alias.color.purpose.onInfo", + "~:type": "~:color", + "~:value": "{xx.global.color.blue.100}", + "~:description": "color for elements placed on informational purpose background", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.border.heavy", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca479", + "~:name": "xx.alias.color.border.heavy", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.700}", + "~:description": "heavy border color", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.state.select", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca481", + "~:name": "xx.alias.color.state.select", + "~:type": "~:color", + "~:value": "{xx.global.color.mint.100}", + "~:description": "background color for selected elements", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.primary.onBrand", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca486", + "~:name": "xx.alias.color.primary.onBrand", + "~:type": "~:color", + "~:value": "{xx.global.color.violet.200}", + "~:description": "color for elements that are placed on brand background color", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.state.focus", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca47e", + "~:name": "xx.alias.color.state.focus", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.100}", + "~:description": "Background color for state overlay of a focused element", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.state.press", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca480", + "~:name": "xx.alias.color.state.press", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.100}", + "~:description": "Background color for state overlay of a pressed element", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.purpose.warningHighEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca477", + "~:name": "xx.alias.color.purpose.warningHighEmphasis", + "~:type": "~:color", + "~:value": "{xx.global.color.yellow.500}", + "~:description": "color for elements with warning purpose of high emphasis", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.state.onSelect", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca485", + "~:name": "xx.alias.color.state.onSelect", + "~:type": "~:color", + "~:value": "{xx.global.color.mint.600}", + "~:description": "Foreground color for selected elements", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.primary.interaction", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca487", + "~:name": "xx.alias.color.primary.interaction", + "~:type": "~:color", + "~:value": "{xx.global.color.violet.600}", + "~:description": "Color that indicates interactions", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.border.focusOutline", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca47a", + "~:name": "xx.alias.color.border.focusOutline", + "~:type": "~:color", + "~:value": "rgba({xx.global.color.blue.500},0.8)", + "~:description": "color for default focus outline", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.state.disable", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca482", + "~:name": "xx.alias.color.state.disable", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.300}", + "~:description": "background color for disabled elements", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.purpose.infoHighEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca478", + "~:name": "xx.alias.color.purpose.infoHighEmphasis", + "~:type": "~:color", + "~:value": "{xx.global.color.blue.500}", + "~:description": "color for elements with informational purpose of high emphasis", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.purpose.infoLowEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca46d", + "~:name": "xx.alias.color.purpose.infoLowEmphasis", + "~:type": "~:color", + "~:value": "{xx.global.color.blue.400}", + "~:description": "color for elements with informational purpose of low emphasis", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.primary.brand", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca488", + "~:name": "xx.alias.color.primary.brand", + "~:type": "~:color", + "~:value": "{xx.global.color.violet.500}", + "~:description": "background color for primary brand elements ", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.text.default", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca48b", + "~:name": "xx.alias.color.text.default", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.900}", + "~:description": "Default text color ", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.text.inverted", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca48c", + "~:name": "xx.alias.color.text.inverted", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.50}", + "~:description": "Inverted text color", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.purpose.onSuccess", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca475", + "~:name": "xx.alias.color.purpose.onSuccess", + "~:type": "~:color", + "~:value": "{xx.global.color.green.100}", + "~:description": "color for elements placed on success purpose background", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.purpose.criticalLowEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca473", + "~:name": "xx.alias.color.purpose.criticalLowEmphasis", + "~:type": "~:color", + "~:value": "{xx.global.color.red.500}", + "~:description": "color for elements with critical purpose of low emphasis", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.background.mediumEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca490", + "~:name": "xx.alias.color.background.mediumEmphasis", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.100}", + "~:description": "background Color for components with medium emphasis", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.text.emphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca48d", + "~:name": "xx.alias.color.text.emphasis", + "~:type": "~:color", + "~:value": "{xx.global.color.black}", + "~:description": "Default text color ", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.background.highEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca491", + "~:name": "xx.alias.color.background.highEmphasis", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.50}", + "~:description": "background Color for components with high emphasis", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.state.hover", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca484", + "~:name": "xx.alias.color.state.hover", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.100}", + "~:description": "Background color for state overlay of a hovered element", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.background.body", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca492", + "~:name": "xx.alias.color.background.body", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.200}", + "~:description": "body background color", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.purpose.successLowEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca471", + "~:name": "xx.alias.color.purpose.successLowEmphasis", + "~:type": "~:color", + "~:value": "{xx.global.color.green.400}", + "~:description": "color for elements with success purpose of low emphasis", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.primary.onInteraction", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca489", + "~:name": "xx.alias.color.primary.onInteraction", + "~:type": "~:color", + "~:value": "{xx.global.color.violet.300}", + "~:description": "color for elements that are placed on interactive background colors", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.purpose.onCritical", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca46e", + "~:name": "xx.alias.color.purpose.onCritical", + "~:type": "~:color", + "~:value": "{xx.global.color.red.200}", + "~:description": "color for elements placed on critical purpose background", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.text.medium", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca48e", + "~:name": "xx.alias.color.text.medium", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.400}", + "~:description": "Text color in subtle weight", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.purpose.criticalHighEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca46f", + "~:name": "xx.alias.color.purpose.criticalHighEmphasis", + "~:type": "~:color", + "~:value": "{xx.global.color.red.600}", + "~:description": "color for elements with critical purpose of high emphasis", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.border.medium", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca47b", + "~:name": "xx.alias.color.border.medium", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.500}", + "~:description": "medium border color", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.border.subtle", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca47c", + "~:name": "xx.alias.color.border.subtle", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.300}", + "~:description": "subtle border color", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.background.lowEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca493", + "~:name": "xx.alias.color.background.lowEmphasis", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.300}", + "~:description": "background color for elements with a low emphasis", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.purpose.onWarning", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca474", + "~:name": "xx.alias.color.purpose.onWarning", + "~:type": "~:color", + "~:value": "{xx.global.color.yellow.100}", + "~:description": "color for elements placed on warning purpose background", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.text.subtle", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca48f", + "~:name": "xx.alias.color.text.subtle", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.600}", + "~:description": "Text color in subtle weight", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.purpose.successHighEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca470", + "~:name": "xx.alias.color.purpose.successHighEmphasis", + "~:type": "~:color", + "~:value": "{xx.global.color.green.500}", + "~:description": "color for elements with success purpose of high emphasis", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.state.onDisable", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca47f", + "~:name": "xx.alias.color.state.onDisable", + "~:type": "~:color", + "~:value": "{xx.global.color.neutral.700}", + "~:description": "color for elements that are palced on disabled background color", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.state.selectInverted", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca483", + "~:name": "xx.alias.color.state.selectInverted", + "~:type": "~:color", + "~:value": "{xx.global.color.mint.600}", + "~:description": "Inverted background color for selected elements", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.purpose.warningLowEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca476", + "~:name": "xx.alias.color.purpose.warningLowEmphasis", + "~:type": "~:color", + "~:value": "{xx.global.color.yellow.400}", + "~:description": "color for elements with warning purpose of low emphasis", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.state.onSelectInverted", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca47d", + "~:name": "xx.alias.color.state.onSelectInverted", + "~:type": "~:color", + "~:value": "{xx.global.color.mint.100}", + "~:description": "inverted color for elements on selected backgrounds", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "xx.alias.color.primary.highEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca48a", + "~:name": "xx.alias.color.primary.highEmphasis", + "~:type": "~:color", + "~:value": "{xx.global.color.violet.200}", + "~:description": "background color for primary brand elements ", + "~:modified-at": "~m1778507716658" + } + } + ] + ] + } + } + } + ], + [ + "S-media-queries", + { + "~#penpot/token-set": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f16cbda", + "~:name": "alias/media-queries", + "~:description": "", + "~:modified-at": "~m1778507716699", + "~:tokens": { + "~#ordered-map": [ + [ + "xx.media.xs", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d3a", + "~:name": "xx.media.xs", + "~:type": "~:dimensions", + "~:value": "576px", + "~:description": "(min-width: 576px)", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.media.sm", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d3b", + "~:name": "xx.media.sm", + "~:type": "~:dimensions", + "~:value": "768px", + "~:description": "(min-width: 768px)", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.media.md", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d3c", + "~:name": "xx.media.md", + "~:type": "~:dimensions", + "~:value": "992px", + "~:description": "min-width: 992px - Tablet portrait", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.media.lg", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d3d", + "~:name": "xx.media.lg", + "~:type": "~:dimensions", + "~:value": "1200px", + "~:description": " (min-width: 1200px) - Tablet landscape", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.media.xl", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d3e", + "~:name": "xx.media.xl", + "~:type": "~:dimensions", + "~:value": "1440px", + "~:description": " (min-width: 1440px) - Desktop small", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.media.2xl", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d3f", + "~:name": "xx.media.2xl", + "~:type": "~:dimensions", + "~:value": "1680px", + "~:description": " (min-width: 1680px) - Desktop Large", + "~:modified-at": "~m1778507716657" + } + } + ] + ] + } + } + } + ], + [ + "S-web-typography", + { + "~#penpot/token-set": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f171543", + "~:name": "alias/web-typography", + "~:description": "", + "~:modified-at": "~m1778507716700", + "~:tokens": { + "~#ordered-map": [ + [ + "lineHeight.paragraph", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca497", + "~:name": "lineHeight.paragraph", + "~:type": "~:number", + "~:value": "1.5", + "~:description": "", + "~:modified-at": "~m1778507716658" + } + } + ], + [ + "lineHeight.inline", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0ca498", + "~:name": "lineHeight.inline", + "~:type": "~:number", + "~:value": "1.2", + "~:description": "", + "~:modified-at": "~m1778507716658" + } + } + ] + ] + } + } + } + ], + [ + "S-app-typography", + { + "~#penpot/token-set": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f17704b", + "~:name": "alias/app-typography", + "~:description": "", + "~:modified-at": "~m1778507716701", + "~:tokens": { + "~#ordered-map": [ + [ + "lineHeight.paragraph", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c38b8", + "~:name": "lineHeight.paragraph", + "~:type": "~:number", + "~:value": "1.4", + "~:description": "", + "~:modified-at": "~m1778507716656" + } + } + ], + [ + "lineHeight.inline", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c38b9", + "~:name": "lineHeight.inline", + "~:type": "~:number", + "~:value": "1.2", + "~:description": "", + "~:modified-at": "~m1778507716656" + } + } + ] + ] + } + } + } + ], + [ + "S-shadows", + { + "~#penpot/token-set": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f18b19c", + "~:name": "alias/shadows", + "~:description": "", + "~:modified-at": "~m1778507716706", + "~:tokens": { + "~#ordered-map": [ + [ + "color.shadow.strong", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d63", + "~:name": "color.shadow.strong", + "~:type": "~:color", + "~:value": "rgba(\t24,20,31, 0.30)", + "~:description": "", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "shadow.lower.hover", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d68", + "~:name": "shadow.lower.hover", + "~:type": "~:shadow", + "~:value": [ + { + "~:offset-x": "0", + "~:offset-y": "1", + "~:blur": "2", + "~:spread": "0", + "~:color": "{color.shadow.strong}", + "~:inset": false + }, + { + "~:offset-x": "0", + "~:offset-y": "0", + "~:blur": "2", + "~:spread": "0", + "~:color": "{color.shadow.base}", + "~:inset": false + } + ], + "~:description": "", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "shadow.lower.default", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d69", + "~:name": "shadow.lower.default", + "~:type": "~:shadow", + "~:value": [ + { + "~:offset-x": "0", + "~:offset-y": "1", + "~:blur": "2", + "~:spread": "0", + "~:color": "{color.shadow.low}", + "~:inset": false + }, + { + "~:offset-x": "0", + "~:offset-y": "0", + "~:blur": "2", + "~:spread": "0", + "~:color": "{color.shadow.base}", + "~:inset": false + } + ], + "~:description": "", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "shadow.low.hover", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d6a", + "~:name": "shadow.low.hover", + "~:type": "~:shadow", + "~:value": [ + { + "~:offset-x": "0", + "~:offset-y": "2", + "~:blur": "8", + "~:spread": "0", + "~:color": "{color.shadow.strong}", + "~:inset": false + }, + { + "~:offset-x": "0", + "~:offset-y": "0", + "~:blur": "2", + "~:spread": "0", + "~:color": "{color.shadow.base}", + "~:inset": false + } + ], + "~:description": "", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "color.shadow.base", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d64", + "~:name": "color.shadow.base", + "~:type": "~:color", + "~:value": "rgba(\t24,20,31, 0.06)", + "~:description": "", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "lower", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d6c", + "~:name": "lower", + "~:type": "~:shadow", + "~:value": [ + { + "~:offset-x": "0", + "~:offset-y": "1", + "~:blur": "2", + "~:spread": "0", + "~:color": "{color.shadow.low}", + "~:inset": false + } + ], + "~:description": "", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "color.shadow.stronger", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d65", + "~:name": "color.shadow.stronger", + "~:type": "~:color", + "~:value": "rgba(\t24,20,31, 0.35)", + "~:description": "", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "color.shadow.medium", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d66", + "~:name": "color.shadow.medium", + "~:type": "~:color", + "~:value": "rgba(\t24,20,31, 0.25)", + "~:description": "", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "shadow.low.default", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d6b", + "~:name": "shadow.low.default", + "~:type": "~:shadow", + "~:value": [ + { + "~:offset-x": "0", + "~:offset-y": "2", + "~:blur": "8", + "~:spread": "0", + "~:color": "{color.shadow.low}", + "~:inset": false + }, + { + "~:offset-x": "0", + "~:offset-y": "0", + "~:blur": "2", + "~:spread": "0", + "~:color": "{color.shadow.base}", + "~:inset": false + } + ], + "~:description": "", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "color.shadow.low", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d67", + "~:name": "color.shadow.low", + "~:type": "~:color", + "~:value": "rgba(\t24,20,31, 0.20)", + "~:description": "", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "base.shadow", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d6d", + "~:name": "base.shadow", + "~:type": "~:shadow", + "~:value": [ + { + "~:offset-x": "0", + "~:offset-y": "0", + "~:blur": "2", + "~:spread": "0", + "~:color": "{color.shadow.base}", + "~:inset": false + } + ], + "~:description": "sombra común", + "~:modified-at": "~m1778507716657" + } + } + ] + ] + } + } + } + ] + ] + } + ], + [ + "G-components", + { + "~#ordered-map": [ + [ + "S-section-message", + { + "~#penpot/token-set": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f1a31f0", + "~:name": "components/section-message", + "~:description": "", + "~:modified-at": "~m1778507716712", + "~:tokens": { + "~#ordered-map": [ + [ + "xx.component.sectionMessage.warning.icon", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d6e", + "~:name": "xx.component.sectionMessage.warning.icon", + "~:type": "~:color", + "~:value": "{xx.alias.color.purpose.onWarning}", + "~:description": "icon color for warning section messages", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.component.sectionMessage.warning.background", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d6f", + "~:name": "xx.component.sectionMessage.warning.background", + "~:type": "~:color", + "~:value": "{xx.alias.color.purpose.warningHighEmphasis}", + "~:description": "background color for warning section messages", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.component.sectionMessage.borderRadius", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d71", + "~:name": "xx.component.sectionMessage.borderRadius", + "~:type": "~:border-radius", + "~:value": "{xx.alias.border.radius.md}", + "~:description": "border radius for section messages", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.component.sectionMessage.spacing.innerY", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d72", + "~:name": "xx.component.sectionMessage.spacing.innerY", + "~:type": "~:spacing", + "~:value": "{xx.alias.spacing.md}", + "~:description": "top and bottom inner spacing for section messages", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.component.sectionMessage.height.min", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d75", + "~:name": "xx.component.sectionMessage.height.min", + "~:type": "~:sizing", + "~:value": "{xx.alias.size.md}", + "~:description": "min height of section messages", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.component.sectionMessage.success.background", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d76", + "~:name": "xx.component.sectionMessage.success.background", + "~:type": "~:color", + "~:value": "{xx.alias.color.purpose.successHighEmphasis}", + "~:description": "background color for success section messages ", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.component.sectionMessage.spacing.innerX", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d73", + "~:name": "xx.component.sectionMessage.spacing.innerX", + "~:type": "~:spacing", + "~:value": "{xx.alias.spacing.sm}", + "~:description": "left and right inner spacing for section messages", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.component.sectionMessage.warning.text", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d70", + "~:name": "xx.component.sectionMessage.warning.text", + "~:type": "~:color", + "~:value": "{xx.alias.color.purpose.onWarning}", + "~:description": "text color for warning section messages", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.component.sectionMessage.spacing.gap", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d74", + "~:name": "xx.component.sectionMessage.spacing.gap", + "~:type": "~:spacing", + "~:value": "{xx.alias.spacing.xs}", + "~:description": "gap for separating elements in section message", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.component.sectionMessage.success.icon", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d77", + "~:name": "xx.component.sectionMessage.success.icon", + "~:type": "~:color", + "~:value": "{xx.alias.color.purpose.onSuccess}", + "~:description": "icon color for success section messages", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.component.sectionMessage.success.text", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d78", + "~:name": "xx.component.sectionMessage.success.text", + "~:type": "~:color", + "~:value": "{xx.alias.color.purpose.onSuccess}", + "~:description": "text color for success section messages", + "~:modified-at": "~m1778507716657" + } + } + ] + ] + } + } + } + ] + ] + } + ], + [ + "G-wl-themes", + { + "~#ordered-map": [ + [ + "S-client_theme_template", + { + "~#penpot/token-set": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f1eae34", + "~:name": "wl-themes/client_theme_template", + "~:description": "", + "~:modified-at": "~m1778507716730", + "~:tokens": { + "~#ordered-map": [ + [ + "xx.alias.color.purpose.onInfo", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d45", + "~:name": "xx.alias.color.purpose.onInfo", + "~:type": "~:color", + "~:value": "#ffffff", + "~:description": "color for elements placed on informational purpose background", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.border.heavy", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d4c", + "~:name": "xx.alias.color.border.heavy", + "~:type": "~:color", + "~:value": "#728090", + "~:description": "heavy border color", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.state.select", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d50", + "~:name": "xx.alias.color.state.select", + "~:type": "~:color", + "~:value": "#B4B4F2", + "~:description": "background color for selected elements", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.primary.onBrand", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d56", + "~:name": "xx.alias.color.primary.onBrand", + "~:type": "~:color", + "~:value": "#f5f5fa", + "~:description": "color for elements that are placed on brand background color", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.purpose.warningHighEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d4a", + "~:name": "xx.alias.color.purpose.warningHighEmphasis", + "~:type": "~:color", + "~:value": "#f1c40f", + "~:description": "color for elements with warning purpose of high emphasis", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.state.onSelect", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d51", + "~:name": "xx.alias.color.state.onSelect", + "~:type": "~:color", + "~:value": "#0707D3", + "~:description": "Foreground color for selected elements", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.primary.interaction", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d57", + "~:name": "xx.alias.color.primary.interaction", + "~:type": "~:color", + "~:value": "#DC7258", + "~:description": "Color that indicates interactions", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.border.focusOutline", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d4d", + "~:name": "xx.alias.color.border.focusOutline", + "~:type": "~:color", + "~:value": "#0505A3", + "~:description": "color for default focus outline", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.state.disable", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d52", + "~:name": "xx.alias.color.state.disable", + "~:type": "~:color", + "~:value": "#bdc3c7", + "~:description": "background color for disabled elements", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.purpose.infoHighEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d4b", + "~:name": "xx.alias.color.purpose.infoHighEmphasis", + "~:type": "~:color", + "~:value": "#2980b9", + "~:description": "color for elements with informational purpose of high emphasis", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.purpose.infoLowEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d40", + "~:name": "xx.alias.color.purpose.infoLowEmphasis", + "~:type": "~:color", + "~:value": "#2980b9", + "~:description": "color for elements with informational purpose of low emphasis", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.primary.brand", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d58", + "~:name": "xx.alias.color.primary.brand", + "~:type": "~:color", + "~:value": "#3333C2", + "~:description": "background color for primary brand elements ", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.text.default", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d5a", + "~:name": "xx.alias.color.text.default", + "~:type": "~:color", + "~:value": "#353535", + "~:description": "Default text color ", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.text.inverted", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d5b", + "~:name": "xx.alias.color.text.inverted", + "~:type": "~:color", + "~:value": "#f5f5fa", + "~:description": "Inverted text color", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.purpose.onSuccess", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d48", + "~:name": "xx.alias.color.purpose.onSuccess", + "~:type": "~:color", + "~:value": "#ffffff", + "~:description": "color for elements placed on success purpose background", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.purpose.criticalLowEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d46", + "~:name": "xx.alias.color.purpose.criticalLowEmphasis", + "~:type": "~:color", + "~:value": "#e74c3c", + "~:description": "color for elements with critical purpose of low emphasis", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.background.highEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d5d", + "~:name": "xx.alias.color.background.highEmphasis", + "~:type": "~:color", + "~:value": "#ffffff", + "~:description": "background Color for components with high emphasis", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.border.radius.md", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d60", + "~:name": "xx.alias.border.radius.md", + "~:type": "~:border-radius", + "~:value": "4px", + "~:description": "medium border radius", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.background.body", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d5e", + "~:name": "xx.alias.color.background.body", + "~:type": "~:color", + "~:value": "#E3E6E9", + "~:description": "body background color", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.purpose.successLowEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d44", + "~:name": "xx.alias.color.purpose.successLowEmphasis", + "~:type": "~:color", + "~:value": "#2ecc71", + "~:description": "color for elements with success purpose of low emphasis", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.primary.onInteraction", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d59", + "~:name": "xx.alias.color.primary.onInteraction", + "~:type": "~:color", + "~:value": "#f5f5fa", + "~:description": "color for elements that are placed on interactive background colors", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.purpose.onCritical", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d41", + "~:name": "xx.alias.color.purpose.onCritical", + "~:type": "~:color", + "~:value": "#ffffff", + "~:description": "color for elements placed on critical purpose background", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.purpose.criticalHighEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d42", + "~:name": "xx.alias.color.purpose.criticalHighEmphasis", + "~:type": "~:color", + "~:value": "#e74c3c", + "~:description": "color for elements with critical purpose of high emphasis", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.border.radius.lg", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d61", + "~:name": "xx.alias.border.radius.lg", + "~:type": "~:border-radius", + "~:value": "8px", + "~:description": "large border radius", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.border.medium", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d4e", + "~:name": "xx.alias.color.border.medium", + "~:type": "~:color", + "~:value": "#9AA5B1", + "~:description": "medium border color", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.border.subtle", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d4f", + "~:name": "xx.alias.color.border.subtle", + "~:type": "~:color", + "~:value": "#C3C9D0", + "~:description": "subtle border color", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.background.lowEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d5f", + "~:name": "xx.alias.color.background.lowEmphasis", + "~:type": "~:color", + "~:value": "#F6F7F8", + "~:description": "background color for elements with a low emphasis", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.purpose.onWarning", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d47", + "~:name": "xx.alias.color.purpose.onWarning", + "~:type": "~:color", + "~:value": "#ffffff", + "~:description": "color for elements placed on warning purpose background", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.text.subtle", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d5c", + "~:name": "xx.alias.color.text.subtle", + "~:type": "~:color", + "~:value": "#9E9E9E", + "~:description": "Text color in subtle weight", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.purpose.successHighEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d43", + "~:name": "xx.alias.color.purpose.successHighEmphasis", + "~:type": "~:color", + "~:value": "#2ecc71", + "~:description": "color for elements with success purpose of high emphasis", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.state.onDisable", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d53", + "~:name": "xx.alias.color.state.onDisable", + "~:type": "~:color", + "~:value": "#7f8c8d", + "~:description": "color for elements that are palced on disabled background color", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.border.radius.sm", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d62", + "~:name": "xx.alias.border.radius.sm", + "~:type": "~:border-radius", + "~:value": "0", + "~:description": "small border radius", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.state.selectInverted", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d54", + "~:name": "xx.alias.color.state.selectInverted", + "~:type": "~:color", + "~:value": "#0707D3", + "~:description": "Inverted background color for selected elements", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.purpose.warningLowEmphasis", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d49", + "~:name": "xx.alias.color.purpose.warningLowEmphasis", + "~:type": "~:color", + "~:value": "#f1c40f", + "~:description": "color for elements with warning purpose of low emphasis", + "~:modified-at": "~m1778507716657" + } + } + ], + [ + "xx.alias.color.state.onSelectInverted", + { + "~#penpot/token": { + "~:id": "~u4cdd76d8-0e6d-8168-8008-01189f0c4d55", + "~:name": "xx.alias.color.state.onSelectInverted", + "~:type": "~:color", + "~:value": "#B4B4F2", + "~:description": "inverted color for elements on selected backgrounds", + "~:modified-at": "~m1778507716657" + } + } + ] + ] + } + } + } + ] + ] + } + ] + ] + }, + "~:themes": { + "~#ordered-map": [ + [ + "", + { + "~#ordered-map": [ + [ + "__PENPOT__HIDDEN__TOKEN__THEME__", + { + "~#penpot/token-theme": { + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:name": "__PENPOT__HIDDEN__TOKEN__THEME__", + "~:group": "", + "~:description": "", + "~:is-source": false, + "~:external-id": "", + "~:modified-at": "~m1778507716730", + "~:sets": { + "~#set": [ + "alias/border", + "global/dimension", + "global/color", + "alias/color-light", + "global/Opacity", + "alias/dimension" + ] + } + } + } + ] + ] + } + ], + [ + "Global", + { + "~#ordered-map": [ + [ + "Brand", + { + "~#penpot/token-theme": { + "~:id": "~uc00d9d2e-76f9-815b-8005-e1260332abac", + "~:name": "Brand", + "~:group": "Global", + "~:description": "", + "~:is-source": true, + "~:external-id": "c00d9d2e-76f9-815b-8005-e1260332abac", + "~:modified-at": "~m1778507716656", + "~:sets": { + "~#set": [ + "global/dimension", + "global/color", + "global/Opacity" + ] + } + } + } + ] + ] + } + ], + [ + "Alias", + { + "~#ordered-map": [ + [ + "Light Mode", + { + "~#penpot/token-theme": { + "~:id": "~uc00d9d2e-76f9-815b-8005-e1260332abb0", + "~:name": "Light Mode", + "~:group": "Alias", + "~:description": "", + "~:is-source": true, + "~:external-id": "c00d9d2e-76f9-815b-8005-e1260332abb0", + "~:modified-at": "~m1778507716656", + "~:sets": { + "~#set": [ + "alias/border", + "alias/color-light", + "alias/dimension" + ] + } + } + } + ], + [ + "Dark Mode", + { + "~#penpot/token-theme": { + "~:id": "~uc00d9d2e-76f9-815b-8005-e1260332abb2", + "~:name": "Dark Mode", + "~:group": "Alias", + "~:description": "", + "~:is-source": true, + "~:external-id": "c00d9d2e-76f9-815b-8005-e1260332abb2", + "~:modified-at": "~m1778507716656", + "~:sets": { + "~#set": [ + "alias/border", + "alias/color-dark", + "alias/dimension" + ] + } + } + } + ] + ] + } + ], + [ + "Components", + { + "~#ordered-map": [ + [ + "Section Message", + { + "~#penpot/token-theme": { + "~:id": "~uc00d9d2e-76f9-815b-8005-e1260332abb4", + "~:name": "Section Message", + "~:group": "Components", + "~:description": "", + "~:is-source": true, + "~:external-id": "c00d9d2e-76f9-815b-8005-e1260332abb4", + "~:modified-at": "~m1778507716656", + "~:sets": { + "~#set": [ + "components/section-message" + ] + } + } + } + ] + ] + } + ], + [ + "White Label", + { + "~#ordered-map": [ + [ + "Client Theme Template", + { + "~#penpot/token-theme": { + "~:id": "~uc00d9d2e-76f9-815b-8005-e1260332abb6", + "~:name": "Client Theme Template", + "~:group": "White Label", + "~:description": "", + "~:is-source": true, + "~:external-id": "c00d9d2e-76f9-815b-8005-e1260332abb6", + "~:modified-at": "~m1778507716656", + "~:sets": { + "~#set": [ + "wl-themes/client_theme_template" + ] + } + } + } + ] + ] + } + ], + [ + "patata", + { + "~#ordered-map": [ + [ + "patatilla", + { + "~#penpot/token-theme": { + "~:id": "~uc00d9d2e-76f9-815b-8005-e1260332abb8", + "~:name": "patatilla", + "~:group": "patata", + "~:description": "", + "~:is-source": false, + "~:external-id": "c00d9d2e-76f9-815b-8005-e1260332abb8", + "~:modified-at": "~m1778507716656", + "~:sets": { + "~#set": [] + } + } + } + ] + ] + } + ], + [ + "typography", + { + "~#ordered-map": [ + [ + "web", + { + "~#penpot/token-theme": { + "~:id": "~u312c1822-c99f-807a-8006-62605689cee0", + "~:name": "web", + "~:group": "typography", + "~:description": "", + "~:is-source": false, + "~:external-id": "312c1822-c99f-807a-8006-62605689cee0", + "~:modified-at": "~m1778507716656", + "~:sets": { + "~#set": [ + "alias/web-typography" + ] + } + } + } + ], + [ + "app", + { + "~#penpot/token-theme": { + "~:id": "~u312c1822-c99f-807a-8006-626064817196", + "~:name": "app", + "~:group": "typography", + "~:description": "", + "~:is-source": false, + "~:external-id": "312c1822-c99f-807a-8006-626064817196", + "~:modified-at": "~m1778507716656", + "~:sets": { + "~#set": [ + "alias/app-typography" + ] + } + } + } + ] + ] + } + ] + ] + }, + "~:active-themes": { + "~#set": [ + "Global/Brand", + "Alias/Light Mode", + "/__PENPOT__HIDDEN__TOKEN__THEME__" + ] + } + } + }, + "~:id": "~u1bac06a1-a942-80a6-8008-0222e1ec38d5", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } +} \ No newline at end of file diff --git a/frontend/playwright/data/workspace/multiselection-typography.json b/frontend/playwright/data/workspace/multiselection-typography.json new file mode 100644 index 0000000000..8fbb18f600 --- /dev/null +++ b/frontend/playwright/data/workspace/multiselection-typography.json @@ -0,0 +1,1655 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "variants/v1", + "layout/grid", + "styles/v2", + "fdata/objects-map", + "tokens/numeric-input", + "render-wasm/v1", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:team-id": "~u647da7ef-3079-81fb-8007-8bb0246a083c", + "~: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": "Nuevo Archivo 1", + "~:revn": 36, + "~:modified-at": "~m1776760054954", + "~:vern": 0, + "~:id": "~u1062e0a0-8fe0-80ae-8007-e70b4993f5ef", + "~: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" + ] + }, + "~:version": 67, + "~:project-id": "~u647da7ef-3079-81fb-8007-8bb0246cef4d", + "~:created-at": "~m1776759390799", + "~:backend": "legacy-db", + "~:data": { + "~:pages": [ + "~u1062e0a0-8fe0-80ae-8007-e70b4993f5f0" + ], + "~:pages-index": { + "~u1062e0a0-8fe0-80ae-8007-e70b4993f5f0": { + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 0.01 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 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, + "~: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": [ + "~u12f7a4ff-ddae-80ff-8007-e70be436041b", + "~u12f7a4ff-ddae-80ff-8007-e70c000c9c84", + "~u12f7a4ff-ddae-80ff-8007-e70c1654b4af", + "~u12f7a4ff-ddae-80ff-8007-e70bf473916f", + "~u12f7a4ff-ddae-80ff-8007-e70c000c9c85", + "~u12f7a4ff-ddae-80ff-8007-e70c1654b4b0", + "~u12f7a4ff-ddae-80ff-8007-e70c3496ef94", + "~u12f7a4ff-ddae-80ff-8007-e70c3aa79b6a" + ] + } + }, + "~u12f7a4ff-ddae-80ff-8007-e70c1654b4af": { + "~#shape": { + "~:y": 289, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:content": { + "~:type": "root", + "~:key": "8j9me9oa49", + "~: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": "capitalize", + "~:font-id": "gfont-rufina", + "~:key": "wgjr6b27pa", + "~:font-size": "14", + "~:font-weight": "700", + "~:typography-ref-file": null, + "~:font-variant-id": "700", + "~:text-decoration": "line-through", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "Rufina", + "~:text": "Text with no typography" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "capitalize", + "~:text-align": "left", + "~:font-id": "gfont-rufina", + "~:key": "1dpjnycmsmq", + "~:font-size": "0", + "~:font-weight": "700", + "~:typography-ref-file": null, + "~:text-direction": "rtl", + "~:type": "paragraph", + "~:font-variant-id": "700", + "~:text-decoration": "line-through", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "Rufina" + } + ] + } + ], + "~:vertical-align": "top" + }, + "~:hide-in-viewer": false, + "~:name": "Text with no typography", + "~:width": 268, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 854, + "~:y": 289 + } + }, + { + "~#point": { + "~:x": 1122, + "~:y": 289 + } + }, + { + "~#point": { + "~:x": 1122, + "~:y": 348 + } + }, + { + "~#point": { + "~:x": 854, + "~:y": 348 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~u12f7a4ff-ddae-80ff-8007-e70c1654b4af", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:position-data": [ + { + "~:y": 306.239990234375, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "capitalize", + "~:text-align": "left", + "~:font-id": "gfont-rufina", + "~:font-size": "14px", + "~:font-weight": "700", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:width": 168.320007324219, + "~:font-variant-id": "regular", + "~:text-decoration": "line-through", + "~:letter-spacing": "0px", + "~:x": 854, + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "Rufina", + "~:height": 17.2899780273438, + "~:text": "Text with no typography" + } + ], + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:x": 854, + "~:selrect": { + "~#rect": { + "~:x": 854, + "~:y": 289, + "~:width": 268, + "~:height": 59, + "~:x1": 854, + "~:y1": 289, + "~:x2": 1122, + "~:y2": 348 + } + }, + "~:flip-x": null, + "~:height": 59, + "~:flip-y": null + } + }, + "~u12f7a4ff-ddae-80ff-8007-e70bf473916f": { + "~#shape": { + "~:y": 380, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:content": { + "~:type": "root", + "~:key": "8j9me9oa49", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.4", + "~:path": "", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.4", + "~:path": "", + "~:font-style": "normal", + "~:typography-ref-id": "~u12f7a4ff-ddae-80ff-8007-e70b6a1d57ba", + "~:text-transform": "uppercase", + "~:font-id": "gfont-im-fell-french-canon-sc", + "~:key": "wgjr6b27pa", + "~:font-size": "16", + "~:font-weight": "400", + "~:typography-ref-file": "~u1062e0a0-8fe0-80ae-8007-e70b4993f5ef", + "~:modified-at": "~m1776759448186", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "IM Fell French Canon SC", + "~:text": "Text with typography asset two" + } + ], + "~:typography-ref-id": "~u12f7a4ff-ddae-80ff-8007-e70b6a1d57ba", + "~:text-transform": "uppercase", + "~:text-align": "right", + "~:font-id": "gfont-im-fell-french-canon-sc", + "~:key": "1dpjnycmsmq", + "~:font-size": "16", + "~:font-weight": "400", + "~:typography-ref-file": "~u1062e0a0-8fe0-80ae-8007-e70b4993f5ef", + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:modified-at": "~m1776759448186", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "IM Fell French Canon SC" + } + ] + } + ], + "~:vertical-align": "center" + }, + "~:hide-in-viewer": false, + "~:name": "Text with typography asset two", + "~:width": 268, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 206, + "~:y": 380 + } + }, + { + "~#point": { + "~:x": 474, + "~:y": 380 + } + }, + { + "~#point": { + "~:x": 474, + "~:y": 439 + } + }, + { + "~#point": { + "~:x": 206, + "~:y": 439 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~u12f7a4ff-ddae-80ff-8007-e70bf473916f", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:position-data": [ + { + "~:y": 407.670013427734, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "uppercase", + "~:text-align": "left", + "~:font-id": "gfont-im-fell-french-canon-sc", + "~:font-size": "16px", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:width": 261.320007324219, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0px", + "~:x": 216.110000610352, + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "IM Fell French Canon SC", + "~:height": 18.7400207519531, + "~:text": "Text with typography asset " + }, + { + "~:y": 429.669982910156, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "uppercase", + "~:text-align": "left", + "~:font-id": "gfont-im-fell-french-canon-sc", + "~:font-size": "16px", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:width": 38.9199829101563, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0px", + "~:x": 435.080017089844, + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "IM Fell French Canon SC", + "~:height": 18.739990234375, + "~:text": "two" + } + ], + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:x": 206, + "~:selrect": { + "~#rect": { + "~:x": 206, + "~:y": 380, + "~:width": 268, + "~:height": 59, + "~:x1": 206, + "~:y1": 380, + "~:x2": 474, + "~:y2": 439 + } + }, + "~:flip-x": null, + "~:height": 59, + "~:flip-y": null + } + }, + "~u12f7a4ff-ddae-80ff-8007-e70c3aa79b6a": { + "~#shape": { + "~:y": 371.999988555908, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Ellipse", + "~:width": 77, + "~:type": "~:circle", + "~:points": [ + { + "~#point": { + "~:x": 1226, + "~:y": 371.999988555908 + } + }, + { + "~#point": { + "~:x": 1303, + "~:y": 371.999988555908 + } + }, + { + "~#point": { + "~:x": 1303, + "~:y": 435.999988555908 + } + }, + { + "~#point": { + "~:x": 1226, + "~:y": 435.999988555908 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~u12f7a4ff-ddae-80ff-8007-e70c3aa79b6a", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 1226, + "~:proportion": 1, + "~:shadow": [ + { + "~:id": "~u12f7a4ff-ddae-80ff-8007-e70db6016174", + "~:style": "~:drop-shadow", + "~:color": { + "~:color": "#000000", + "~:opacity": 0.2 + }, + "~:offset-x": 4, + "~:offset-y": 4, + "~:blur": 4, + "~:spread": 0, + "~:hidden": false + } + ], + "~:selrect": { + "~#rect": { + "~:x": 1226, + "~:y": 371.999988555908, + "~:width": 77, + "~:height": 64, + "~:x1": 1226, + "~:y1": 371.999988555908, + "~:x2": 1303, + "~:y2": 435.999988555908 + } + }, + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 64, + "~:flip-y": null + } + }, + "~u12f7a4ff-ddae-80ff-8007-e70c000c9c84": { + "~#shape": { + "~:y": 289, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:content": { + "~:type": "root", + "~:key": "8j9me9oa49", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "gfont-metrophobic", + "~:key": "wgjr6b27pa", + "~:font-size": "20", + "~:font-weight": "400", + "~:font-variant-id": "regular", + "~:text-decoration": "underline", + "~:letter-spacing": "2", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "Metrophobic", + "~:text": "Text with typography token one" + } + ], + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "gfont-metrophobic", + "~:key": "1dpjnycmsmq", + "~:font-size": "20", + "~:font-weight": "400", + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "underline", + "~:letter-spacing": "2", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "Metrophobic" + } + ] + } + ], + "~:vertical-align": "top" + }, + "~:hide-in-viewer": false, + "~:name": "Text with typography token one", + "~:width": 268, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 540, + "~:y": 289 + } + }, + { + "~#point": { + "~:x": 808, + "~:y": 289 + } + }, + { + "~#point": { + "~:x": 808, + "~:y": 348 + } + }, + { + "~#point": { + "~:x": 540, + "~:y": 348 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~u12f7a4ff-ddae-80ff-8007-e70c000c9c84", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:applied-tokens": { + "~:typography": "token-typo-one" + }, + "~:position-data": [ + { + "~:y": 313.329986572266, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "gfont-metrophobic", + "~:font-size": "20px", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:width": 242.419982910156, + "~:font-variant-id": "regular", + "~:text-decoration": "underline", + "~:letter-spacing": "2px", + "~:x": 541, + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "Metrophobic", + "~:height": 24.6599731445313, + "~:text": "Text with typography " + }, + { + "~:y": 337.330017089844, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "gfont-metrophobic", + "~:font-size": "20px", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:width": 109.25, + "~:font-variant-id": "regular", + "~:text-decoration": "underline", + "~:letter-spacing": "2px", + "~:x": 541, + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "Metrophobic", + "~:height": 24.6600036621094, + "~:text": "token one" + } + ], + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:x": 540, + "~:selrect": { + "~#rect": { + "~:x": 540, + "~:y": 289, + "~:width": 268, + "~:height": 59, + "~:x1": 540, + "~:y1": 289, + "~:x2": 808, + "~:y2": 348 + } + }, + "~:flip-x": null, + "~:height": 59, + "~:flip-y": null + } + }, + "~u12f7a4ff-ddae-80ff-8007-e70c000c9c85": { + "~#shape": { + "~:y": 378, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:content": { + "~:type": "root", + "~:key": "8j9me9oa49", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "capitalize", + "~:font-id": "gfont-alumni-sans-sc", + "~:key": "wgjr6b27pa", + "~:font-size": "18", + "~:font-weight": "400", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "Alumni Sans SC", + "~:text": "Text with typography token two" + } + ], + "~:text-transform": "capitalize", + "~:text-align": "left", + "~:font-id": "gfont-alumni-sans-sc", + "~:key": "1dpjnycmsmq", + "~:font-size": "18", + "~:font-weight": "400", + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "Alumni Sans SC" + } + ] + } + ], + "~:vertical-align": "top" + }, + "~:hide-in-viewer": false, + "~:name": "Text with typography token two", + "~:width": 268, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 540, + "~:y": 378 + } + }, + { + "~#point": { + "~:x": 808, + "~:y": 378 + } + }, + { + "~#point": { + "~:x": 808, + "~:y": 437 + } + }, + { + "~#point": { + "~:x": 540, + "~:y": 437 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~u12f7a4ff-ddae-80ff-8007-e70c000c9c85", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:applied-tokens": { + "~:typography": "token-typography-two" + }, + "~:position-data": [ + { + "~:y": 400, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "capitalize", + "~:text-align": "left", + "~:font-id": "gfont-alumni-sans-sc", + "~:font-size": "18px", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:width": 179.599975585938, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0px", + "~:x": 540, + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "Alumni Sans SC", + "~:height": 21.6000061035156, + "~:text": "Text with typography token two" + } + ], + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:x": 540, + "~:selrect": { + "~#rect": { + "~:x": 540, + "~:y": 378, + "~:width": 268, + "~:height": 59, + "~:x1": 540, + "~:y1": 378, + "~:x2": 808, + "~:y2": 437 + } + }, + "~:flip-x": null, + "~:height": 59, + "~:flip-y": null + } + }, + "~u12f7a4ff-ddae-80ff-8007-e70be436041b": { + "~#shape": { + "~:y": 291.000000417233, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:auto-height", + "~:content": { + "~:type": "root", + "~:key": "8j9me9oa49", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.2", + "~:path": "", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:path": "", + "~:font-style": "normal", + "~:typography-ref-id": "~u12f7a4ff-ddae-80ff-8007-e70b4e12167a", + "~:text-transform": "lowercase", + "~:font-id": "gfont-agdasima", + "~:key": "wgjr6b27pa", + "~:font-size": "18", + "~:font-weight": "700", + "~:typography-ref-file": "~u1062e0a0-8fe0-80ae-8007-e70b4993f5ef", + "~:modified-at": "~m1776759420985", + "~:font-variant-id": "700", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "Agdasima", + "~:text": "Text with typography asset one" + } + ], + "~:typography-ref-id": "~u12f7a4ff-ddae-80ff-8007-e70b4e12167a", + "~:text-transform": "lowercase", + "~:text-align": "center", + "~:font-id": "gfont-agdasima", + "~:key": "1dpjnycmsmq", + "~:font-size": "18", + "~:font-weight": "700", + "~:typography-ref-file": "~u1062e0a0-8fe0-80ae-8007-e70b4993f5ef", + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:modified-at": "~m1776759420985", + "~:font-variant-id": "700", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "Agdasima" + } + ] + } + ], + "~:vertical-align": "top" + }, + "~:hide-in-viewer": false, + "~:name": "Text with typography asset one", + "~:width": 268, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 206, + "~:y": 291.000000417233 + } + }, + { + "~#point": { + "~:x": 474, + "~:y": 291.000000417233 + } + }, + { + "~#point": { + "~:x": 474, + "~:y": 313.000000238419 + } + }, + { + "~#point": { + "~:x": 206, + "~:y": 313.000000238419 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~u12f7a4ff-ddae-80ff-8007-e70be436041b", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:position-data": [ + { + "~:y": 312.980010986328, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "lowercase", + "~:text-align": "left", + "~:font-id": "gfont-agdasima", + "~:font-size": "18px", + "~:font-weight": "700", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:width": 189.099990844727, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0px", + "~:x": 245.449996948242, + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "Agdasima", + "~:height": 21.5599975585938, + "~:text": "Text with typography asset one" + } + ], + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:x": 206, + "~:selrect": { + "~#rect": { + "~:x": 206, + "~:y": 291.000000417233, + "~:width": 268, + "~:height": 21.9999998211861, + "~:x1": 206, + "~:y1": 291.000000417233, + "~:x2": 474, + "~:y2": 313.000000238419 + } + }, + "~:flip-x": null, + "~:height": 21.9999998211861, + "~:flip-y": null + } + }, + "~u12f7a4ff-ddae-80ff-8007-e70c3496ef94": { + "~#shape": { + "~:y": 270.717638632528, + "~:transform": { + "~#matrix": { + "~:a": 0.866025405744331, + "~:b": 0.499999996605365, + "~:c": -0.499999999814932, + "~:d": 0.866025403891286, + "~:e": 2.27373675443232e-13, + "~:f": -1.36424205265939e-12 + } + }, + "~:rotation": 30, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 75.999946156548, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 1243.50013273954, + "~:y": 255.000030219555 + } + }, + { + "~#point": { + "~:x": 1309.31801694632, + "~:y": 293.000003039837 + } + }, + { + "~#point": { + "~:x": 1284.81801402569, + "~:y": 335.435252904892 + } + }, + { + "~#point": { + "~:x": 1219.00012981892, + "~:y": 297.43528008461 + } + } + ], + "~:r2": 4, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 0.866025403891288, + "~:b": -0.499999996605366, + "~:c": 0.499999999814933, + "~:d": 0.866025405744333, + "~:e": 4.85209646967248e-13, + "~:f": 1.2951551141376e-12 + } + }, + "~:r3": 4, + "~:r1": 4, + "~:id": "~u12f7a4ff-ddae-80ff-8007-e70c3496ef94", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 1, + "~:stroke-color": "#b01c1c", + "~:stroke-opacity": 1 + } + ], + "~:x": 1226.15910030434, + "~:proportion": 1, + "~:r4": 4, + "~:selrect": { + "~#rect": { + "~:x": 1226.15910030434, + "~:y": 270.717638632528, + "~:width": 75.999946156548, + "~:height": 49.0000058593917, + "~:x1": 1226.15910030434, + "~:y1": 270.717638632528, + "~:x2": 1302.15904646089, + "~:y2": 319.717644491919 + } + }, + "~:fills": [ + { + "~:fill-color": "#1355c0", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 49.0000058593917, + "~:flip-y": null + } + }, + "~u12f7a4ff-ddae-80ff-8007-e70c1654b4b0": { + "~#shape": { + "~:y": 378.000021457672, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:auto-height", + "~:content": { + "~:type": "root", + "~:key": "8j9me9oa49", + "~: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": "gfont-playwrite-tz", + "~:key": "wgjr6b27pa", + "~:font-size": "24", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#1355c0", + "~:fill-opacity": 1 + } + ], + "~:font-family": "Playwrite TZ", + "~:text": "Text with no typography two" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "justify", + "~:font-id": "gfont-playwrite-tz", + "~:key": "1dpjnycmsmq", + "~:font-size": "0", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "rtl", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#1355c0", + "~:fill-opacity": 1 + } + ], + "~:font-family": "Playwrite TZ" + } + ] + } + ], + "~:vertical-align": "bottom" + }, + "~:hide-in-viewer": false, + "~:name": "Text with no typography two", + "~:width": 268, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 854, + "~:y": 378.000021457672 + } + }, + { + "~#point": { + "~:x": 1122, + "~:y": 378.000021457672 + } + }, + { + "~#point": { + "~:x": 1122, + "~:y": 436.000020027161 + } + }, + { + "~#point": { + "~:x": 854, + "~:y": 436.000020027161 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~u12f7a4ff-ddae-80ff-8007-e70c1654b4b0", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:position-data": [ + { + "~:y": 415.780029296875, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "gfont-playwrite-tz", + "~:font-size": "24px", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:width": 275.199951171875, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0px", + "~:x": 854, + "~:fills": [ + { + "~:fill-color": "#1355c0", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "Playwrite TZ", + "~:height": 46.3599853515625, + "~:text": "Text with no " + }, + { + "~:y": 444.780029296875, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "gfont-playwrite-tz", + "~:font-size": "24px", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:width": 205.989990234375, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0px", + "~:x": 916.010009765625, + "~:fills": [ + { + "~:fill-color": "#1355c0", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "Playwrite TZ", + "~:height": 46.3599853515625, + "~:text": "typography two" + } + ], + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 1, + "~:stroke-color": "#ac0f0f", + "~:stroke-opacity": 1 + } + ], + "~:x": 854, + "~:selrect": { + "~#rect": { + "~:x": 854, + "~:y": 378.000021457672, + "~:width": 268, + "~:height": 57.9999985694885, + "~:x1": 854, + "~:y1": 378.000021457672, + "~:x2": 1122, + "~:y2": 436.000020027161 + } + }, + "~:flip-x": null, + "~:height": 57.9999985694885, + "~:flip-y": null + } + } + }, + "~:id": "~u1062e0a0-8fe0-80ae-8007-e70b4993f5f0", + "~:name": "Page 1" + } + }, + "~:id": "~u1062e0a0-8fe0-80ae-8007-e70b4993f5ef", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + }, + "~:typographies": { + "~u12f7a4ff-ddae-80ff-8007-e70b4e12167a": { + "~:line-height": "1.2", + "~:path": "", + "~:font-style": "normal", + "~:text-transform": "lowercase", + "~:font-id": "gfont-agdasima", + "~:font-size": "18", + "~:font-weight": "700", + "~:name": "typography one", + "~:modified-at": "~m1776759424008", + "~:font-variant-id": "700", + "~:id": "~u12f7a4ff-ddae-80ff-8007-e70b4e12167a", + "~:letter-spacing": "0", + "~:font-family": "Agdasima" + }, + "~u12f7a4ff-ddae-80ff-8007-e70b6a1d57ba": { + "~:line-height": "1.4", + "~:path": "", + "~:font-style": "normal", + "~:text-transform": "uppercase", + "~:font-id": "gfont-im-fell-french-canon-sc", + "~:font-size": "16", + "~:font-weight": "400", + "~:name": "typography 2", + "~:modified-at": "~m1776759451211", + "~:font-variant-id": "regular", + "~:id": "~u12f7a4ff-ddae-80ff-8007-e70b6a1d57ba", + "~:letter-spacing": "0", + "~:font-family": "IM Fell French Canon SC" + } + }, + "~:tokens-lib": { + "~#penpot/tokens-lib": { + "~:sets": { + "~#ordered-map": [ + [ + "S-Global", + { + "~#penpot/token-set": { + "~:id": "~u12f7a4ff-ddae-80ff-8007-e70bba7e2db2", + "~:name": "Global", + "~:description": "", + "~:modified-at": "~m1776759536029", + "~:tokens": { + "~#ordered-map": [ + [ + "token-typo-one", + { + "~#penpot/token": { + "~:id": "~u12f7a4ff-ddae-80ff-8007-e70bba7deb47", + "~:name": "token-typo-one", + "~:type": "~:typography", + "~:value": { + "~:font-family": [ + "Metrophobic" + ], + "~:font-size": "20", + "~:letter-spacing": "2", + "~:text-decoration": "underline" + }, + "~:description": "", + "~:modified-at": "~m1776759506423" + } + } + ], + [ + "token-typography-two", + { + "~#penpot/token": { + "~:id": "~u12f7a4ff-ddae-80ff-8007-e70bd4736112", + "~:name": "token-typography-two", + "~:type": "~:typography", + "~:value": { + "~:font-family": [ + "Alumni Sans SC" + ], + "~:font-size": "18", + "~:text-case": "capitalize" + }, + "~:description": "", + "~:modified-at": "~m1776759533005" + } + } + ] + ] + } + } + } + ] + ] + }, + "~:themes": { + "~#ordered-map": [ + [ + "", + { + "~#ordered-map": [ + [ + "__PENPOT__HIDDEN__TOKEN__THEME__", + { + "~#penpot/token-theme": { + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:name": "__PENPOT__HIDDEN__TOKEN__THEME__", + "~:group": "", + "~:description": "", + "~:is-source": false, + "~:external-id": "", + "~:modified-at": "~m1776759509463", + "~:sets": { + "~#set": [ + "Global" + ] + } + } + } + ] + ] + } + ] + ] + }, + "~:active-themes": { + "~#set": [ + "/__PENPOT__HIDDEN__TOKEN__THEME__" + ] + } + } + } + } +} \ No newline at end of file diff --git a/frontend/playwright/data/workspace/versions-init-2.json b/frontend/playwright/data/workspace/versions-init-2.json new file mode 100644 index 0000000000..a1507a3f83 --- /dev/null +++ b/frontend/playwright/data/workspace/versions-init-2.json @@ -0,0 +1 @@ +{"~:features":{"~#set":["layout/grid","styles/v2","fdata/pointer-map","fdata/objects-map","components/v2","fdata/shape-data-type"]},"~:permissions":{"~:type":"~:membership","~:is-owner":true,"~:is-admin":true,"~:can-edit":true,"~:can-read":true,"~:is-logged":true},"~:has-media-trimmed":false,"~:comment-thread-seqn":0,"~:name":"New File 5","~:revn":2,"~:modified-at":"~m1730197748522","~:vern":2,"~:id":"~u406b7b01-d3e2-80e4-8005-3138ac5d449c","~:is-shared":false,"~:version":55,"~:project-id":"~u3ffbd505-2f26-800f-8004-f34da98bdad8","~:created-at":"~m1730197736824","~:data":{"~:pages":["~u406b7b01-d3e2-80e4-8005-3138ac5d449d"],"~:pages-index":{"~u406b7b01-d3e2-80e4-8005-3138ac5d449d":{"~#penpot/pointer":["~u406b7b01-d3e2-80e4-8005-3138b7cc5f0b",{"~:created-at":"~m1730197748531"}]}},"~:id":"~u406b7b01-d3e2-80e4-8005-3138ac5d449c","~:options":{"~:components-v2":true}}} diff --git a/frontend/playwright/ui/pages/BasePage.js b/frontend/playwright/ui/pages/BasePage.js index bb95840664..670e8a2766 100644 --- a/frontend/playwright/ui/pages/BasePage.js +++ b/frontend/playwright/ui/pages/BasePage.js @@ -99,14 +99,14 @@ export class BasePage { } } - static async mockConfigFlags(page, flags) { + static async mockConfigFlags(page, flags, extras = {}) { const url = "**/js/config.js*"; + const config = { penpotFlags: flags.join(" "), ...extras }; + const body = Object.entries(config) + .map(([key, value]) => `var ${key} = ${JSON.stringify(value)};`) + .join("\n"); return await page.route(url, (route) => - route.fulfill({ - status: 200, - contentType: "application/javascript", - body: `var penpotFlags = "${flags.join(" ")}";`, - }), + route.fulfill({ status: 200, contentType: "application/javascript", body }), ); } @@ -128,8 +128,8 @@ export class BasePage { return BasePage.mockRPC(this.page, path, jsonFilename, options); } - async mockConfigFlags(flags) { - return BasePage.mockConfigFlags(this.page, flags); + async mockConfigFlags(flags, extras = {}) { + return BasePage.mockConfigFlags(this.page, flags, extras); } async mockFileMediaAsset( diff --git a/frontend/playwright/ui/pages/WorkspacePage.js b/frontend/playwright/ui/pages/WorkspacePage.js index ec963f718a..e8e3a062b6 100644 --- a/frontend/playwright/ui/pages/WorkspacePage.js +++ b/frontend/playwright/ui/pages/WorkspacePage.js @@ -172,6 +172,7 @@ export class WorkspacePage extends BaseWebSocketPage { this.toolbarOptions = page.getByTestId("toolbar-options"); this.rectShapeButton = page.getByRole("button", { name: "Rectangle (R)" }); this.ellipseShapeButton = page.getByRole("button", { name: "Ellipse (E)" }); + this.textShapeButton = page.getByRole("button", { name: "Text (T)" }); this.moveButton = page.getByRole("button", { name: "Move (V)" }); this.boardButton = page.getByRole("button", { name: "Board (B)" }); this.toggleToolbarButton = page.getByRole("button", { diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js index 21fb267806..55158b9564 100644 --- a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js +++ b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js @@ -243,6 +243,22 @@ test("Renders a file with a closed path shape with multiple segments using strok await expect(workspace.canvas).toHaveScreenshot(); }); +test("Renders svg paths with evenodd", async ({ + page, +}) => { + const workspace = new WasmWorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("render-wasm/get-file-paths-evenodd.json"); + + await workspace.goToWorkspace({ + id: "3e84615b-5628-818c-8007-e7563bb081fb", + pageId: "u3e84615b-5628-818c-8007-e7563bb081fc", + }); + await workspace.waitForFirstRenderWithoutUI(); + + await expect(workspace.canvas).toHaveScreenshot(); +}); + test("Renders solid shadows after select all and zoom to selected", async ({ page, }) => { diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-svg-paths-with-evenodd-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-svg-paths-with-evenodd-1.png new file mode 100644 index 0000000000..64617666a1 Binary files /dev/null and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-svg-paths-with-evenodd-1.png differ diff --git a/frontend/playwright/ui/specs/colorpicker.spec.js b/frontend/playwright/ui/specs/colorpicker.spec.js index a0e28eea07..75dad9c97f 100644 --- a/frontend/playwright/ui/specs/colorpicker.spec.js +++ b/frontend/playwright/ui/specs/colorpicker.spec.js @@ -85,14 +85,6 @@ test("Create a LINEAR gradient", async ({ page }) => { .last(); await inputOpacity2.fill("40"); - const inputOpacityGlobal = workspacePage.colorpicker.getByTestId( - "opacity-global-input", - ); - await inputOpacityGlobal.fill("50"); - await inputOpacityGlobal.press("Enter"); - await expect(inputOpacityGlobal).toHaveValue("50"); - await expect(inputOpacityGlobal).toBeVisible(); - await expect( workspacePage.page.getByText("Linear gradient") ).toBeVisible(); @@ -169,14 +161,6 @@ test("Create a RADIAL gradient", async ({ page }) => { .last(); await inputOpacity2.fill("100"); - const inputOpacityGlobal = workspacePage.colorpicker.getByTestId( - "opacity-global-input", - ); - await inputOpacityGlobal.fill("50"); - await inputOpacityGlobal.press("Enter"); - await expect(inputOpacityGlobal).toHaveValue("50"); - await expect(inputOpacityGlobal).toBeVisible(); - await expect( workspacePage.page.getByText("Radial gradient") ).toBeVisible(); @@ -212,7 +196,7 @@ test("Gradient stops limit", async ({ page }) => { }); // Fix for https://tree.taiga.io/project/penpot/issue/9900 -test("Bug 9900 - Color picker has no inputs for HSV values", async ({ +test("Bug 9900 - Color picker has no inputs for HSB values", async ({ page, }) => { const workspacePage = new WasmWorkspacePage(page); @@ -223,12 +207,12 @@ test("Bug 9900 - Color picker has no inputs for HSV values", async ({ const swatch = workspacePage.page.getByRole("button", { name: "E8E9EA" }); await swatch.click(); - const HSVA = await workspacePage.page.getByLabel("HSVA"); - await HSVA.click(); + const HSBA = await workspacePage.page.getByLabel("HSBA"); + await HSBA.click(); await workspacePage.page.getByLabel("H", { exact: true }).isVisible(); await workspacePage.page.getByLabel("S", { exact: true }).isVisible(); - await workspacePage.page.getByLabel("V", { exact: true }).isVisible(); + await workspacePage.page.getByLabel("B(V)", { exact: true }).isVisible(); }); test("Bug 10089 - Cannot change alpha", async ({ page }) => { diff --git a/frontend/playwright/ui/specs/components.spec.js b/frontend/playwright/ui/specs/components.spec.js index 50adc17eae..9661ba9c88 100644 --- a/frontend/playwright/ui/specs/components.spec.js +++ b/frontend/playwright/ui/specs/components.spec.js @@ -3,9 +3,12 @@ import { WasmWorkspacePage } from "../pages/WasmWorkspacePage"; test.beforeEach(async ({ page }) => { await WasmWorkspacePage.init(page); + await WasmWorkspacePage.mockConfigFlags(page, ["enable-feature-token-input"]); }); -test("BUG 13267 - Component instance is not synced with parent for geometry changes", async ({ page }) => { +test("BUG 13267 - Component instance is not synced with parent for geometry changes", async ({ + page, +}) => { const workspacePage = new WasmWorkspacePage(page); await workspacePage.setupEmptyFile(page); await workspacePage.mockGetFile("components/get-file-13267.json"); @@ -21,7 +24,9 @@ test("BUG 13267 - Component instance is not synced with parent for geometry chan // Select the main component await workspacePage.clickLeafLayer("A Component", {}, 1); - const rotationInput = workspacePage.rightSidebar.getByTestId("rotation").getByRole("textbox"); + const rotationInput = workspacePage.rightSidebar.getByRole("textbox", { + name: "Rotation", + }); await rotationInput.fill("45"); await rotationInput.press("Enter"); @@ -30,4 +35,4 @@ test("BUG 13267 - Component instance is not synced with parent for geometry chan await workspacePage.clickLeafLayer("Rectangle"); await expect(rotationInput).toHaveValue("45"); -}); \ No newline at end of file +}); diff --git a/frontend/playwright/ui/specs/design-tab.spec.js b/frontend/playwright/ui/specs/design-tab.spec.js index 8fe67b9d3a..0cf953a302 100644 --- a/frontend/playwright/ui/specs/design-tab.spec.js +++ b/frontend/playwright/ui/specs/design-tab.spec.js @@ -1,8 +1,11 @@ import { test, expect } from "@playwright/test"; import { WasmWorkspacePage } from "../pages/WasmWorkspacePage"; +const tokenInputFlag = "enable-feature-token-input"; + test.beforeEach(async ({ page }) => { await WasmWorkspacePage.init(page); + await WasmWorkspacePage.mockConfigFlags(page, [tokenInputFlag]); }); const multipleConstraintsFileId = `03bff843-920f-81a1-8004-756365e1eb6a`; @@ -71,7 +74,10 @@ test.describe("Shape attributes", () => { page, }) => { const workspace = new WasmWorkspacePage(page); - await workspace.mockConfigFlags(["enable-feature-render-wasm"]); + await workspace.mockConfigFlags([ + "enable-feature-render-wasm", + tokenInputFlag, + ]); await workspace.setupEmptyFile(); await workspace.mockRPC(/get\-file\?/, "design/get-file-fills-limit.json"); @@ -95,7 +101,10 @@ test.describe("Shape attributes", () => { page, }) => { const workspace = new WasmWorkspacePage(page); - await workspace.mockConfigFlags(["enable-feature-render-wasm"]); + await workspace.mockConfigFlags([ + "enable-feature-render-wasm", + tokenInputFlag, + ]); await workspace.setupEmptyFile(); await workspace.mockRPC( /get\-file\?/, @@ -236,7 +245,7 @@ test.describe("Background blur", () => { page, }) => { const workspace = new WasmWorkspacePage(page); - await workspace.mockConfigFlags(["enable-background-blur"]); + await workspace.mockConfigFlags(["enable-background-blur", tokenInputFlag]); await workspace.setupEmptyFile(); await workspace.mockGetFile("render-wasm/get-file-background-blur.json"); @@ -260,7 +269,7 @@ test.describe("Background blur", () => { page, }) => { const workspace = new WasmWorkspacePage(page); - await workspace.mockConfigFlags(["enable-background-blur"]); + await workspace.mockConfigFlags(["enable-background-blur", tokenInputFlag]); await workspace.setupEmptyFile(); await workspace.mockGetFile("render-wasm/get-file-background-blur.json"); @@ -319,6 +328,7 @@ test("BUG 9543 - Layout padding inputs not showing 'mixed' when needed", async ( page, }) => { const workspace = new WasmWorkspacePage(page); + await workspace.setupEmptyFile(); await workspace.mockRPC(/get\-file\?/, "design/get-file-9543.json"); await workspace.mockRPC( @@ -338,14 +348,18 @@ test("BUG 9543 - Layout padding inputs not showing 'mixed' when needed", async ( }); await toggle.click(); - await workspace.page.getByLabel("Top padding").fill("10"); + const topPaddingInput = workspace.page.getByRole("textbox", { + name: "Top padding", + }); + await topPaddingInput.fill("10"); + await topPaddingInput.press("Enter"); await toggle.click(); - await expect(workspace.page.getByLabel("Vertical padding")).toHaveValue(""); - await expect(workspace.page.getByLabel("Vertical padding")).toHaveAttribute( - "placeholder", - "Mixed", - ); + const verticalPaddingInput = await workspace.page.getByRole("textbox", { + name: "Vertical padding", + }); + await expect(verticalPaddingInput).toHaveValue(""); + await expect(verticalPaddingInput).toHaveAttribute("placeholder", "Mixed"); }); test("BUG 11177 - Font size input not showing 'mixed' when needed", async ({ diff --git a/frontend/playwright/ui/specs/inspect-tab.spec.js b/frontend/playwright/ui/specs/inspect-tab.spec.js index d85266a8c4..064ae644b2 100644 --- a/frontend/playwright/ui/specs/inspect-tab.spec.js +++ b/frontend/playwright/ui/specs/inspect-tab.spec.js @@ -14,14 +14,16 @@ const setupFile = async (workspacePage) => { /get\-file\?/, "workspace/get-file-inspect-tab.json", ); + + await workspacePage.mockRPC( + /update\-file\?/, + "workspace/update-file-empty.json", + ); + await workspacePage.goToWorkspace({ fileId: "7b2da435-6186-815a-8007-0daa95d2f26d", pageId: "ce79274b-11ab-8088-8007-0487ad43f789", }); - await workspacePage.mockRPC( - "update-file?id=*", - "workspace/update-file-empty.json", - ); }; const shapeToLayerName = { diff --git a/frontend/playwright/ui/specs/multiseleccion.spec.js b/frontend/playwright/ui/specs/multiseleccion.spec.js new file mode 100644 index 0000000000..3f7b351ce0 --- /dev/null +++ b/frontend/playwright/ui/specs/multiseleccion.spec.js @@ -0,0 +1,315 @@ +import { test, expect } from "@playwright/test"; +import { WasmWorkspacePage } from "../pages/WasmWorkspacePage"; + +test.beforeEach(async ({ page }) => { + await WasmWorkspacePage.init(page); + await WasmWorkspacePage.mockConfigFlags(page, ["enable-feature-token-input"]); +}); + +test("Multiselection - check multiple values in measures", async ({ page }) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(page); + await workspacePage.mockRPC( + /get\-file\?/, + "workspace/get-file-copy-paste.json", + ); + await workspacePage.mockRPC( + "get-file-fragment?file-id=*&fragment-id=*", + "workspace/get-file-copy-paste-fragment.json", + ); + + await workspacePage.goToWorkspace({ + fileId: "870f9f10-87b5-8137-8005-934804124660", + pageId: "870f9f10-87b5-8137-8005-934804124661", + }); + + // Select first shape (single selection first) + await page.getByTestId("layer-item").getByRole("button").first().click(); + await workspacePage.layers.getByTestId("layer-row").nth(0).click(); + + // === CHECK SINGLE SELECTION - ALL MEASURE FIELDS === + const measuresSection = workspacePage.rightSidebar.getByRole("region", { + name: "shape-measures-section", + }); + await expect(measuresSection).toBeVisible(); + + // Width + const widthInput = measuresSection.getByRole("textbox", { + name: "Width", + exact: true, + }); + await expect(widthInput).toHaveValue("360"); + + // Height + const heightInput = measuresSection.getByRole("textbox", { + name: "Height", + exact: true, + }); + await expect(heightInput).toHaveValue("53"); + + // X Position (using "X axis" title) + const xPosInput = measuresSection.getByRole("textbox", { + name: "X axis", + exact: true, + }); + await expect(xPosInput).toHaveValue("1094"); + + // Y Position (using "Y axis" title) + const yPosInput = measuresSection.getByRole("textbox", { + name: "Y axis", + exact: true, + }); + await expect(yPosInput).toHaveValue("856"); + + // === CHECK MULTI-SELECTION - MIXED VALUES === + // Shift+click to add second layer to selection + await workspacePage.layers + .getByTestId("layer-row") + .nth(1) + .click({ modifiers: ["Shift"] }); + + // All measure fields should show "Mixed" placeholder when values differ + await expect(widthInput).toHaveAttribute("placeholder", "Mixed"); + await expect(heightInput).toHaveAttribute("placeholder", "Mixed"); + await expect(xPosInput).toHaveAttribute("placeholder", "Mixed"); + await expect(yPosInput).toHaveAttribute("placeholder", "Mixed"); +}); + +test("Multiselection - check fill multiple values", async ({ page }) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(page); + await workspacePage.mockRPC( + /get\-file\?/, + "workspace/get-file-copy-paste.json", + ); + await workspacePage.mockRPC( + "get-file-fragment?file-id=*&fragment-id=*", + "workspace/get-file-copy-paste-fragment.json", + ); + + await workspacePage.goToWorkspace({ + fileId: "870f9f10-87b5-8137-8005-934804124660", + pageId: "870f9f10-87b5-8137-8005-934804124661", + }); + + await page.getByTestId("layer-item").getByRole("button").first().click(); + await workspacePage.layers.getByTestId("layer-row").nth(0).click(); + + // Fill section + const fillSection = workspacePage.rightSidebar.getByRole("region", { + name: "Fill section", + }); + await expect(fillSection).toBeVisible(); + + // Single selection - fill color should be visible (not "Mixed") + await expect(fillSection.getByText(/Mixed/i)).not.toBeVisible(); + + // Multi-selection with Shift+click + await workspacePage.layers + .getByTestId("layer-row") + .nth(1) + .click({ modifiers: ["Shift"] }); + + // Should show "Mixed" for fills when shapes have different fill colors + await expect(fillSection.getByText("Mixed")).toBeVisible(); +}); + +test("Multiselection - check stroke multiple values", async ({ page }) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(page); + await workspacePage.mockRPC( + /get\-file\?/, + "workspace/get-file-copy-paste.json", + ); + await workspacePage.mockRPC( + "get-file-fragment?file-id=*&fragment-id=*", + "workspace/get-file-copy-paste-fragment.json", + ); + + await workspacePage.goToWorkspace({ + fileId: "870f9f10-87b5-8137-8005-934804124660", + pageId: "870f9f10-87b5-8137-8005-934804124661", + }); + + await page.getByTestId("layer-item").getByRole("button").first().click(); + await workspacePage.layers.getByTestId("layer-row").nth(0).click(); + + // Stroke section + const strokeSection = workspacePage.rightSidebar.getByRole("region", { + name: "Stroke section", + }); + await expect(strokeSection).toBeVisible(); + + // Single selection - stroke should be visible (not "Mixed") + await expect(strokeSection.getByText(/Mixed/i)).not.toBeVisible(); + + // Multi-selection + await workspacePage.layers + .getByTestId("layer-row") + .nth(1) + .click({ modifiers: ["Shift"] }); + + // Should show "Mixed" for strokes when shapes have different stroke colors + await expect(strokeSection.getByText("Mixed")).toBeVisible(); +}); + +test("Multiselection - check rotation multiple values", async ({ page }) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(page); + await workspacePage.mockRPC( + /get\-file\?/, + "workspace/get-file-copy-paste.json", + ); + await workspacePage.mockRPC( + "get-file-fragment?file-id=*&fragment-id=*", + "workspace/get-file-copy-paste-fragment.json", + ); + + await workspacePage.goToWorkspace({ + fileId: "870f9f10-87b5-8137-8005-934804124660", + pageId: "870f9f10-87b5-8137-8005-934804124661", + }); + + await page.getByTestId("layer-item").getByRole("button").first().click(); + await workspacePage.layers.getByTestId("layer-row").nth(1).click(); + + // Measures section contains rotation + const measuresSection = workspacePage.rightSidebar.getByRole("region", { + name: "shape-measures-section", + }); + await expect(measuresSection).toBeVisible(); + + // Rotation field exists + const rotationInput = measuresSection.getByRole("textbox", { + name: "Rotation", + exact: true, + }); + await expect(rotationInput).toBeVisible(); + + // Rotate that shape + await rotationInput.fill("45"); + await page.keyboard.press("Enter"); + await expect(rotationInput).toHaveValue("45"); // Rotation should be 45 + + // Multi-selection + await workspacePage.layers + .getByTestId("layer-row") + .nth(0) + .click({ modifiers: ["Shift"] }); + + // Rotation should show "Mixed" placeholder + await expect(rotationInput).toHaveAttribute("placeholder", "Mixed"); +}); + +test("Multiselection of text and typographies", async ({ page }) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(page); + await workspacePage.mockRPC( + /get\-file\?/, + "workspace/multiselection-typography.json", + ); + + await workspacePage.goToWorkspace({ + fileId: "1062e0a0-8fe0-80ae-8007-e70b4993f5ef", + pageId: "1062e0a0-8fe0-80ae-8007-e70b4993f5f0", + }); + + await workspacePage.mockRPC( + "update-file?id=*", + "workspace/update-file-empty.json", + ); + + const plainTextLayer = workspacePage.layers.getByTestId("layer-row").nth(5); + const plainTextLayerTwo = workspacePage.layers + .getByTestId("layer-row") + .nth(2); + const typographyTextLayerOne = workspacePage.layers + .getByTestId("layer-row") + .nth(7); + const typographyTextLayerTwo = workspacePage.layers + .getByTestId("layer-row") + .nth(4); + const tokenTypographyTextLayerOne = workspacePage.layers + .getByTestId("layer-row") + .nth(6); + const tokenTypographyTextLayerTwo = workspacePage.layers + .getByTestId("layer-row") + .nth(3); + const rectangleLayer = workspacePage.layers.getByTestId("layer-row").nth(1); + const elipseLayer = workspacePage.layers.getByTestId("layer-row").nth(0); + const textSection = workspacePage.rightSidebar.getByRole("region", { + name: "Text section", + }); + // Select rectangle and elipse together + await rectangleLayer.click(); + await elipseLayer.click({ modifiers: ["Control"] }); + await expect(textSection).not.toBeVisible(); + + // Select plain text layer + await plainTextLayer.click(); + + await expect(textSection).toBeVisible(); + await expect( + textSection.getByText("Mixed assets"), + ).not.toBeVisible(); + + // Select two plain text layer with different font family + await plainTextLayerTwo.click({ modifiers: ["Control"] }); + await expect(textSection).toBeVisible(); + await expect( + textSection.getByTitle("Font family").getByText("Mixed Font Families"), + ).toBeVisible(); + + // Select typography text layer + await typographyTextLayerOne.click(); + await expect(textSection).toBeVisible(); + await expect(textSection.getByText("Typography one")).toBeVisible(); + + // Select two typography text layer with different typography + await typographyTextLayerTwo.click({ modifiers: ["Control"] }); + await expect(textSection).toBeVisible(); + await expect(textSection.getByText("Mixed assets")).toBeVisible(); + + // Select token typography text layer + // TODO: CHANGE WHEN TOKEN TYPOGRAPHY ROW IS READY + await tokenTypographyTextLayerOne.click(); + await expect(textSection).toBeVisible(); + await expect(textSection.getByText("Metrophobic")).toBeVisible(); + + // Select two token typography text layer with different token typography + // TODO: CHANGE WHEN TOKEN TYPOGRAPHY ROW IS READY + await tokenTypographyTextLayerTwo.click({ modifiers: ["Control"] }); + await expect(textSection).toBeVisible(); + await expect( + textSection.getByTitle("Font family").getByText("Mixed Font Families"), + ).toBeVisible(); + + //Select plain text layer and typography text layer together + await plainTextLayer.click(); + await typographyTextLayerOne.click({ modifiers: ["Control"] }); + await expect(textSection).toBeVisible(); + await expect(textSection.getByText("Mixed assets")).toBeVisible(); + + //Select plain text layer and typography text layer together on reverse order + await typographyTextLayerOne.click(); + await plainTextLayer.click({ modifiers: ["Control"] }); + await expect(textSection).toBeVisible(); + await expect(textSection.getByText("Mixed assets")).toBeVisible(); + + //Selen token typography text layer and typography text layer together + await tokenTypographyTextLayerOne.click(); + await typographyTextLayerOne.click({ modifiers: ["Control"] }); + await expect(textSection).toBeVisible(); + await expect(textSection.getByText("Mixed assets")).toBeVisible(); + + //Select token typography text layer and typography text layer together on reverse order + await typographyTextLayerOne.click(); + await tokenTypographyTextLayerOne.click({ modifiers: ["Control"] }); + await expect(textSection).toBeVisible(); + await expect(textSection.getByText("Mixed assets")).toBeVisible(); + + // Select rectangle and elipse together + await rectangleLayer.click(); + await elipseLayer.click({ modifiers: ["Control"] }); + await expect(textSection).not.toBeVisible(); +}); diff --git a/frontend/playwright/ui/specs/profile-menu.spec.js b/frontend/playwright/ui/specs/profile-menu.spec.js index e86a79a826..71bdbb4199 100644 --- a/frontend/playwright/ui/specs/profile-menu.spec.js +++ b/frontend/playwright/ui/specs/profile-menu.spec.js @@ -10,12 +10,16 @@ test("Navigate to penpot changelog from profile menu", async ({ page }) => { await dashboardPage.goToDashboard(); await dashboardPage.openProfileMenu(); - await dashboardPage.clickProfileMenuItem("About Penpot"); + const aboutPenpotItem = page.getByText("About Penpot"); + await aboutPenpotItem.hover(); + + const changelogSubmenuItem = page.getByText("Penpot Changelog"); + await expect(changelogSubmenuItem).toBeVisible(); // Listen for the new page (tab) that opens when clicking "Penpot Changelog" const [newPage] = await Promise.all([ page.context().waitForEvent("page"), - dashboardPage.clickProfileMenuItem("Penpot Changelog"), + changelogSubmenuItem.click(), ]); await newPage.waitForLoadState(); diff --git a/frontend/playwright/ui/specs/render-wasm.spec.js b/frontend/playwright/ui/specs/render-wasm.spec.js index c764df70b1..3dd82ccf17 100644 --- a/frontend/playwright/ui/specs/render-wasm.spec.js +++ b/frontend/playwright/ui/specs/render-wasm.spec.js @@ -16,7 +16,7 @@ test.skip("BUG 10867 - Crash when loading comments", async ({ page }) => { ).toBeVisible(); }); -test("BUG 13541 - Shows error page when WebGL context is lost", async ({ +test("Shows toast when WebGL context is lost", async ({ page, }) => { const workspacePage = new WasmWorkspacePage(page); @@ -31,12 +31,9 @@ test("BUG 13541 - Shows error page when WebGL context is lost", async ({ }); await expect( - page.getByText("Oops! The canvas context was lost"), + page.getByText("WebGL context was lost"), ).toBeVisible(); - await expect( - page.getByText("WebGL has stopped working"), - ).toBeVisible(); - await expect(page.getByText("Reload page")).toBeVisible(); + await expect(page.getByRole("button", { name: "Refresh" })).toBeVisible(); }); test.skip("BUG 12164 - Crash when trying to fetch a missing font", async ({ diff --git a/frontend/playwright/ui/specs/subscriptions-profile.spec.js b/frontend/playwright/ui/specs/subscriptions-profile.spec.js index 4f93070fcb..8c0e93d128 100644 --- a/frontend/playwright/ui/specs/subscriptions-profile.spec.js +++ b/frontend/playwright/ui/specs/subscriptions-profile.spec.js @@ -4,10 +4,11 @@ import SubscriptionProfilePage from "../pages/SubscriptionProfilePage"; test.beforeEach(async ({ page }) => { await SubscriptionProfilePage.init(page); - await SubscriptionProfilePage.mockConfigFlags(page, [ - "enable-subscriptions", - "disable-onboarding", - ]); + await SubscriptionProfilePage.mockConfigFlags( + page, + ["enable-subscriptions", "disable-onboarding"], + { penpotIsSaas: true }, + ); }); test.describe("Subscriptions: profile", () => { diff --git a/frontend/playwright/ui/specs/text-editor-v2.spec.js b/frontend/playwright/ui/specs/text-editor-v2.spec.js index c12fef1bba..25676c45e1 100644 --- a/frontend/playwright/ui/specs/text-editor-v2.spec.js +++ b/frontend/playwright/ui/specs/text-editor-v2.spec.js @@ -315,3 +315,19 @@ test("BUG 11552 - Apply styles to the current caret", async ({ page }) => { await expect(fontSizeInput).toHaveValue(""); await expect(fontSizeInput).toHaveAttribute("placeholder", "Mixed"); }); + +// This is to prevent QA tests from failing due to playwright +// considering 0-width text boxes as invisible +test("BUG 14098 - Fix text editor having 0 width or height", async ({ page }) => { + const workspace = new WasmWorkspacePage(page); + + await workspace.setupEmptyFile(); + await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json"); + await workspace.goToWorkspace(); + + await workspace.textShapeButton.click(); + await workspace.clickAt(200, 200); + + const textEditor = workspace.page.locator(`div[class*="viewport"]`).first().getByRole('textbox').first(); + await expect(textEditor).toBeVisible(); +}); diff --git a/frontend/playwright/ui/specs/tokens/apply.spec.js b/frontend/playwright/ui/specs/tokens/apply.spec.js index 5b20d089d0..9b4b98ac16 100644 --- a/frontend/playwright/ui/specs/tokens/apply.spec.js +++ b/frontend/playwright/ui/specs/tokens/apply.spec.js @@ -735,7 +735,7 @@ test.describe("Tokens: Apply token", () => { // Check if token pill is visible on right sidebar const strokeSectionSidebar = rightSidebar.getByRole("region", { - name: "stroke-section", + name: "Stroke section", }); await expect(strokeSectionSidebar).toBeVisible(); const firstStrokeRow = strokeSectionSidebar.getByLabel("stroke-row-0"); @@ -1030,6 +1030,229 @@ test("BUG: 13930, Token colors are shown on selected colors section", async ({ ).toBeVisible(); }); +test("BUG: 14136 Apply grid layout padding token to a shape from the sidebar does not change values", async ({ + page, +}) => { + // Setup the workspace with token features enabled + const { workspacePage, tokensSidebar, tokenContextMenuForToken } = + await setupTokensFileRender(page, { + flags: ["enable-token-combobox", "enable-feature-token-input"], + }); + + // Transform a rectangle into a grid container to expose gap properties + await page.getByRole("tab", { name: "Layers" }).click(); + + await workspacePage.layers.getByTestId("layer-row").nth(1).click(); + + const layoutSection = page.getByTestId("inspect-layout"); + await expect(layoutSection).toBeVisible(); + + const addLayoutButton = layoutSection + .getByRole("button", { name: "Add layout" }) + .first(); + await addLayoutButton.click(); + await page.getByText("Grid layout").click(); + + // Apply a dimension token to the vertical padding property + await layoutSection.getByLabel("Open token list").nth(2).click(); + const tokenDimensionMd = layoutSection.getByRole("option", { + name: "dimension.md", + }); + await expect(tokenDimensionMd).toBeVisible(); + await tokenDimensionMd.click(); + + // Expand padding to all sides + await layoutSection + .getByRole("button", { name: "Show 4 sided padding options" }) + .click(); + const topPaddingSection = layoutSection.getByLabel("Top padding"); + const bottomPaddingSection = layoutSection.getByLabel("Bottom padding"); + await expect(topPaddingSection).toBeVisible(); + + // Check if token is still applied to top and bottom padding + await expect(topPaddingSection.getByLabel("Detach token")).toBeVisible(); + await expect(bottomPaddingSection.getByLabel("Detach token")).toBeVisible(); + + // Check if the value of the attribute is still correct + await expect( + await topPaddingSection + .getByRole("button", { name: "dimension.md" }) + .textContent(), + ).toBe("16"); + await expect( + await bottomPaddingSection + .getByRole("button", { name: "dimension.md" }) + .textContent(), + ).toBe("16"); +}); + +test("BUG: 14200, Tokens in sets are applied when clicking on Save during creation", async ({ + page, +}) => { + // Setup the workspace with token features enabled + const { + workspacePage, + tokensSidebar, + tokenContextMenuForToken, + tokenThemesSetsSidebar, + tokenSetGroupItems, + tokensUpdateCreateModal, + } = await setupTokensFileRender(page, { + flags: ["enable-token-combobox", "enable-feature-token-input"], + }); + + const changeSetInput = async (sidebar, setName, finalKey = "Enter") => { + const setInput = sidebar.locator("input:focus"); + await expect(setInput).toBeVisible(); + await setInput.fill(setName); + await setInput.press(finalKey); + }; + + const createSet = async (sidebar, setName, finalKey = "Enter") => { + const tokensTabButton = sidebar + .getByRole("button", { name: "Add set" }) + .click(); + + await changeSetInput(sidebar, setName, (finalKey = "Enter")); + }; + + // Select rectangle layer + await page.getByRole("tab", { name: "Layers" }).click(); + + await workspacePage.layers + .getByTestId("layer-row") + .filter({ hasText: "Rectangle" }) + .first() + .click(); + + await page.getByRole("tab", { name: "Tokens" }).click(); + + // Create nested token set and activate it + await createSet(tokenThemesSetsSidebar, "set/first"); + + await tokenThemesSetsSidebar.getByRole("button", { name: "first" }).click(); + + await tokenThemesSetsSidebar + .getByRole("button", { name: "first" }) + .getByRole("checkbox") + .click(); + + // Create token in nested set + await unfoldTokenType(tokensSidebar, "Border radius"); + + // Create border token + const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); + await tokensTabPanel + .getByRole("button", { name: `Add Token: Border radius` }) + .click(); + await expect(tokensUpdateCreateModal).toBeVisible(); + + const nameField = tokensUpdateCreateModal.getByLabel("Name"); + await nameField.fill("border"); + + const valueField = tokensUpdateCreateModal.getByRole("combobox", { + name: "Value", + }); + await valueField.fill("20"); + + const submitButton = tokensUpdateCreateModal.getByRole("button", { + name: "Save", + }); + await submitButton.click(); + await expect(tokensUpdateCreateModal).not.toBeVisible(); + + // Check "border" token is not applied while creating. + + const borderRadiusSection = page.getByRole("region", { + name: "Border radius section", + }); + await expect(borderRadiusSection).toBeVisible(); + + // Check if token pill is visible on design tab on right sidebar + const borderTokenPill = borderRadiusSection.getByRole("button", { + name: "border", + exact: true, + }); + await expect(borderTokenPill).not.toBeVisible(); + + //Create new set and activate it + + await createSet(tokenThemesSetsSidebar, "set/other"); + + await tokenThemesSetsSidebar.getByRole("button", { name: "other" }).click(); + + await tokenThemesSetsSidebar + .getByRole("button", { name: "other" }) + .getByRole("checkbox") + .click(); + + //Create the same token in new set + await unfoldTokenType(tokensSidebar, "Border radius"); + await tokensTabPanel + .getByRole("button", { name: `Add Token: Border radius` }) + .click(); + await expect(tokensUpdateCreateModal).toBeVisible(); + await nameField.fill("border"); + await valueField.fill("50"); + await valueField.press("Enter"); + await expect(tokensUpdateCreateModal).not.toBeVisible(); + await expect(borderRadiusSection).toBeVisible(); + await expect(borderTokenPill).not.toBeVisible(); +}); + +test("BUG: 14191, Apply tokens from different set", async ({ page }) => { + const { + workspacePage, + tokensSidebar, + tokenContextMenuForToken, + tokenThemesSetsSidebar, + tokenSetGroupItems, + } = await setupTokensFileRender(page); + + await page.getByRole("tab", { name: "Layers" }).click(); + + await workspacePage.layers + .getByTestId("layer-row") + .filter({ hasText: "Rectangle" }) + .first() + .click(); + + await page.getByRole("tab", { name: "Tokens" }).click(); + + await unfoldTokenType(tokensSidebar, "Border radius"); + // Apply border radius token from core set + await tokensSidebar.getByRole("button", { name: "borderRadius.xl" }).click(); + + const borderRadiusSection = page.getByRole("region", { + name: "Border radius section", + }); + await expect(borderRadiusSection).toBeVisible(); + + // Check if token pill is visible on design tab on right sidebar + const brTokenPillxl = borderRadiusSection.getByRole("button", { + name: "borderRadius.xl", + }); + await expect(brTokenPillxl).toBeVisible(); + + // Change active token set + await expect( + tokenThemesSetsSidebar.getByRole("button", { name: "theme" }), + ).toBeVisible(); + + await tokenThemesSetsSidebar.getByRole("button", { name: "theme" }).click(); + // Apply border radius token from theme set + await unfoldTokenType(tokensSidebar, "Border radius"); + + await tokensSidebar + .getByRole("button", { name: "card.borderRadius" }) + .click(); + + const brTokenPillCard = borderRadiusSection.getByRole("button", { + name: "card.borderRadius", + }); + await expect(brTokenPillCard).toBeVisible(); +}); + test.describe("Numeric Input and Token Integration Tests", () => { test("Token pill persists after blur in gap inputs", async ({ page }) => { // Setup the workspace with token features enabled @@ -1386,8 +1609,14 @@ test.describe("Numeric Input and Token Integration Tests", () => { flags: ["enable-token-combobox", "enable-feature-token-input"], }); // Create a token with a reference value in other set. - await createToken(page, "Dimensions", "reference-token", "Value", "{card.padding}"); - + await createToken( + page, + "Dimensions", + "reference-token", + "Value", + "combobox", + "{card.padding}", + ); // Apply this token to a shape await page.getByRole("tab", { name: "Layers" }).click(); @@ -1410,11 +1639,16 @@ test.describe("Numeric Input and Token Integration Tests", () => { }); await expect(measuresSection).toBeVisible(); - await expect(measuresSection.getByRole('button', { name: 'reference-token' })).toBeVisible(); + await expect( + measuresSection.getByRole("button", { name: "reference-token" }), + ).toBeVisible(); // Deactivate token set where reference token exist to make token broken - await tokenThemesSetsSidebar.getByRole('button', { name: 'theme' }).getByRole('checkbox').click(); - + await tokenThemesSetsSidebar + .getByRole("button", { name: "theme" }) + .getByRole("checkbox") + .click(); + // Check if token pill show broken reference state const brokenPill = measuresSection.getByRole("button", { name: "is not in any active set", diff --git a/frontend/playwright/ui/specs/tokens/crud.spec.js b/frontend/playwright/ui/specs/tokens/crud.spec.js index 4bfd1c6a4b..594d9bf5aa 100644 --- a/frontend/playwright/ui/specs/tokens/crud.spec.js +++ b/frontend/playwright/ui/specs/tokens/crud.spec.js @@ -1687,29 +1687,58 @@ test.describe("Tokens - creation", () => { // Submit button should remain disabled when value is empty await expect(submitButton).toBeDisabled(); }); +}); - test("User duplicate color token", async ({ page }) => { - const { tokensSidebar, tokenContextMenuForToken } = - await setupTokensFileRender(page); +test("User cannot create token with a conflicting name in other set", async ({ page }) => { + const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } = + await setupTokensFileRender(page); - await expect(tokensSidebar).toBeVisible(); + await expect(tokensSidebar).toBeVisible(); - await unfoldTokenType(tokensSidebar, "color"); + await tokenThemesSetsSidebar.getByRole('button', { name: 'light', exact: true }).click(); - const colorToken = tokensSidebar.getByRole("button", { - name: "colors.blue.100", - }); + const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); + await tokensTabPanel + .getByRole("button", { name: "Add Token: Color" }) + .click(); - await colorToken.click({ button: "right" }); - await expect(tokenContextMenuForToken).toBeVisible(); + await expect(tokensUpdateCreateModal).toBeVisible(); - await tokenContextMenuForToken.getByText("Duplicate token").click(); - await expect(tokenContextMenuForToken).not.toBeVisible(); - - await expect( - tokensSidebar.getByRole("button", { name: "colors.blue.100-copy" }), - ).toBeVisible(); + const nameField = tokensUpdateCreateModal.getByLabel("Name"); + const valueField = tokensUpdateCreateModal.getByLabel("Value"); + const submitButton = tokensUpdateCreateModal.getByRole("button", { + name: "Save", }); + + // Initially submit button should be disabled + await expect(submitButton).toBeDisabled(); + + await nameField.click(); + + // Fill in the name of an existing token in the current set + await nameField.fill("accent.default"); + + // An error message should appear and submit button should be disabled + await expect(tokensUpdateCreateModal.getByText('A token already exists at the path: accent.default')) + .toBeVisible() + + await expect(submitButton).toBeDisabled(); + + // Fill in a name that clashes with tokens like colors.red.600 in set core + await nameField.fill("colors.red"); + + // An error message should appear and submit button should be disabled + await expect(tokensUpdateCreateModal.getByText('A token already exists at the path: colors.red')) + .toBeVisible() + + await expect(submitButton).toBeDisabled(); + + // Fill in a name that matches exactly a token in another set + await nameField.fill("colors.red.600"); + await valueField.fill("#6000000"); + + // Submit button should be enabled now + await expect(submitButton).toBeEnabled(); }); test("User creates grouped color token", async ({ page }) => { @@ -1794,22 +1823,33 @@ test("User duplicate color token", async ({ page }) => { test("User disables the current set but token still have resolved values shown in the sidebar", async ({ page, }) => { - const { tokenThemesSetsSidebar, tokensSidebar } = await setupEmptyTokensFileRender(page); + const { tokenThemesSetsSidebar, tokensSidebar } = + await setupEmptyTokensFileRender(page); // Create color token - await createToken(page, "Color", "color.primary", "Value", "#ff0000"); + await createToken( + page, + "Color", + "color.primary", + "Value", + "textbox", + "#ff0000", + ); await unfoldTokenType(tokensSidebar, "color"); // Deactivate current set - await tokenThemesSetsSidebar - .getByRole("checkbox") - .click(); + await tokenThemesSetsSidebar.getByRole("checkbox").click(); // Tokens tab panel should have a token with the color #ff0000 and correct resolved value in the tooltip - const colorTokenPill = tokensSidebar.getByRole("button", { name: "#ff0000 color.primary" }); + const colorTokenPill = tokensSidebar.getByRole("button", { + name: "#ff0000 color.primary", + }); await expect(colorTokenPill).toHaveCount(1); - await colorTokenPill.hover(); // Force title attribute to be attached to the button - await expect(colorTokenPill).toHaveAttribute("title", /Resolved value: #ff0000/); + await colorTokenPill.hover(); // Force title attribute to be attached to the button + await expect(colorTokenPill).toHaveAttribute( + "title", + /Resolved value: #ff0000/, + ); }); test.describe("Tokens tab - edition", () => { diff --git a/frontend/playwright/ui/specs/tokens/helpers.js b/frontend/playwright/ui/specs/tokens/helpers.js index 937a268242..d3f47a98da 100644 --- a/frontend/playwright/ui/specs/tokens/helpers.js +++ b/frontend/playwright/ui/specs/tokens/helpers.js @@ -332,7 +332,26 @@ const unfoldTokenType = async (tokensTabPanel, type) => { } }; -const createToken = async (page, type, name, textFieldName, value) => { +/** + * Creates a token from the Tokens sidebar modal. + * + * @param {import("@playwright/test").Page} page - Playwright page instance. + * @param {string} type - Token category label shown in UI (e.g. "Color", "Typography", "Shadow"). + * @param {string} name - Token name to create. + * @param {string} textFieldName - Accessible label of the value textbox in the modal (e.g. "Value", "Color", "Font size"). + * @param {"textbox" | "combobox"} textFieldType - Type of the token value field, wether it's a simple text field or a combo box, to properly fill the value. + * @param {string} value - Token value to set in the modal. + * @returns {Promise<void>} + */ + +const createToken = async ( + page, + type, + name, + textFieldName, + textFieldType, + value, +) => { const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); const { tokensUpdateCreateModal } = await setupTokensFileRender(page, { @@ -348,7 +367,7 @@ const createToken = async (page, type, name, textFieldName, value) => { const nameField = tokensUpdateCreateModal.getByLabel("Name"); await nameField.fill(name); - const colorField = tokensUpdateCreateModal.getByRole("textbox", { + const colorField = tokensUpdateCreateModal.getByRole(textFieldType, { name: textFieldName, }); await colorField.fill(value); diff --git a/frontend/playwright/ui/specs/tokens/remapping.spec.js b/frontend/playwright/ui/specs/tokens/remapping.spec.js index 55472cb4a1..3c733e9a2c 100644 --- a/frontend/playwright/ui/specs/tokens/remapping.spec.js +++ b/frontend/playwright/ui/specs/tokens/remapping.spec.js @@ -106,7 +106,14 @@ test.describe("Remapping a single token", () => { }); // Create base shadow token - await createToken(page, "Shadow", "base-shadow", "Color", "#000000"); + await createToken( + page, + "Shadow", + "base-shadow", + "Color", + "textbox", + "#000000", + ); // Create derived shadow token that references base-shadow await createCompositeDerivedToken( @@ -150,7 +157,14 @@ test.describe("Remapping a single token", () => { } = await setupTokensFileRender(page, { flags: ["enable-token-shadow"] }); // Create base shadow token - await createToken(page, "Shadow", "primary-shadow", "Color", "#000000"); + await createToken( + page, + "Shadow", + "primary-shadow", + "Color", + "textbox", + "#000000", + ); // Create derived shadow token that references base await createCompositeDerivedToken( @@ -257,7 +271,14 @@ test.describe("Remapping a single token", () => { const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); // Create base typography token - await createToken(page, "Typography", "base-text", "Font size", "16"); + await createToken( + page, + "Typography", + "base-text", + "Font size", + "textbox", + "16", + ); // Create derived typography token await createCompositeDerivedToken( @@ -301,7 +322,14 @@ test.describe("Remapping a single token", () => { const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); // Create base typography token - await createToken(page, "Typography", "body-style", "Font size", "16"); + await createToken( + page, + "Typography", + "body-style", + "Font size", + "textbox", + "16", + ); // Create derived typography token await tokensTabPanel @@ -536,7 +564,14 @@ test.describe("Remapping a single token", () => { }); // Create base shadow token - await createToken(page, "Shadow", "base-shadow", "Color", "#000000"); + await createToken( + page, + "Shadow", + "base-shadow", + "Color", + "textbox", + "#000000", + ); // Create derived shadow token that references base-shadow await createCompositeDerivedToken( @@ -575,7 +610,14 @@ test.describe("Remapping a single token", () => { }); // Create base shadow token - await createToken(page, "Shadow", "base-shadow", "Color", "#000000"); + await createToken( + page, + "Shadow", + "base-shadow", + "Color", + "textbox", + "#000000", + ); // Create derived shadow token that references base-shadow await createCompositeDerivedToken( @@ -615,8 +657,22 @@ test.describe("Remapping group of tokens", () => { const rightSidebar = workspacePage.rightSidebar; // Create multiple tokens in a group - await createToken(page, "Color", "light.primary", "Value", "#FFFFFF"); - await createToken(page, "Color", "light.secondary", "Value", "#EEEEEE"); + await createToken( + page, + "Color", + "light.primary", + "Value", + "textbox", + "#FFFFFF", + ); + await createToken( + page, + "Color", + "light.secondary", + "Value", + "textbox", + "#EEEEEE", + ); // Verify that the node and child token are visible before deletion const lightNode = tokensSidebar.getByRole("button", { @@ -687,7 +743,9 @@ test.describe("Remapping group of tokens", () => { await expect(lighterNode).toBeVisible(); // Verify that the applied token reference has been updated in the right sidebar for the selected shape - const fillSection = rightSidebar.getByRole("region", { name: "Fill section" }); + const fillSection = rightSidebar.getByRole("region", { + name: "Fill section", + }); await expect(fillSection).toBeVisible(); const tokenReference = fillSection.getByLabel("lighter.primary", { diff --git a/frontend/playwright/ui/specs/tokens/text.spec.js b/frontend/playwright/ui/specs/tokens/text.spec.js new file mode 100644 index 0000000000..3c34376934 --- /dev/null +++ b/frontend/playwright/ui/specs/tokens/text.spec.js @@ -0,0 +1,29 @@ +import { test, expect } from "@playwright/test"; +import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage"; + +test.beforeEach(async ({ page }) => { + await WasmWorkspacePage.init(page); +}); + +test("BUG 13958 - Fill token gets detached when editing text shape", async ({ page }) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(); + // Load a file that has a text shape, whose content contains a `:fills []` in the root node. + // This attribute is removed when editing the text, causing the old code to detect that the + // fills had changed and detaching the token. + await workspacePage.mockGetFile("workspace/get-file-13958.json"); + await workspacePage.goToWorkspace(); + + // Check token is attached to the shape + await workspacePage.clickLeafLayer("Design tokens are a set"); + await expect(workspacePage.rightSidebar.getByLabel("xx.alias.color.text.default", { exact: true })).toBeVisible(); + + // Enter and exit the text editor + await workspacePage.page.keyboard.press("Enter"); + await expect(workspacePage.page.getByTestId("text-editor")).toBeVisible(); + await workspacePage.page.keyboard.press("Escape"); + await expect(workspacePage.page.getByTestId("text-editor")).not.toBeAttached(); + + // Assert token is still attached to the shape + await expect(workspacePage.rightSidebar.getByLabel("xx.alias.color.text.default", { exact: true })).toBeVisible(); +}); diff --git a/frontend/playwright/ui/specs/tokens/tree.spec.js b/frontend/playwright/ui/specs/tokens/tree.spec.js index 243a539432..940141cbe0 100644 --- a/frontend/playwright/ui/specs/tokens/tree.spec.js +++ b/frontend/playwright/ui/specs/tokens/tree.spec.js @@ -32,8 +32,22 @@ test.describe("Tokens - node tree", () => { const { tokensSidebar } = await setupTokensFileRender(page); // Create multiple tokens in a group - await createToken(page, "Color", "dark.primary", "Value", "#000000"); - await createToken(page, "Color", "dark.secondary", "Value", "#111111"); + await createToken( + page, + "Color", + "dark.primary", + "Value", + "textbox", + "#000000", + ); + await createToken( + page, + "Color", + "dark.secondary", + "Value", + "textbox", + "#111111", + ); // Verify that the node and child token are visible before deletion const darkNode = tokensSidebar.getByRole("button", { @@ -88,8 +102,22 @@ test.describe("Tokens - node tree", () => { const { tokensSidebar } = await setupTokensFileRender(page); // Create multiple tokens in a group - await createToken(page, "Color", "dark.primary", "Value", "#000000"); - await createToken(page, "Color", "dark.secondary", "Value", "#111111"); + await createToken( + page, + "Color", + "dark.primary", + "Value", + "textbox", + "#000000", + ); + await createToken( + page, + "Color", + "dark.secondary", + "Value", + "textbox", + "#111111", + ); // Verify that the node and child token are visible before deletion const darkNode = tokensSidebar.getByRole("button", { @@ -141,6 +169,64 @@ test.describe("Tokens - node tree", () => { await expect(darkerNodeToken).toBeVisible(); }); + test("Renaming a token into a collapsed group auto-expands that group", async ({ + page, + }) => { + const { tokensSidebar, tokensUpdateCreateModal, tokenContextMenuForToken } = + await setupTokensFileRender(page); + + // Create tokens in two separate groups + await createToken( + page, + "Color", + "dark.base", + "Value", + "textbox", + "#000000", + ); + + await createToken( + page, + "Color", + "light.accent", + "Value", + "textbox", + "#ffffff", + ); + + const lightGroup = tokensSidebar.getByRole("button", { + name: "light", + exact: true, + }); + + // Collapse the light group so its children are hidden + await lightGroup.click(); + + const lightAccentToken = tokensSidebar.getByRole("button", { + name: "accent", + }); + await expect(lightAccentToken).not.toBeVisible(); + + // Open the edit modal for the dark.base token + const darkBaseToken = tokensSidebar.getByRole("button", { name: "base" }); + await darkBaseToken.click({ button: "right" }); + await tokenContextMenuForToken.getByText("Edit token").click(); + + await expect(tokensUpdateCreateModal).toBeVisible(); + + // Rename to move it into the collapsed light group + const nameField = tokensUpdateCreateModal.getByLabel("Name"); + await nameField.fill("light.base"); + await tokensUpdateCreateModal.getByRole("button", { name: "Save" }).click(); + + // After rename, light group should be auto-expanded and both tokens visible + await expect(lightGroup).toBeVisible(); + await expect(lightAccentToken).toBeVisible(); + await expect( + tokensSidebar.getByRole("button", { name: "base" }), + ).toBeVisible(); + }); + test("User removes node and all child tokens", async ({ page }) => { const { tokensSidebar } = await setupTokensFileRender(page); diff --git a/frontend/playwright/ui/specs/versions.spec.js b/frontend/playwright/ui/specs/versions.spec.js index 22fbdc08be..98bb92acb0 100644 --- a/frontend/playwright/ui/specs/versions.spec.js +++ b/frontend/playwright/ui/specs/versions.spec.js @@ -87,6 +87,9 @@ test("Save and restore version", async ({ page }) => { "workspace/versions-restore-snapshot-1.json", ); + await workspacePage.mockRPC(/get\-file\?/, "workspace/versions-init-2.json"); + + await page.getByRole("button", { name: "Open version menu" }).click(); await page.getByRole("button", { name: "Restore" }).click(); await page.getByRole("button", { name: "Restore" }).click(); @@ -118,6 +121,11 @@ test("BUG 13385 - Fix viewport not updating when restoring version", async ({ pa await workspacePage.mockGetFile("workspace/get-file-13385.json"); await workspacePage.mockRPC("get-profiles-for-file-comments?file-id=*", "workspace/get-profiles-for-file-comments-13385.json"); + await workspacePage.mockRPC( + "update-file?id=*", + "workspace/update-file-empty.json", + ); + // navigate to workspace and check that the circle shape is not there await workspacePage.goToWorkspace(); await expect(workspacePage.layers.getByText("Ellipse")).not.toBeVisible(); @@ -138,4 +146,4 @@ test("BUG 13385 - Fix viewport not updating when restoring version", async ({ pa // assert that the circle shape exists await expect(workspacePage.layers.getByText("Ellipse")).toBeVisible(); -}); \ No newline at end of file +}); diff --git a/frontend/playwright/ui/specs/workspace-modifers.spec.js b/frontend/playwright/ui/specs/workspace-modifers.spec.js index 8e5f871fd8..bbea6199f8 100644 --- a/frontend/playwright/ui/specs/workspace-modifers.spec.js +++ b/frontend/playwright/ui/specs/workspace-modifers.spec.js @@ -3,13 +3,17 @@ import { WasmWorkspacePage } from "../pages/WasmWorkspacePage"; test.beforeEach(async ({ page }) => { await WasmWorkspacePage.init(page); + await WasmWorkspacePage.mockConfigFlags(page, ["enable-feature-token-input"]); }); test("BUG 13305 - Fix resize board to fit content", async ({ page }) => { const workspacePage = new WasmWorkspacePage(page); await workspacePage.setupEmptyFile(); await workspacePage.mockGetFile("workspace/get-file-13305.json"); - await workspacePage.mockRPC("update-file?id=*", "workspace/update-file-13305.json"); + await workspacePage.mockRPC( + "update-file?id=*", + "workspace/update-file-13305.json", + ); await workspacePage.goToWorkspace({ fileId: "9666e946-78e8-8111-8007-8fe5f0f454bf", @@ -17,12 +21,42 @@ test("BUG 13305 - Fix resize board to fit content", async ({ page }) => { }); await workspacePage.clickLeafLayer("Board"); - await workspacePage.rightSidebar.getByRole("button", { name: "Resize board to fit content" }).click(); + await workspacePage.rightSidebar + .getByRole("button", { name: "Resize board to fit content" }) + .click(); - await expect(workspacePage.rightSidebar.getByTitle("Width").getByRole("textbox")).toHaveValue("630"); - await expect(workspacePage.rightSidebar.getByTitle("Height").getByRole("textbox")).toHaveValue("630"); - await expect(workspacePage.rightSidebar.getByTitle("X axis").getByRole("textbox")).toHaveValue("110"); - await expect(workspacePage.rightSidebar.getByTitle("Y axis").getByRole("textbox")).toHaveValue("110"); + const measuresSection = workspacePage.rightSidebar.getByRole("region", { + name: "shape-measures-section", + }); + await expect(measuresSection).toBeVisible(); + + // Width + const widthInput = measuresSection.getByRole("textbox", { + name: "Width", + exact: true, + }); + await expect(widthInput).toHaveValue("630"); + + // Height + const heightInput = measuresSection.getByRole("textbox", { + name: "Height", + exact: true, + }); + await expect(heightInput).toHaveValue("630"); + + // X Position (using "X axis" title) + const xPosInput = measuresSection.getByRole("textbox", { + name: "X axis", + exact: true, + }); + await expect(xPosInput).toHaveValue("110"); + + // Y Position (using "Y axis" title) + const yPosInput = measuresSection.getByRole("textbox", { + name: "Y axis", + exact: true, + }); + await expect(yPosInput).toHaveValue("110"); }); test("BUG 13382 - Fix problem with flex layout", async ({ page }) => { @@ -35,7 +69,10 @@ test("BUG 13382 - Fix problem with flex layout", async ({ page }) => { "workspace/get-file-13382-fragment.json", ); - await workspacePage.mockRPC("update-file?id=*", "workspace/update-file-empty.json"); + await workspacePage.mockRPC( + "update-file?id=*", + "workspace/update-file-empty.json", + ); await workspacePage.goToWorkspace({ fileId: "52c4e771-3853-8190-8007-9506c70e8100", @@ -47,13 +84,26 @@ test("BUG 13382 - Fix problem with flex layout", async ({ page }) => { await workspacePage.clickToggableLayer("C"); await workspacePage.clickLeafLayer("R2"); - const heightText = workspacePage.rightSidebar.getByTitle("Height").getByPlaceholder('--'); - await heightText.fill("200"); - await heightText.press("Enter"); + const measuresSection = workspacePage.rightSidebar.getByRole("region", { + name: "shape-measures-section", + }); + await expect(measuresSection).toBeVisible(); + + const heightInput = measuresSection.getByRole("textbox", { + name: "Height", + exact: true, + }); + await heightInput.fill("200"); + await heightInput.press("Enter"); await workspacePage.clickLeafLayer("B"); - await expect(workspacePage.rightSidebar.getByTitle("Height").getByRole("textbox")).toHaveValue("340"); + // Width + const widthInput = measuresSection.getByRole("textbox", { + name: "Width", + exact: true, + }); + await expect(widthInput).toHaveValue("393"); }); test("BUG 13468 - Fix problem with flex propagation", async ({ page }) => { @@ -66,7 +116,10 @@ test("BUG 13468 - Fix problem with flex propagation", async ({ page }) => { "workspace/get-file-13468-fragment.json", ); - await workspacePage.mockRPC("update-file?id=*", "workspace/update-file-empty.json"); + await workspacePage.mockRPC( + "update-file?id=*", + "workspace/update-file-empty.json", + ); await workspacePage.goToWorkspace({ fileId: "3a4d7ec7-c391-8146-8007-9a05c41da6b9", @@ -76,10 +129,21 @@ test("BUG 13468 - Fix problem with flex propagation", async ({ page }) => { await workspacePage.clickToggableLayer("Parent"); await workspacePage.clickToggableLayer("Container"); - await workspacePage.sidebar.getByRole('button', { name: 'Show' }).click(); + await workspacePage.sidebar.getByRole("button", { name: "Show" }).click(); await workspacePage.clickLeafLayer("Container"); - await expect(workspacePage.rightSidebar.getByTitle("Height").getByRole("textbox")).toHaveValue("76"); + + const measuresSection = workspacePage.rightSidebar.getByRole("region", { + name: "shape-measures-section", + }); + await expect(measuresSection).toBeVisible(); + + const heightInput = measuresSection.getByRole("textbox", { + name: "Height", + exact: true, + }); + + await expect(heightInput).toHaveValue("76"); }); test("BUG 13272 - Fix problem with snap to pixel", async ({ page }) => { @@ -92,7 +156,10 @@ test("BUG 13272 - Fix problem with snap to pixel", async ({ page }) => { "workspace/get-file-13272-fragment.json", ); - await workspacePage.mockRPC("update-file?id=*", "workspace/update-file-empty.json"); + await workspacePage.mockRPC( + "update-file?id=*", + "workspace/update-file-empty.json", + ); await workspacePage.goToWorkspace({ fileId: "3b9773cc-d4f1-81e1-8007-b3f8dcaba770", @@ -102,15 +169,31 @@ test("BUG 13272 - Fix problem with snap to pixel", async ({ page }) => { await workspacePage.clickToggableLayer("Group"); await workspacePage.clickLeafLayer("Group"); - await workspacePage.page.locator('g:nth-child(11) > .cursor-resize-nesw-0').hover(); + await workspacePage.page + .locator("g:nth-child(11) > .cursor-resize-nesw-0") + .hover(); await workspacePage.page.mouse.down(); await workspacePage.page.mouse.move(1200, 800); await workspacePage.page.mouse.up(); await workspacePage.clickLeafLayer("Rectangle"); - await expect(workspacePage.rightSidebar.getByTitle("Width").getByRole("textbox")).toHaveValue("197.5"); - await expect(workspacePage.rightSidebar.getByTitle("Height").getByRole("textbox")).toHaveValue("128.28"); + + const measuresSection = workspacePage.rightSidebar.getByRole("region", { + name: "shape-measures-section", + }); + await expect(measuresSection).toBeVisible(); + + const heightInput = measuresSection.getByRole("textbox", { + name: "Height", + exact: true, + }); + const widthInput = measuresSection.getByRole("textbox", { + name: "Width", + exact: true, + }); + await expect(widthInput).toHaveValue("197.5"); + await expect(heightInput).toHaveValue("128.28"); }); test("BUG 13755 - Fix problem with text change modiifers", async ({ page }) => { @@ -123,7 +206,10 @@ test("BUG 13755 - Fix problem with text change modiifers", async ({ page }) => { "workspace/get-file-13755-fragment.json", ); - await workspacePage.mockRPC("update-file?id=*", "workspace/update-file-empty.json"); + await workspacePage.mockRPC( + "update-file?id=*", + "workspace/update-file-empty.json", + ); await workspacePage.goToWorkspace({ fileId: "7fd33337-c651-80ae-8007-c357213f876e", @@ -133,9 +219,19 @@ test("BUG 13755 - Fix problem with text change modiifers", async ({ page }) => { await workspacePage.clickToggableLayer("Board"); await workspacePage.clickLeafLayer("uno dos tres cuatro"); - await workspacePage.page.keyboard.press('Enter'); - await workspacePage.page.keyboard.type('test'); + await workspacePage.page.keyboard.press("Enter"); + await workspacePage.page.keyboard.type("test"); await workspacePage.clickToggableLayer("Board"); - await expect(workspacePage.rightSidebar.getByTitle("Width").getByRole("textbox")).toHaveValue("23"); + + const measuresSection = workspacePage.rightSidebar.getByRole("region", { + name: "shape-measures-section", + }); + await expect(measuresSection).toBeVisible(); + + const widthInput = measuresSection.getByRole("textbox", { + name: "Width", + exact: true, + }); + await expect(widthInput).toHaveValue("23"); }); diff --git a/frontend/playwright/ui/specs/workspace.spec.js b/frontend/playwright/ui/specs/workspace.spec.js index e6169e92c8..6006d888ab 100644 --- a/frontend/playwright/ui/specs/workspace.spec.js +++ b/frontend/playwright/ui/specs/workspace.spec.js @@ -78,6 +78,32 @@ test("User draws a rect", async ({ page }) => { await expect(workspacePage.canvas).toHaveScreenshot(); }); +test("Selection size badge appears on selection and hides on deselect", async ({ + page, +}) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.mockRPC( + /get\-file\?/, + "workspace/get-file-not-empty.json", + ); + + await workspacePage.goToWorkspace({ + fileId: "6191cd35-bb1f-81f7-8004-7cc63d087374", + pageId: "6191cd35-bb1f-81f7-8004-7cc63d087375", + }); + + const badge = page.locator(".selection-size-badge"); + + await expect(badge).toHaveCount(0); + + await workspacePage.clickLeafLayer("Rectangle"); + await expect(badge).toBeVisible(); + + await workspacePage.page.keyboard.press("Escape"); + await expect(badge).toHaveCount(0); +}); + test("User makes a group", async ({ page }) => { const workspacePage = new WasmWorkspacePage(page); await workspacePage.setupEmptyFile(); diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 5fa2751411..04852ba1b0 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -35,41 +35,41 @@ importers: specifier: workspace:./packages/ui version: link:packages/ui '@playwright/test': - specifier: 1.59.1 - version: 1.59.1 + specifier: 1.60.0 + version: 1.60.0 '@storybook/addon-docs': specifier: 10.3.5 - version: 10.3.5(@types/react@19.2.14)(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2)) + version: 10.3.5(@types/react@19.2.14)(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2)) '@storybook/addon-themes': specifier: 10.3.5 version: 10.3.5(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) '@storybook/addon-vitest': specifier: 10.3.5 - version: 10.3.5(@vitest/browser-playwright@4.1.3)(@vitest/browser@4.1.3)(@vitest/runner@4.1.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vitest@4.1.3) + version: 10.3.5(@vitest/browser-playwright@4.1.6)(@vitest/browser@4.1.3)(@vitest/runner@4.1.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vitest@4.1.6) '@storybook/react-vite': specifier: 10.3.5 - version: 10.3.5(esbuild@0.28.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2)(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2)) + version: 10.3.5(esbuild@0.28.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.3)(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2)) '@tokens-studio/sd-transforms': specifier: 1.2.11 version: 1.2.11(style-dictionary@5.0.0-rc.1(tslib@2.8.1)) '@types/node': specifier: ^25.5.2 - version: 25.5.2 + version: 25.7.0 '@vitest/browser': specifier: 4.1.3 - version: 4.1.3(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2))(vitest@4.1.3) + version: 4.1.3(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2))(vitest@4.1.6) '@vitest/browser-playwright': specifier: ^4.1.3 - version: 4.1.3(playwright@1.59.1)(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2))(vitest@4.1.3) + version: 4.1.6(playwright@1.60.0)(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2))(vitest@4.1.6) '@vitest/coverage-v8': specifier: 4.1.3 - version: 4.1.3(@vitest/browser@4.1.3)(vitest@4.1.3) + version: 4.1.3(@vitest/browser@4.1.3)(vitest@4.1.6) '@zip.js/zip.js': specifier: 2.8.26 version: 2.8.26(patch_hash=7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95) autoprefixer: specifier: ^10.4.27 - version: 10.4.27(postcss@8.5.8) + version: 10.5.0(postcss@8.5.14) compression: specifier: ^1.8.1 version: 1.8.1 @@ -83,8 +83,8 @@ importers: specifier: ^0.28.0 version: 0.28.0 eventsource-parser: - specifier: ^3.0.6 - version: 3.0.6 + specifier: ^3.0.8 + version: 3.0.8 express: specifier: ^5.1.0 version: 5.2.1 @@ -105,7 +105,7 @@ importers: version: 1.15.4 jsdom: specifier: ^29.0.2 - version: 29.0.2(canvas@3.2.1) + version: 29.1.1(canvas@3.2.1) lodash: specifier: ^4.18.1 version: 4.18.1 @@ -117,7 +117,7 @@ importers: version: 0.0.7 marked: specifier: ^17.0.5 - version: 17.0.5 + version: 17.0.6 mkdirp: specifier: ^3.0.1 version: 3.0.1 @@ -137,20 +137,20 @@ importers: specifier: ^7.3.0 version: 7.3.0 playwright: - specifier: 1.59.1 - version: 1.59.1 + specifier: 1.60.0 + version: 1.60.0 postcss: specifier: ^8.5.8 - version: 8.5.8 + version: 8.5.14 postcss-clean: specifier: ^1.2.2 version: 1.2.2 postcss-modules: specifier: ^6.0.1 - version: 6.0.1(postcss@8.5.8) + version: 6.0.1(postcss@8.5.14) postcss-scss: specifier: ^4.0.9 - version: 4.0.9(postcss@8.5.8) + version: 4.0.9(postcss@8.5.14) prettier: specifier: 3.8.1 version: 3.8.1 @@ -183,10 +183,10 @@ importers: version: 8.0.0-alpha.14 sass: specifier: ^1.98.0 - version: 1.98.0 + version: 1.99.0 sass-embedded: specifier: ^1.98.0 - version: 1.98.0 + version: 1.99.0 sax: specifier: ^1.4.1 version: 1.4.4 @@ -204,16 +204,16 @@ importers: version: 5.0.0-rc.1(tslib@2.8.1) stylelint: specifier: ^17.4.0 - version: 17.4.0(typescript@6.0.2) + version: 17.11.0(typescript@6.0.3) stylelint-config-standard-scss: specifier: ^17.0.0 - version: 17.0.0(postcss@8.5.8)(stylelint@17.4.0(typescript@6.0.2)) + version: 17.0.0(postcss@8.5.14)(stylelint@17.11.0(typescript@6.0.3)) stylelint-scss: specifier: ^7.0.0 - version: 7.0.0(stylelint@17.4.0(typescript@6.0.2)) + version: 7.1.1(stylelint@17.11.0(typescript@6.0.3)) stylelint-use-logical-spec: specifier: ^5.0.1 - version: 5.0.1(stylelint@17.4.0(typescript@6.0.2)) + version: 5.0.1(stylelint@17.11.0(typescript@6.0.3)) svg-sprite: specifier: ^2.0.4 version: 2.0.4 @@ -225,19 +225,19 @@ importers: version: 1.6.0 typescript: specifier: ^6.0.2 - version: 6.0.2 + version: 6.0.3 ua-parser-js: specifier: 2.0.9 version: 2.0.9 vite: specifier: ^8.0.7 - version: 8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) + version: 8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2) vitest: specifier: ^4.1.3 - version: 4.1.3(@types/node@25.5.2)(@vitest/browser-playwright@4.1.3)(@vitest/coverage-v8@4.1.3)(jsdom@29.0.2(canvas@3.2.1))(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2)) + version: 4.1.6(@types/node@25.7.0)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.3)(jsdom@29.1.1(canvas@3.2.1))(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2)) wait-on: specifier: ^9.0.4 - version: 9.0.4 + version: 9.0.10 wasm-pack: specifier: ^0.13.1 version: 0.13.1 @@ -246,7 +246,7 @@ importers: version: 2.3.1 workerpool: specifier: ^10.0.1 - version: 10.0.1 + version: 10.0.2 xregexp: specifier: ^5.1.2 version: 5.1.2 @@ -282,10 +282,10 @@ importers: dependencies: react: specifier: '>=19.2' - version: 19.2.4 + version: 19.2.3 react-dom: specifier: '>=19.2' - version: 19.2.4(react@19.2.4) + version: 19.2.3(react@19.2.3) devDependencies: '@babel/core': specifier: ^7.14.5 @@ -295,16 +295,16 @@ importers: version: 7.28.5(@babel/core@7.29.0) '@storybook/react': specifier: 10.3.5 - version: 10.3.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2) + version: 10.3.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@6.0.3) '@storybook/react-vite': specifier: 10.3.5 - version: 10.3.5(esbuild@0.28.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2)(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2)) + version: 10.3.5(esbuild@0.28.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@6.0.3)(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2)) '@testing-library/dom': specifier: 10.4.1 version: 10.4.1 '@testing-library/react': specifier: 16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@types/react': specifier: ^19.2.14 version: 19.2.14 @@ -313,7 +313,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2)) + version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2)) babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 @@ -331,13 +331,13 @@ importers: version: 7.0.1(eslint@9.39.2) react-compiler-runtime: specifier: ^1.0.0 - version: 1.0.0(react@19.2.4) + version: 1.0.0(react@19.2.3) storybook: specifier: 10.3.5 - version: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) vite-plugin-dts: specifier: ^4.5.4 - version: 4.5.4(@types/node@25.5.2)(rollup@4.57.1)(typescript@6.0.2)(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2)) + version: 4.5.4(@types/node@25.7.0)(rollup@4.57.1)(typescript@6.0.3)(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2)) text-editor: devDependencies: @@ -373,10 +373,10 @@ importers: version: 3.8.1 vite: specifier: ^5.3.1 - version: 5.4.21(@types/node@25.2.1)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0) + version: 5.4.21(@types/node@25.2.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0) vitest: specifier: ^1.6.0 - version: 1.6.1(@types/node@25.2.1)(@vitest/browser@1.6.1)(@vitest/ui@1.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0) + version: 1.6.1(@types/node@25.2.1)(@vitest/browser@1.6.1)(@vitest/ui@1.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0) packages: @@ -399,15 +399,19 @@ packages: '@asamuzakjp/css-color@4.1.2': resolution: {integrity: sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==} - '@asamuzakjp/css-color@5.1.6': - resolution: {integrity: sha512-BXWCh8dHs9GOfpo/fWGDJtDmleta2VePN9rn6WQt3GjEbxzutVF4t0x2pmH+7dbMCLtuv3MlwqRsAuxlzFXqFg==} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} '@asamuzakjp/dom-selector@6.7.8': resolution: {integrity: sha512-stisC1nULNc9oH5lakAj8MH88ZxeGxzyWNDfbdCxvJSJIvDsHNZqYvscGTgy/ysgXWLJPt6K/4t0/GjvtKcFJQ==} - '@asamuzakjp/dom-selector@7.0.8': - resolution: {integrity: sha512-erMO6FgtM02dC24NGm0xufMzWz5OF0wXKR7BpvGD973bq/GbmR8/DbxNZbj0YevQ5hlToJaWSVK/G9/NDgGEVw==} + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} '@asamuzakjp/nwsapi@2.3.9': @@ -476,11 +480,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@7.29.2': - resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/plugin-syntax-jsx@7.28.6': resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} engines: {node: '>=6.9.0'} @@ -525,10 +524,6 @@ packages: resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} - '@babel/runtime@7.29.2': - resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} - engines: {node: '>=6.9.0'} - '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -573,8 +568,8 @@ packages: '@cacheable/memory@2.0.8': resolution: {integrity: sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==} - '@cacheable/utils@2.4.0': - resolution: {integrity: sha512-PeMMsqjVq+bF0WBsxFBxr/WozBJiZKY0rUojuaCoIaKnEl3Ju1wfEwS+SV1DU/cSe8fqHIPiYJFif8T3MVt4cQ==} + '@cacheable/utils@2.4.1': + resolution: {integrity: sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==} '@colors/colors@1.6.0': resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} @@ -595,8 +590,8 @@ packages: '@csstools/css-parser-algorithms': ^4.0.0 '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-calc@3.1.1': - resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} engines: {node: '>=20.19.0'} peerDependencies: '@csstools/css-parser-algorithms': ^4.0.0 @@ -609,8 +604,8 @@ packages: '@csstools/css-parser-algorithms': ^4.0.0 '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-color-parser@4.0.2': - resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + '@csstools/css-color-parser@4.1.1': + resolution: {integrity: sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==} engines: {node: '>=20.19.0'} peerDependencies: '@csstools/css-parser-algorithms': ^4.0.0 @@ -625,11 +620,8 @@ packages: '@csstools/css-syntax-patches-for-csstree@1.0.26': resolution: {integrity: sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA==} - '@csstools/css-syntax-patches-for-csstree@1.1.0': - resolution: {integrity: sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==} - - '@csstools/css-syntax-patches-for-csstree@1.1.2': - resolution: {integrity: sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==} + '@csstools/css-syntax-patches-for-csstree@1.1.4': + resolution: {integrity: sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==} peerDependencies: css-tree: ^3.2.1 peerDependenciesMeta: @@ -662,14 +654,14 @@ packages: '@dabh/diagnostics@2.0.8': resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} - '@emnapi/core@1.9.1': - resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - '@emnapi/runtime@1.9.1': - resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - '@emnapi/wasi-threads@1.2.0': - resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} @@ -683,8 +675,8 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.27.7': - resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] @@ -707,8 +699,8 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.27.7': - resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} engines: {node: '>=18'} cpu: [arm64] os: [android] @@ -731,8 +723,8 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.27.7': - resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} engines: {node: '>=18'} cpu: [arm] os: [android] @@ -755,8 +747,8 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.27.7': - resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} engines: {node: '>=18'} cpu: [x64] os: [android] @@ -779,8 +771,8 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.27.7': - resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] @@ -803,8 +795,8 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.27.7': - resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] @@ -827,8 +819,8 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.27.7': - resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] @@ -851,8 +843,8 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.7': - resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] @@ -875,8 +867,8 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.27.7': - resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} engines: {node: '>=18'} cpu: [arm64] os: [linux] @@ -899,8 +891,8 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.27.7': - resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} engines: {node: '>=18'} cpu: [arm] os: [linux] @@ -923,8 +915,8 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.27.7': - resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] @@ -947,8 +939,8 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.27.7': - resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] @@ -971,8 +963,8 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.27.7': - resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] @@ -995,8 +987,8 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.27.7': - resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] @@ -1019,8 +1011,8 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.27.7': - resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] @@ -1043,8 +1035,8 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.27.7': - resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} engines: {node: '>=18'} cpu: [s390x] os: [linux] @@ -1067,8 +1059,8 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.27.7': - resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} engines: {node: '>=18'} cpu: [x64] os: [linux] @@ -1085,8 +1077,8 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.27.7': - resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] @@ -1109,8 +1101,8 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.7': - resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] @@ -1127,8 +1119,8 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.27.7': - resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] @@ -1151,8 +1143,8 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.7': - resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] @@ -1169,8 +1161,8 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.27.7': - resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] @@ -1193,8 +1185,8 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.27.7': - resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] @@ -1217,8 +1209,8 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.27.7': - resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] @@ -1241,8 +1233,8 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.27.7': - resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] @@ -1265,8 +1257,8 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.27.7': - resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -1346,19 +1338,23 @@ packages: '@hapi/pinpoint@2.0.1': resolution: {integrity: sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==} - '@hapi/tlds@1.1.6': - resolution: {integrity: sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==} + '@hapi/tlds@1.1.4': + resolution: {integrity: sha512-Fq+20dxsxLaUn5jSSWrdtSRcIUba2JquuorF9UW1wIJS5cSUwxIsO2GIhaWynPRflvxSzFN+gxKte2HEW1OuoA==} engines: {node: '>=14.0.0'} '@hapi/topo@6.0.2': resolution: {integrity: sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==} - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} - '@humanfs/node@0.16.7': - resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} engines: {node: '>=18.18.0'} '@humanwhocodes/module-importer@1.0.1': @@ -1562,8 +1558,8 @@ packages: '@microsoft/tsdoc@0.16.0': resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} - '@napi-rs/wasm-runtime@1.1.2': - resolution: {integrity: sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==} + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 @@ -1583,8 +1579,8 @@ packages: '@one-ini/wasm@0.1.1': resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} - '@oxc-project/types@0.123.0': - resolution: {integrity: sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==} + '@oxc-project/types@0.129.0': + resolution: {integrity: sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==} '@parcel/watcher-android-arm64@2.5.6': resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} @@ -1683,8 +1679,8 @@ packages: engines: {node: '>=18'} hasBin: true - '@playwright/test@1.59.1': - resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} engines: {node: '>=18'} hasBin: true @@ -1771,103 +1767,103 @@ packages: resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==} engines: {node: '>= 10'} - '@rolldown/binding-android-arm64@1.0.0-rc.13': - resolution: {integrity: sha512-5ZiiecKH2DXAVJTNN13gNMUcCDg4Jy8ZjbXEsPnqa248wgOVeYRX0iqXXD5Jz4bI9BFHgKsI2qmyJynstbmr+g==} + '@rolldown/binding-android-arm64@1.0.0': + resolution: {integrity: sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.0-rc.13': - resolution: {integrity: sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA==} + '@rolldown/binding-darwin-arm64@1.0.0': + resolution: {integrity: sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-rc.13': - resolution: {integrity: sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug==} + '@rolldown/binding-darwin-x64@1.0.0': + resolution: {integrity: sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.0-rc.13': - resolution: {integrity: sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA==} + '@rolldown/binding-freebsd-x64@1.0.0': + resolution: {integrity: sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.13': - resolution: {integrity: sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0': + resolution: {integrity: sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.13': - resolution: {integrity: sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg==} + '@rolldown/binding-linux-arm64-gnu@1.0.0': + resolution: {integrity: sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.13': - resolution: {integrity: sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA==} + '@rolldown/binding-linux-arm64-musl@1.0.0': + resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.13': - resolution: {integrity: sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ==} + '@rolldown/binding-linux-ppc64-gnu@1.0.0': + resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.13': - resolution: {integrity: sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg==} + '@rolldown/binding-linux-s390x-gnu@1.0.0': + resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.13': - resolution: {integrity: sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA==} + '@rolldown/binding-linux-x64-gnu@1.0.0': + resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.0.0-rc.13': - resolution: {integrity: sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w==} + '@rolldown/binding-linux-x64-musl@1.0.0': + resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.0.0-rc.13': - resolution: {integrity: sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w==} + '@rolldown/binding-openharmony-arm64@1.0.0': + resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.0-rc.13': - resolution: {integrity: sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g==} - engines: {node: '>=14.0.0'} + '@rolldown/binding-wasm32-wasi@1.0.0': + resolution: {integrity: sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.13': - resolution: {integrity: sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ==} + '@rolldown/binding-win32-arm64-msvc@1.0.0': + resolution: {integrity: sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.13': - resolution: {integrity: sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ==} + '@rolldown/binding-win32-x64-msvc@1.0.0': + resolution: {integrity: sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-rc.13': - resolution: {integrity: sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==} + '@rolldown/pluginutils@1.0.0': + resolution: {integrity: sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==} '@rolldown/pluginutils@1.0.0-rc.7': resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} @@ -2199,8 +2195,8 @@ packages: resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} - '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} '@types/argparse@1.0.38': resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} @@ -2232,6 +2228,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2241,14 +2240,14 @@ packages: '@types/mdx@2.0.13': resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} - '@types/node@22.19.17': - resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + '@types/node@22.19.9': + resolution: {integrity: sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==} '@types/node@25.2.1': resolution: {integrity: sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==} - '@types/node@25.5.2': - resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==} + '@types/node@25.7.0': + resolution: {integrity: sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==} '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} @@ -2277,11 +2276,11 @@ packages: babel-plugin-react-compiler: optional: true - '@vitest/browser-playwright@4.1.3': - resolution: {integrity: sha512-D3Q+YozvSpiFaLPgd6/OMbyqEIZeucSe6AHJJ7VnNJKQhIyBE60TlBZlxzwM8bvjzQE9ZnYWQCPeCw5pnhbiNg==} + '@vitest/browser-playwright@4.1.6': + resolution: {integrity: sha512-4csoeyl/qwHyxU2zNL0++WaoDr8YJDXOQPwWPNJoTZ+QzcdO3INYKgF5Zfz730Io7zbkuv914aZmfQ+QE+1Hvw==} peerDependencies: playwright: '*' - vitest: 4.1.3 + vitest: 4.1.6 '@vitest/browser@1.6.1': resolution: {integrity: sha512-9ZYW6KQ30hJ+rIfJoGH4wAub/KAb4YrFzX0kVLASvTm7nJWVC5EAv5SlzlXVl3h3DaUq5aqHlZl77nmOPnALUQ==} @@ -2303,6 +2302,11 @@ packages: peerDependencies: vitest: 4.1.3 + '@vitest/browser@4.1.6': + resolution: {integrity: sha512-ynsspTubXGSpa58JFJ24xIQt4z4A25epSbugEyaTmmrV1//Wec9EgE/LtoaC6yxUrXi5P7erGHRrkdZIHaVQuA==} + peerDependencies: + vitest: 4.1.6 + '@vitest/coverage-v8@1.6.1': resolution: {integrity: sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==} peerDependencies: @@ -2323,8 +2327,8 @@ packages: '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} - '@vitest/expect@4.1.3': - resolution: {integrity: sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==} + '@vitest/expect@4.1.6': + resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} '@vitest/mocker@4.1.3': resolution: {integrity: sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==} @@ -2337,23 +2341,37 @@ packages: vite: optional: true + '@vitest/mocker@4.1.6': + resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} '@vitest/pretty-format@4.1.3': resolution: {integrity: sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==} + '@vitest/pretty-format@4.1.6': + resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} + '@vitest/runner@1.6.1': resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} - '@vitest/runner@4.1.3': - resolution: {integrity: sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==} + '@vitest/runner@4.1.6': + resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==} '@vitest/snapshot@1.6.1': resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==} - '@vitest/snapshot@4.1.3': - resolution: {integrity: sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==} + '@vitest/snapshot@4.1.6': + resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==} '@vitest/spy@1.6.1': resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==} @@ -2364,6 +2382,9 @@ packages: '@vitest/spy@4.1.3': resolution: {integrity: sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==} + '@vitest/spy@4.1.6': + resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} + '@vitest/ui@1.6.1': resolution: {integrity: sha512-xa57bCPGuzEFqGjPs3vVLyqareG8DX0uMkr5U/v5vLv5/ZUrBrPL7gzxzTJedEyZxFMfsozwTIbbYfEQVo3kgg==} peerDependencies: @@ -2378,6 +2399,9 @@ packages: '@vitest/utils@4.1.3': resolution: {integrity: sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==} + '@vitest/utils@4.1.6': + resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} + '@volar/language-core@2.4.28': resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} @@ -2448,6 +2472,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -2468,8 +2496,8 @@ packages: ajv: optional: true - ajv@6.14.0: - resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} ajv@8.12.0: resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} @@ -2596,8 +2624,8 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - autoprefixer@10.4.27: - resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} + autoprefixer@10.5.0: + resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: @@ -2607,15 +2635,15 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - axe-core@4.11.2: - resolution: {integrity: sha512-byD6KPdvo72y/wj2T/4zGEvvlis+PsZsn/yPS3pEO+sFpcrqRpX/TJCxvVaEsNeMrfQbCr7w163YqoD9IYwHXw==} + axe-core@4.11.1: + resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} engines: {node: '>=4'} axios@0.26.1: resolution: {integrity: sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==} - axios@1.14.0: - resolution: {integrity: sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==} + axios@1.16.1: + resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} @@ -2634,8 +2662,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.13: - resolution: {integrity: sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==} + baseline-browser-mapping@2.10.29: + resolution: {integrity: sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -2677,8 +2705,8 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -2719,8 +2747,8 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} - cacheable@2.3.3: - resolution: {integrity: sha512-iffYMX4zxKp54evOH27fm92hs+DeC1DhXmNVN8Tr94M/iZIV42dqTHSR2Ik4TOSPyOAwKr7Yu3rN9ALoLkbWyQ==} + cacheable@2.3.5: + resolution: {integrity: sha512-EQfaKe09tl615iNvq/TBRWTFf1AKJNXYQSsMx0Z3EI0nA+pVsVPS8wJhnRlkbdacKPh1d0qVIhwTc2zsQNFEEg==} call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} @@ -2741,8 +2769,8 @@ packages: caniuse-lite@1.0.30001769: resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} - caniuse-lite@1.0.30001784: - resolution: {integrity: sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==} + caniuse-lite@1.0.30001792: + resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} canvas@3.2.1: resolution: {integrity: sha512-ej1sPFR5+0YWtaVp6S1N1FVz69TQCqmrkGeRvQxZeAB1nAIcjNTHVwrZtYtWFFBmQsF40/uDLehsW5KuYC99mg==} @@ -3220,8 +3248,8 @@ packages: electron-to-chromium@1.5.286: resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} - electron-to-chromium@1.5.331: - resolution: {integrity: sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==} + electron-to-chromium@1.5.355: + resolution: {integrity: sha512-LUPZhKzZPYSPme1jEYohpkA+ybYCJztr1quAdBd7E7h3+VOBVcKkwwtBJu41nrjawrRzfb8mtMfzWozoaK0ZIQ==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -3261,6 +3289,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -3280,12 +3312,12 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-iterator-helpers@1.3.1: - resolution: {integrity: sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==} + es-iterator-helpers@1.2.2: + resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} engines: {node: '>= 0.4'} - es-module-lexer@2.0.0: - resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} @@ -3313,8 +3345,8 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.27.7: - resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} engines: {node: '>=18'} hasBin: true @@ -3338,8 +3370,8 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-import-resolver-node@0.3.10: - resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} + eslint-import-resolver-node@0.3.9: + resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} eslint-module-utils@2.12.1: resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} @@ -3451,8 +3483,8 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - eventsource-parser@3.0.6: - resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + eventsource-parser@3.0.8: + resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} engines: {node: '>=18.0.0'} execa@8.0.1: @@ -3522,8 +3554,8 @@ packages: fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} - file-entry-cache@11.1.2: - resolution: {integrity: sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log==} + file-entry-cache@11.1.3: + resolution: {integrity: sha512-oMbq0PD6VIiIwMF6LIa7MEwd/l9huKwmqRKXqmrkqIZv8CvRbfowL+L0ryAl8h//HfAS0zS+4SbYoRyAoA6BJA==} file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} @@ -3548,8 +3580,8 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flat-cache@6.1.20: - resolution: {integrity: sha512-AhHYqwvN62NVLp4lObVXGVluiABTHapoB57EyegZVmazN+hhGhLTn3uZbOofoTw4DSDvVCadzzyChXhOAvy8uQ==} + flat-cache@6.1.22: + resolution: {integrity: sha512-N2dnzVJIphnNsjHcrxGW7DePckJ6haPrSFqpsBUhHYgwtKGVq4JrBGielEGD2fCVnsGm1zlBVZ8wGhkyuetgug==} flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} @@ -3569,6 +3601,15 @@ packages: debug: optional: true + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -3645,8 +3686,8 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.5.0: - resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} engines: {node: '>=18'} get-func-name@2.0.2: @@ -3697,6 +3738,10 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@13.0.1: + resolution: {integrity: sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==} + engines: {node: 20 || >=22} + glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -3721,8 +3766,8 @@ packages: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} - globby@16.1.1: - resolution: {integrity: sha512-dW7vl+yiAJSp6aCekaVnVJxurRv7DCOLyXqEG3RYMYUg7AuJ2jCqPkZTA8ooqC2vtnkaMcV5WfFBMuEnTu1OQg==} + globby@16.2.0: + resolution: {integrity: sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==} engines: {node: '>=20'} globjoin@0.1.4: @@ -3766,8 +3811,8 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} - hashery@1.5.0: - resolution: {integrity: sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==} + hashery@1.5.1: + resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} engines: {node: '>=20'} hasown@2.0.2: @@ -3791,6 +3836,9 @@ packages: hookified@1.15.1: resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + hookified@2.2.0: + resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==} + hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -3813,6 +3861,10 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -4112,8 +4164,8 @@ packages: jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} - joi@18.1.2: - resolution: {integrity: sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA==} + joi@18.2.1: + resolution: {integrity: sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==} engines: {node: '>= 20'} js-beautify@1.15.4: @@ -4147,8 +4199,8 @@ packages: canvas: optional: true - jsdom@29.0.2: - resolution: {integrity: sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==} + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} peerDependencies: canvas: ^3.0.0 @@ -4374,12 +4426,8 @@ packages: resolution: {integrity: sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==} engines: {node: 20 || >=22} - lru-cache@11.2.7: - resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} - engines: {node: 20 || >=22} - - lru-cache@11.3.2: - resolution: {integrity: sha512-wgWa6FWQ3QRRJbIjbsldRJZxdxYngT/dO0I5Ynmlnin8qy7tC6xYzbcJjtN4wHLXtkbVwHzk0C+OejVw1XM+DQ==} + lru-cache@11.3.6: + resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} engines: {node: 20 || >=22} lru-cache@5.1.1: @@ -4409,8 +4457,8 @@ packages: map-stream@0.0.7: resolution: {integrity: sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==} - marked@17.0.5: - resolution: {integrity: sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==} + marked@17.0.6: + resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==} engines: {node: '>= 20'} hasBin: true @@ -4600,10 +4648,6 @@ packages: node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} - node-exports-info@1.6.0: - resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} - engines: {node: '>= 0.4'} - node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -4616,8 +4660,8 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - node-releases@2.0.37: - resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + node-releases@2.0.44: + resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==} nodemon@3.1.14: resolution: {integrity: sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==} @@ -4760,6 +4804,9 @@ packages: parse5@8.0.0: resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -4799,6 +4846,10 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.1: + resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} + engines: {node: 20 || >=22} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -4839,10 +4890,6 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} - picomatch@4.0.3: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} @@ -4871,8 +4918,8 @@ packages: engines: {node: '>=18'} hasBin: true - playwright-core@1.59.1: - resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} engines: {node: '>=18'} hasBin: true @@ -4881,8 +4928,8 @@ packages: engines: {node: '>=18'} hasBin: true - playwright@1.59.1: - resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} engines: {node: '>=18'} hasBin: true @@ -4963,14 +5010,13 @@ packages: resolution: {integrity: sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==} engines: {node: '>=4.0.0'} - postcss@8.5.8: - resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} - deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true prelude-ls@1.2.1: @@ -5040,8 +5086,8 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qified@0.6.0: - resolution: {integrity: sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==} + qified@0.10.1: + resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} engines: {node: '>=20'} qs@6.14.1: @@ -5079,8 +5125,8 @@ packages: peerDependencies: typescript: '>= 4.3.x' - react-docgen@8.0.3: - resolution: {integrity: sha512-aEZ9qP+/M+58x2qgfSFEWH1BxLyHe5+qkLNJOZQb5iGS017jpbRnoKhNRrXPeA6RfBrZO5wZrT9DMC1UqE1f1w==} + react-docgen@8.0.2: + resolution: {integrity: sha512-+NRMYs2DyTP4/tqWz371Oo50JqmWltR1h2gcdgUMAWZJIAvrd0/SqlCfx7tpzpl/s36rzw6qH2MjoNrxtRNYhA==} engines: {node: ^20.9.0 || >=22} react-dom@19.2.3: @@ -5187,9 +5233,8 @@ packages: engines: {node: '>= 0.4'} hasBin: true - resolve@2.0.0-next.6: - resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} - engines: {node: '>= 0.4'} + resolve@2.0.0-next.5: + resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} hasBin: true reusify@1.1.0: @@ -5206,8 +5251,8 @@ packages: engines: {node: 20 || >=22} hasBin: true - rolldown@1.0.0-rc.13: - resolution: {integrity: sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw==} + rolldown@1.0.0: + resolution: {integrity: sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -5258,125 +5303,125 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - sass-embedded-all-unknown@1.98.0: - resolution: {integrity: sha512-6n4RyK7/1mhdfYvpP3CClS3fGoYqDvRmLClCESS6I7+SAzqjxvGG6u5Fo+cb1nrPNbbilgbM4QKdgcgWHO9NCA==} + sass-embedded-all-unknown@1.99.0: + resolution: {integrity: sha512-qPIRG8Uhjo6/OKyAKixTnwMliTz+t9K6Duk0mx5z+K7n0Ts38NSJz2sjDnc7cA/8V9Lb3q09H38dZ1CLwD+ssw==} cpu: ['!arm', '!arm64', '!riscv64', '!x64'] - sass-embedded-android-arm64@1.98.0: - resolution: {integrity: sha512-M9Ra98A6vYJHpwhoC/5EuH1eOshQ9ZyNwC8XifUDSbRl/cGeQceT1NReR9wFj3L7s1pIbmes1vMmaY2np0uAKQ==} + sass-embedded-android-arm64@1.99.0: + resolution: {integrity: sha512-fNHhdnP23yqqieCbAdym4N47AleSwjbNt6OYIYx4DdACGdtERjQB4iOX/TaKsW034MupfF7SjnAAK8w7Ptldtg==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [android] - sass-embedded-android-arm@1.98.0: - resolution: {integrity: sha512-LjGiMhHgu7VL1n7EJxTCre1x14bUsWd9d3dnkS2rku003IWOI/fxc7OXgaKagoVzok1kv09rzO3vFXJR5ZeONQ==} + sass-embedded-android-arm@1.99.0: + resolution: {integrity: sha512-EHvJ0C7/VuP78Qr6f8gIUVUmCqIorEQpw2yp3cs3SMg02ZuumlhjXvkTcFBxHmFdFR23vTNk1WnhY6QSeV1nFQ==} engines: {node: '>=14.0.0'} cpu: [arm] os: [android] - sass-embedded-android-riscv64@1.98.0: - resolution: {integrity: sha512-WPe+0NbaJIZE1fq/RfCZANMeIgmy83x4f+SvFOG7LhUthHpZWcOcrPTsCKKmN3xMT3iw+4DXvqTYOCYGRL3hcQ==} + sass-embedded-android-riscv64@1.99.0: + resolution: {integrity: sha512-4zqDFRvgGDTL5vTHuIhRxUpXFoh0Cy7Gm5Ywk19ASd8Settmd14YdPRZPmMxfgS1GH292PofV1fq1ifiSEJWBw==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [android] - sass-embedded-android-x64@1.98.0: - resolution: {integrity: sha512-zrD25dT7OHPEgLWuPEByybnIfx4rnCtfge4clBgjZdZ3lF6E7qNLRBtSBmoFflh6Vg0RlEjJo5VlpnTMBM5MQQ==} + sass-embedded-android-x64@1.99.0: + resolution: {integrity: sha512-Uk53k/dGYt04RjOL4gFjZ0Z9DH9DKh8IA8WsXUkNqsxerAygoy3zqRBS2zngfE9K2jiOM87q+1R1p87ory9oQQ==} engines: {node: '>=14.0.0'} cpu: [x64] os: [android] - sass-embedded-darwin-arm64@1.98.0: - resolution: {integrity: sha512-cgr1z9rBnCdMf8K+JabIaYd9Rag2OJi5mjq08XJfbJGMZV/TA6hFJCLGkr5/+ZOn4/geTM5/3aSfQ8z5EIJAOg==} + sass-embedded-darwin-arm64@1.99.0: + resolution: {integrity: sha512-u61/7U3IGLqoO6gL+AHeiAtlTPFwJK1+964U8gp45ZN0hzh1yrARf5O1mivXv8NnNgJvbG2wWJbiNZP0lG/lTg==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [darwin] - sass-embedded-darwin-x64@1.98.0: - resolution: {integrity: sha512-OLBOCs/NPeiMqTdOrMFbVHBQFj19GS3bSVSxIhcCq16ZyhouUkYJEZjxQgzv9SWA2q6Ki8GCqp4k6jMeUY9dcA==} + sass-embedded-darwin-x64@1.99.0: + resolution: {integrity: sha512-j/kkk/NcXdIameLezSfXjgCiBkVcA+G60AXrX768/3g0miK1g7M9dj7xOhCb1i7/wQeiEI3rw2LLuO63xRIn4A==} engines: {node: '>=14.0.0'} cpu: [x64] os: [darwin] - sass-embedded-linux-arm64@1.98.0: - resolution: {integrity: sha512-axOE3t2MTBwCtkUCbrdM++Gj0gC0fdHJPrgzQ+q1WUmY9NoNMGqflBtk5mBZaWUeha2qYO3FawxCB8lctFwCtw==} + sass-embedded-linux-arm64@1.99.0: + resolution: {integrity: sha512-btNcFpItcB56L40n8hDeL7sRSMLDXQ56nB5h2deddJx1n60rpKSElJmkaDGHtpkrY+CTtDRV0FZDjHeTJddYew==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [linux] libc: glibc - sass-embedded-linux-arm@1.98.0: - resolution: {integrity: sha512-03baQZCxVyEp8v1NWBRlzGYrmVT/LK7ZrHlF1piscGiGxwfdxoLXVuxsylx3qn/dD/4i/rh7Bzk7reK1br9jvQ==} + sass-embedded-linux-arm@1.99.0: + resolution: {integrity: sha512-d4IjJZrX2+AwB2YCy1JySwdptJECNP/WfAQLUl8txI3ka8/d3TUI155GtelnoZUkio211PwIeFvvAeZ9RXPQnw==} engines: {node: '>=14.0.0'} cpu: [arm] os: [linux] libc: glibc - sass-embedded-linux-musl-arm64@1.98.0: - resolution: {integrity: sha512-LeqNxQA8y4opjhe68CcFvMzCSrBuJqYVFbwElEj9bagHXQHTp9xVPJRn6VcrC+0VLEDq13HVXMv7RslIuU0zmA==} + sass-embedded-linux-musl-arm64@1.99.0: + resolution: {integrity: sha512-Hi2bt/IrM5P4FBKz6EcHAlniwfpoz9mnTdvSd58y+avA3SANM76upIkAdSayA8ZGwyL3gZokru1AKDPF9lJDNw==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [linux] libc: musl - sass-embedded-linux-musl-arm@1.98.0: - resolution: {integrity: sha512-OBkjTDPYR4hSaueOGIM6FDpl9nt/VZwbSRpbNu9/eEJcxE8G/vynRugW8KRZmCFjPy8j/jkGBvvS+k9iOqKV3g==} + sass-embedded-linux-musl-arm@1.99.0: + resolution: {integrity: sha512-2gvHOupgIw3ytatXT4nFUow71LFbuOZPEwG+HUzcNQDH8ue4Ez8cr03vsv5MDv3lIjOKcXwDvWD980t18MwkoQ==} engines: {node: '>=14.0.0'} cpu: [arm] os: [linux] libc: musl - sass-embedded-linux-musl-riscv64@1.98.0: - resolution: {integrity: sha512-7w6hSuOHKt8FZsmjRb3iGSxEzM87fO9+M8nt5JIQYMhHTj5C+JY/vcske0v715HCVj5e1xyTnbGXf8FcASeAIw==} + sass-embedded-linux-musl-riscv64@1.99.0: + resolution: {integrity: sha512-mKqGvVaJ9rHMqyZsF0kikQe4NO0f4osb67+X6nLhBiVDKvyazQHJ3zJQreNefIE36yL2sjHIclSB//MprzaQDg==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [linux] libc: musl - sass-embedded-linux-musl-x64@1.98.0: - resolution: {integrity: sha512-QikNyDEJOVqPmxyCFkci8ZdCwEssdItfjQFJB+D+Uy5HFqcS5Lv3d3GxWNX/h1dSb23RPyQdQc267ok5SbEyJw==} + sass-embedded-linux-musl-x64@1.99.0: + resolution: {integrity: sha512-huhgOMmOc30r7CH7qbRbT9LerSEGSnWuS4CYNOskr9BvNeQp4dIneFufNRGZ7hkOAxUM8DglxIZJN/cyAT95Ew==} engines: {node: '>=14.0.0'} cpu: [x64] os: [linux] libc: musl - sass-embedded-linux-riscv64@1.98.0: - resolution: {integrity: sha512-E7fNytc/v4xFBQKzgzBddV/jretA4ULAPO6XmtBiQu4zZBdBozuSxsQLe2+XXeb0X4S2GIl72V7IPABdqke/vA==} + sass-embedded-linux-riscv64@1.99.0: + resolution: {integrity: sha512-mevFPIFAVhrH90THifxLfOntFmHtcEKOcdWnep2gJ0X4DVva4AiVIRlQe/7w9JFx5+gnDRE1oaJJkzuFUuYZsA==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [linux] libc: glibc - sass-embedded-linux-x64@1.98.0: - resolution: {integrity: sha512-VsvP0t/uw00mMNPv3vwyYKUrFbqzxQHnRMO+bHdAMjvLw4NFf6mscpym9Bzf+NXwi1ZNKnB6DtXjmcpcvqFqYg==} + sass-embedded-linux-x64@1.99.0: + resolution: {integrity: sha512-9k7IkULqIZdCIVt4Mboryt6vN8Mjmm3EhI1P3mClU5y5i3wLK5ExC3cbVWk047KsID/fvB1RLslqghXJx5BoxA==} engines: {node: '>=14.0.0'} cpu: [x64] os: [linux] libc: glibc - sass-embedded-unknown-all@1.98.0: - resolution: {integrity: sha512-C4MMzcAo3oEDQnW7L8SBgB9F2Fq5qHPnaYTZRMOH3Mp/7kM4OooBInXpCiiFjLnjY95hzP4KyctVx0uYR6MYlQ==} + sass-embedded-unknown-all@1.99.0: + resolution: {integrity: sha512-P7MxiUtL/XzGo3PX0CaB8lNNEFLQWKikPA8pbKytx9ZCLZSDkt2NJcdAbblB/sqMs4AV3EK2NadV8rI/diq3xg==} os: ['!android', '!darwin', '!linux', '!win32'] - sass-embedded-win32-arm64@1.98.0: - resolution: {integrity: sha512-nP/10xbAiPbhQkMr3zQfXE4TuOxPzWRQe1Hgbi90jv2R4TbzbqQTuZVOaJf7KOAN4L2Bo6XCTRjK5XkVnwZuwQ==} + sass-embedded-win32-arm64@1.99.0: + resolution: {integrity: sha512-8whpsW7S+uO8QApKfQuc36m3P9EISzbVZOgC79goob4qGy09u8Gz/rYvw8h1prJDSjltpHGhOzBE6LDz7WvzVw==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [win32] - sass-embedded-win32-x64@1.98.0: - resolution: {integrity: sha512-/lbrVsfbcbdZQ5SJCWcV0NVPd6YRs+FtAnfedp4WbCkO/ZO7Zt/58MvI4X2BVpRY/Nt5ZBo1/7v2gYcQ+J4svQ==} + sass-embedded-win32-x64@1.99.0: + resolution: {integrity: sha512-ipuOv1R2K4MHeuCEAZGpuUbAgma4gb0sdacyrTjJtMOy/OY9UvWfVlwErdB09KIkp4fPDpQJDJfvYN6bC8jeNg==} engines: {node: '>=14.0.0'} cpu: [x64] os: [win32] - sass-embedded@1.98.0: - resolution: {integrity: sha512-Do7u6iRb6K+lrllcTkB1BXcHwOxcKe3rEfOF/GcCLE2w3WpddakRAosJOHFUR37DpsvimQXEt5abs3NzUjEIqg==} + sass-embedded@1.99.0: + resolution: {integrity: sha512-gF/juR1aX02lZHkvwxdF80SapkQeg2fetoDF6gIQkNbSw5YEUFspMkyGTjPjgZSgIHuZpy+Wz4PlebKnLXMjdg==} engines: {node: '>=16.0.0'} hasBin: true - sass@1.98.0: - resolution: {integrity: sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==} + sass@1.99.0: + resolution: {integrity: sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==} engines: {node: '>=14.0.0'} hasBin: true @@ -5551,8 +5596,8 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - std-env@4.0.0: - resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} @@ -5585,8 +5630,8 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} - string-width@8.2.0: - resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + string-width@8.2.1: + resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} engines: {node: '>=20'} string.prototype.codepointat@0.2.1: @@ -5668,8 +5713,8 @@ packages: engines: {node: '>=22.0.0'} hasBin: true - stylelint-config-recommended-scss@17.0.0: - resolution: {integrity: sha512-VkVD9r7jfUT/dq3mA3/I1WXXk2U71rO5wvU2yIil9PW5o1g3UM7Xc82vHmuVJHV7Y8ok5K137fmW5u3HbhtTOA==} + stylelint-config-recommended-scss@17.0.1: + resolution: {integrity: sha512-x5DVehzJudcwF0od3sGpgkln2PLLranFE7twwbp7dqDINCyZvwzFkMc6TLhNOvazRiVBJYATQLouJY0xPGB8WA==} engines: {node: '>=20'} peerDependencies: postcss: ^8.3.3 @@ -5700,8 +5745,8 @@ packages: peerDependencies: stylelint: ^17.0.0 - stylelint-scss@7.0.0: - resolution: {integrity: sha512-H88kCC+6Vtzj76NsC8rv6x/LW8slBzIbyeSjsKVlS+4qaEJoDrcJR4L+8JdrR2ORdTscrBzYWiiT2jq6leYR1Q==} + stylelint-scss@7.1.1: + resolution: {integrity: sha512-pLPXJZ7RtAFNLXe8gqarf3B56ScVTd1vPiL9IFgcJkIZsYPzAgLJPh2h9NHrp3BFeKrtAkIgwQ08QSmhvlv1gA==} engines: {node: '>=20.19.0'} peerDependencies: stylelint: ^16.8.2 || ^17.0.0 @@ -5712,8 +5757,8 @@ packages: peerDependencies: stylelint: '>=11 < 17' - stylelint@17.4.0: - resolution: {integrity: sha512-3kQ2/cHv3Zt8OBg+h2B8XCx9evEABQIrv4hh3uXahGz/ZEHrTR80zxBiK2NfXNaSoyBzxO1pjsz1Vhdzwn5XSw==} + stylelint@17.11.0: + resolution: {integrity: sha512-/3czzmbF9XdGWvReDF3Ex4R23Ajolo7j8RB2bFNEqk6Ht356nlpVV+G5bG2Qt8AW1ofJzXztBRDnAtd7cgowWA==} engines: {node: '>=20.19.0'} hasBin: true @@ -5817,14 +5862,18 @@ packages: tinycolor2@1.6.0: resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} - tinyexec@1.1.1: - resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + tinypool@0.8.4: resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} engines: {node: '>=14.0.0'} @@ -5848,17 +5897,10 @@ packages: tldts-core@7.0.22: resolution: {integrity: sha512-KgbTDC5wzlL6j/x6np6wCnDSMUq4kucHNm00KXPbfNzmllCmtmvtykJHfmgdHntwIeupW04y8s1N/43S1PkQDw==} - tldts-core@7.0.28: - resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==} - tldts@7.0.22: resolution: {integrity: sha512-nqpKFC53CgopKPjT6Wfb6tpIcZXHcI6G37hesvikhx0EmUGPkZrujRyAjgnmp1SHNgpQfKVanZ+KfpANFt2Hxw==} hasBin: true - tldts@7.0.28: - resolution: {integrity: sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==} - hasBin: true - tmp@0.2.5: resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} engines: {node: '>=14.14'} @@ -5958,8 +6000,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@6.0.2: - resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true @@ -5990,11 +6032,11 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@7.21.0: + resolution: {integrity: sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==} - undici@7.24.7: - resolution: {integrity: sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==} + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} engines: {node: '>=20.18.1'} unicorn-magic@0.4.0: @@ -6099,13 +6141,13 @@ packages: terser: optional: true - vite@8.0.7: - resolution: {integrity: sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==} + vite@8.0.12: + resolution: {integrity: sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.0 + '@vitejs/devtools': ^0.1.18 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -6167,20 +6209,20 @@ packages: jsdom: optional: true - vitest@4.1.3: - resolution: {integrity: sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==} + vitest@4.1.6: + resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.3 - '@vitest/browser-preview': 4.1.3 - '@vitest/browser-webdriverio': 4.1.3 - '@vitest/coverage-istanbul': 4.1.3 - '@vitest/coverage-v8': 4.1.3 - '@vitest/ui': 4.1.3 + '@vitest/browser-playwright': 4.1.6 + '@vitest/browser-preview': 4.1.6 + '@vitest/browser-webdriverio': 4.1.6 + '@vitest/coverage-istanbul': 4.1.6 + '@vitest/coverage-v8': 4.1.6 + '@vitest/ui': 4.1.6 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -6215,8 +6257,8 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} - wait-on@9.0.4: - resolution: {integrity: sha512-k8qrgfwrPVJXTeFY8tl6BxVHiclK11u72DVKhpybHfUL/K6KM4bdyK9EhIVYGytB5MJe/3lq4Tf0hrjM+pvJZQ==} + wait-on@9.0.10: + resolution: {integrity: sha512-rCoJEhvMr0X6alHmwc9abbrA5ZrLZFKpFQVKPNFwl2h7DapXOGdmimIHDtLOWhT4PjhZhxFEtZoQgEXbkDWdZw==} engines: {node: '>=20.0.0'} hasBin: true @@ -6298,8 +6340,8 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - workerpool@10.0.1: - resolution: {integrity: sha512-NAnKwZJxWlj/U1cp6ZkEtPE+GQY1S6KtOS3AlCiPfPFLxV3m64giSp7g2LsNJxzYCocDT7TSl+7T0sgrDp3KoQ==} + workerpool@10.0.2: + resolution: {integrity: sha512-8PCeZlCwu0+8hXruze1ahYNsY+M0LOCmbmySZ9BWWqWIXP9TAXa6FZCxACTDL/0j47pFcC4xW98Gr8nAC5oymg==} wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} @@ -6328,18 +6370,6 @@ packages: utf-8-validate: optional: true - ws@8.20.0: - resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} - 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 - wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} @@ -6427,10 +6457,11 @@ snapshots: '@csstools/css-tokenizer': 4.0.0 lru-cache: 11.2.5 - '@asamuzakjp/css-color@5.1.6': + '@asamuzakjp/css-color@5.1.11': dependencies: - '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 @@ -6442,13 +6473,16 @@ snapshots: is-potential-custom-element-name: 1.0.1 lru-cache: 11.2.5 - '@asamuzakjp/dom-selector@7.0.8': + '@asamuzakjp/dom-selector@7.1.1': dependencies: + '@asamuzakjp/generational-cache': 1.0.1 '@asamuzakjp/nwsapi': 2.3.9 bidi-js: 1.0.3 css-tree: 3.2.1 is-potential-custom-element-name: 1.0.1 + '@asamuzakjp/generational-cache@1.0.1': {} + '@asamuzakjp/nwsapi@2.3.9': {} '@babel/code-frame@7.29.0': @@ -6534,10 +6568,6 @@ snapshots: dependencies: '@babel/types': 7.29.0 - '@babel/parser@7.29.2': - dependencies: - '@babel/types': 7.29.0 - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -6590,8 +6620,6 @@ snapshots: '@babel/runtime@7.28.6': {} - '@babel/runtime@7.29.2': {} - '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -6660,14 +6688,14 @@ snapshots: '@cacheable/memory@2.0.8': dependencies: - '@cacheable/utils': 2.4.0 + '@cacheable/utils': 2.4.1 '@keyv/bigmap': 1.3.1(keyv@5.6.0) hookified: 1.15.1 keyv: 5.6.0 - '@cacheable/utils@2.4.0': + '@cacheable/utils@2.4.1': dependencies: - hashery: 1.5.0 + hashery: 1.5.1 keyv: 5.6.0 '@colors/colors@1.6.0': {} @@ -6681,7 +6709,7 @@ snapshots: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 @@ -6693,10 +6721,10 @@ snapshots: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + '@csstools/css-color-parser@4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: '@csstools/color-helpers': 6.0.2 - '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 @@ -6706,9 +6734,7 @@ snapshots: '@csstools/css-syntax-patches-for-csstree@1.0.26': {} - '@csstools/css-syntax-patches-for-csstree@1.1.0': {} - - '@csstools/css-syntax-patches-for-csstree@1.1.2(css-tree@3.2.1)': + '@csstools/css-syntax-patches-for-csstree@1.1.4(css-tree@3.2.1)': optionalDependencies: css-tree: 3.2.1 @@ -6733,18 +6759,18 @@ snapshots: enabled: 2.0.0 kuler: 2.0.0 - '@emnapi/core@1.9.1': + '@emnapi/core@1.10.0': dependencies: - '@emnapi/wasi-threads': 1.2.0 + '@emnapi/wasi-threads': 1.2.1 tslib: 2.8.1 optional: true - '@emnapi/runtime@1.9.1': + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.2.0': + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 optional: true @@ -6755,7 +6781,7 @@ snapshots: '@esbuild/aix-ppc64@0.27.3': optional: true - '@esbuild/aix-ppc64@0.27.7': + '@esbuild/aix-ppc64@0.27.4': optional: true '@esbuild/aix-ppc64@0.28.0': @@ -6767,7 +6793,7 @@ snapshots: '@esbuild/android-arm64@0.27.3': optional: true - '@esbuild/android-arm64@0.27.7': + '@esbuild/android-arm64@0.27.4': optional: true '@esbuild/android-arm64@0.28.0': @@ -6779,7 +6805,7 @@ snapshots: '@esbuild/android-arm@0.27.3': optional: true - '@esbuild/android-arm@0.27.7': + '@esbuild/android-arm@0.27.4': optional: true '@esbuild/android-arm@0.28.0': @@ -6791,7 +6817,7 @@ snapshots: '@esbuild/android-x64@0.27.3': optional: true - '@esbuild/android-x64@0.27.7': + '@esbuild/android-x64@0.27.4': optional: true '@esbuild/android-x64@0.28.0': @@ -6803,7 +6829,7 @@ snapshots: '@esbuild/darwin-arm64@0.27.3': optional: true - '@esbuild/darwin-arm64@0.27.7': + '@esbuild/darwin-arm64@0.27.4': optional: true '@esbuild/darwin-arm64@0.28.0': @@ -6815,7 +6841,7 @@ snapshots: '@esbuild/darwin-x64@0.27.3': optional: true - '@esbuild/darwin-x64@0.27.7': + '@esbuild/darwin-x64@0.27.4': optional: true '@esbuild/darwin-x64@0.28.0': @@ -6827,7 +6853,7 @@ snapshots: '@esbuild/freebsd-arm64@0.27.3': optional: true - '@esbuild/freebsd-arm64@0.27.7': + '@esbuild/freebsd-arm64@0.27.4': optional: true '@esbuild/freebsd-arm64@0.28.0': @@ -6839,7 +6865,7 @@ snapshots: '@esbuild/freebsd-x64@0.27.3': optional: true - '@esbuild/freebsd-x64@0.27.7': + '@esbuild/freebsd-x64@0.27.4': optional: true '@esbuild/freebsd-x64@0.28.0': @@ -6851,7 +6877,7 @@ snapshots: '@esbuild/linux-arm64@0.27.3': optional: true - '@esbuild/linux-arm64@0.27.7': + '@esbuild/linux-arm64@0.27.4': optional: true '@esbuild/linux-arm64@0.28.0': @@ -6863,7 +6889,7 @@ snapshots: '@esbuild/linux-arm@0.27.3': optional: true - '@esbuild/linux-arm@0.27.7': + '@esbuild/linux-arm@0.27.4': optional: true '@esbuild/linux-arm@0.28.0': @@ -6875,7 +6901,7 @@ snapshots: '@esbuild/linux-ia32@0.27.3': optional: true - '@esbuild/linux-ia32@0.27.7': + '@esbuild/linux-ia32@0.27.4': optional: true '@esbuild/linux-ia32@0.28.0': @@ -6887,7 +6913,7 @@ snapshots: '@esbuild/linux-loong64@0.27.3': optional: true - '@esbuild/linux-loong64@0.27.7': + '@esbuild/linux-loong64@0.27.4': optional: true '@esbuild/linux-loong64@0.28.0': @@ -6899,7 +6925,7 @@ snapshots: '@esbuild/linux-mips64el@0.27.3': optional: true - '@esbuild/linux-mips64el@0.27.7': + '@esbuild/linux-mips64el@0.27.4': optional: true '@esbuild/linux-mips64el@0.28.0': @@ -6911,7 +6937,7 @@ snapshots: '@esbuild/linux-ppc64@0.27.3': optional: true - '@esbuild/linux-ppc64@0.27.7': + '@esbuild/linux-ppc64@0.27.4': optional: true '@esbuild/linux-ppc64@0.28.0': @@ -6923,7 +6949,7 @@ snapshots: '@esbuild/linux-riscv64@0.27.3': optional: true - '@esbuild/linux-riscv64@0.27.7': + '@esbuild/linux-riscv64@0.27.4': optional: true '@esbuild/linux-riscv64@0.28.0': @@ -6935,7 +6961,7 @@ snapshots: '@esbuild/linux-s390x@0.27.3': optional: true - '@esbuild/linux-s390x@0.27.7': + '@esbuild/linux-s390x@0.27.4': optional: true '@esbuild/linux-s390x@0.28.0': @@ -6947,7 +6973,7 @@ snapshots: '@esbuild/linux-x64@0.27.3': optional: true - '@esbuild/linux-x64@0.27.7': + '@esbuild/linux-x64@0.27.4': optional: true '@esbuild/linux-x64@0.28.0': @@ -6956,7 +6982,7 @@ snapshots: '@esbuild/netbsd-arm64@0.27.3': optional: true - '@esbuild/netbsd-arm64@0.27.7': + '@esbuild/netbsd-arm64@0.27.4': optional: true '@esbuild/netbsd-arm64@0.28.0': @@ -6968,7 +6994,7 @@ snapshots: '@esbuild/netbsd-x64@0.27.3': optional: true - '@esbuild/netbsd-x64@0.27.7': + '@esbuild/netbsd-x64@0.27.4': optional: true '@esbuild/netbsd-x64@0.28.0': @@ -6977,7 +7003,7 @@ snapshots: '@esbuild/openbsd-arm64@0.27.3': optional: true - '@esbuild/openbsd-arm64@0.27.7': + '@esbuild/openbsd-arm64@0.27.4': optional: true '@esbuild/openbsd-arm64@0.28.0': @@ -6989,7 +7015,7 @@ snapshots: '@esbuild/openbsd-x64@0.27.3': optional: true - '@esbuild/openbsd-x64@0.27.7': + '@esbuild/openbsd-x64@0.27.4': optional: true '@esbuild/openbsd-x64@0.28.0': @@ -6998,7 +7024,7 @@ snapshots: '@esbuild/openharmony-arm64@0.27.3': optional: true - '@esbuild/openharmony-arm64@0.27.7': + '@esbuild/openharmony-arm64@0.27.4': optional: true '@esbuild/openharmony-arm64@0.28.0': @@ -7010,7 +7036,7 @@ snapshots: '@esbuild/sunos-x64@0.27.3': optional: true - '@esbuild/sunos-x64@0.27.7': + '@esbuild/sunos-x64@0.27.4': optional: true '@esbuild/sunos-x64@0.28.0': @@ -7022,7 +7048,7 @@ snapshots: '@esbuild/win32-arm64@0.27.3': optional: true - '@esbuild/win32-arm64@0.27.7': + '@esbuild/win32-arm64@0.27.4': optional: true '@esbuild/win32-arm64@0.28.0': @@ -7034,7 +7060,7 @@ snapshots: '@esbuild/win32-ia32@0.27.3': optional: true - '@esbuild/win32-ia32@0.27.7': + '@esbuild/win32-ia32@0.27.4': optional: true '@esbuild/win32-ia32@0.28.0': @@ -7046,7 +7072,7 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true - '@esbuild/win32-x64@0.27.7': + '@esbuild/win32-x64@0.27.4': optional: true '@esbuild/win32-x64@0.28.0': @@ -7077,7 +7103,7 @@ snapshots: '@eslint/eslintrc@3.3.5': dependencies: - ajv: 6.14.0 + ajv: 6.15.0 debug: 4.4.3(supports-color@5.5.0) espree: 10.4.0 globals: 14.0.0 @@ -7112,19 +7138,24 @@ snapshots: '@hapi/pinpoint@2.0.1': {} - '@hapi/tlds@1.1.6': {} + '@hapi/tlds@1.1.4': {} '@hapi/topo@6.0.2': dependencies: '@hapi/hoek': 11.0.7 - '@humanfs/core@0.19.1': {} - - '@humanfs/node@0.16.7': + '@humanfs/core@0.19.2': dependencies: - '@humanfs/core': 0.19.1 + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 '@humanwhocodes/retry': 0.4.3 + '@humanfs/types@0.15.0': {} + '@humanwhocodes/module-importer@1.0.1': {} '@humanwhocodes/retry@0.4.3': {} @@ -7150,13 +7181,13 @@ snapshots: dependencies: '@sinclair/typebox': 0.27.10 - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@6.0.2)(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@6.0.3)(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2))': dependencies: - glob: 13.0.6 - react-docgen-typescript: 2.4.0(typescript@6.0.2) - vite: 8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) + glob: 13.0.1 + react-docgen-typescript: 2.4.0(typescript@6.0.3) + vite: 8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2) optionalDependencies: - typescript: 6.0.2 + typescript: 6.0.3 '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -7306,7 +7337,7 @@ snapshots: '@keyv/bigmap@1.3.1(keyv@5.6.0)': dependencies: - hashery: 1.5.0 + hashery: 1.5.1 hookified: 1.15.1 keyv: 5.6.0 @@ -7318,23 +7349,23 @@ snapshots: '@types/react': 19.2.14 react: 19.2.4 - '@microsoft/api-extractor-model@7.32.2(@types/node@25.5.2)': + '@microsoft/api-extractor-model@7.32.2(@types/node@25.7.0)': dependencies: '@microsoft/tsdoc': 0.16.0 '@microsoft/tsdoc-config': 0.18.0 - '@rushstack/node-core-library': 5.19.1(@types/node@25.5.2) + '@rushstack/node-core-library': 5.19.1(@types/node@25.7.0) transitivePeerDependencies: - '@types/node' - '@microsoft/api-extractor@7.56.2(@types/node@25.5.2)': + '@microsoft/api-extractor@7.56.2(@types/node@25.7.0)': dependencies: - '@microsoft/api-extractor-model': 7.32.2(@types/node@25.5.2) + '@microsoft/api-extractor-model': 7.32.2(@types/node@25.7.0) '@microsoft/tsdoc': 0.16.0 '@microsoft/tsdoc-config': 0.18.0 - '@rushstack/node-core-library': 5.19.1(@types/node@25.5.2) + '@rushstack/node-core-library': 5.19.1(@types/node@25.7.0) '@rushstack/rig-package': 0.6.0 - '@rushstack/terminal': 0.21.0(@types/node@25.5.2) - '@rushstack/ts-command-line': 5.2.0(@types/node@25.5.2) + '@rushstack/terminal': 0.21.0(@types/node@25.7.0) + '@rushstack/ts-command-line': 5.2.0(@types/node@25.7.0) diff: 8.0.3 lodash: 4.17.23 minimatch: 10.1.2 @@ -7354,11 +7385,11 @@ snapshots: '@microsoft/tsdoc@0.16.0': {} - '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: - '@emnapi/core': 1.9.1 - '@emnapi/runtime': 1.9.1 - '@tybys/wasm-util': 0.10.1 + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 optional: true '@nodelib/fs.scandir@2.1.5': @@ -7375,7 +7406,7 @@ snapshots: '@one-ini/wasm@0.1.1': {} - '@oxc-project/types@0.123.0': {} + '@oxc-project/types@0.129.0': {} '@parcel/watcher-android-arm64@2.5.6': optional: true @@ -7421,7 +7452,7 @@ snapshots: detect-libc: 2.1.2 is-glob: 4.0.3 node-addon-api: 7.1.1 - picomatch: 4.0.4 + picomatch: 4.0.3 optionalDependencies: '@parcel/watcher-android-arm64': 2.5.6 '@parcel/watcher-darwin-arm64': 2.5.6 @@ -7445,9 +7476,9 @@ snapshots: dependencies: playwright: 1.58.0 - '@playwright/test@1.59.1': + '@playwright/test@1.60.0': dependencies: - playwright: 1.59.1 + playwright: 1.60.0 '@polka/url@1.0.0-next.29': {} @@ -7502,62 +7533,62 @@ snapshots: '@resvg/resvg-js-win32-ia32-msvc': 2.6.2 '@resvg/resvg-js-win32-x64-msvc': 2.6.2 - '@rolldown/binding-android-arm64@1.0.0-rc.13': + '@rolldown/binding-android-arm64@1.0.0': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-rc.13': + '@rolldown/binding-darwin-arm64@1.0.0': optional: true - '@rolldown/binding-darwin-x64@1.0.0-rc.13': + '@rolldown/binding-darwin-x64@1.0.0': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-rc.13': + '@rolldown/binding-freebsd-x64@1.0.0': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.13': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.13': + '@rolldown/binding-linux-arm64-gnu@1.0.0': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.13': + '@rolldown/binding-linux-arm64-musl@1.0.0': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.13': + '@rolldown/binding-linux-ppc64-gnu@1.0.0': optional: true - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.13': + '@rolldown/binding-linux-s390x-gnu@1.0.0': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.13': + '@rolldown/binding-linux-x64-gnu@1.0.0': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-rc.13': + '@rolldown/binding-linux-x64-musl@1.0.0': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-rc.13': + '@rolldown/binding-openharmony-arm64@1.0.0': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.13': + '@rolldown/binding-wasm32-wasi@1.0.0': dependencies: - '@emnapi/core': 1.9.1 - '@emnapi/runtime': 1.9.1 - '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.13': + '@rolldown/binding-win32-arm64-msvc@1.0.0': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.13': + '@rolldown/binding-win32-x64-msvc@1.0.0': optional: true - '@rolldown/pluginutils@1.0.0-rc.13': {} + '@rolldown/pluginutils@1.0.0': {} '@rolldown/pluginutils@1.0.0-rc.7': {} '@rollup/pluginutils@5.3.0(rollup@4.57.1)': dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 estree-walker: 2.0.2 picomatch: 4.0.3 optionalDependencies: @@ -7640,7 +7671,7 @@ snapshots: '@rtsao/scc@1.1.0': {} - '@rushstack/node-core-library@5.19.1(@types/node@25.5.2)': + '@rushstack/node-core-library@5.19.1(@types/node@25.7.0)': dependencies: ajv: 8.13.0 ajv-draft-04: 1.0.0(ajv@8.13.0) @@ -7651,28 +7682,28 @@ snapshots: resolve: 1.22.11 semver: 7.5.4 optionalDependencies: - '@types/node': 25.5.2 + '@types/node': 25.7.0 - '@rushstack/problem-matcher@0.1.1(@types/node@25.5.2)': + '@rushstack/problem-matcher@0.1.1(@types/node@25.7.0)': optionalDependencies: - '@types/node': 25.5.2 + '@types/node': 25.7.0 '@rushstack/rig-package@0.6.0': dependencies: resolve: 1.22.11 strip-json-comments: 3.1.1 - '@rushstack/terminal@0.21.0(@types/node@25.5.2)': + '@rushstack/terminal@0.21.0(@types/node@25.7.0)': dependencies: - '@rushstack/node-core-library': 5.19.1(@types/node@25.5.2) - '@rushstack/problem-matcher': 0.1.1(@types/node@25.5.2) + '@rushstack/node-core-library': 5.19.1(@types/node@25.7.0) + '@rushstack/problem-matcher': 0.1.1(@types/node@25.7.0) supports-color: 8.1.1 optionalDependencies: - '@types/node': 25.5.2 + '@types/node': 25.7.0 - '@rushstack/ts-command-line@5.2.0(@types/node@25.5.2)': + '@rushstack/ts-command-line@5.2.0(@types/node@25.7.0)': dependencies: - '@rushstack/terminal': 0.21.0(@types/node@25.5.2) + '@rushstack/terminal': 0.21.0(@types/node@25.7.0) '@types/argparse': 1.0.38 argparse: 1.0.10 string-argv: 0.3.2 @@ -7690,10 +7721,10 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@storybook/addon-docs@10.3.5(@types/react@19.2.14)(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2))': + '@storybook/addon-docs@10.3.5(@types/react@19.2.14)(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.4) - '@storybook/csf-plugin': 10.3.5(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2)) + '@storybook/csf-plugin': 10.3.5(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2)) '@storybook/icons': 2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@storybook/react-dom-shim': 10.3.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) react: 19.2.4 @@ -7712,68 +7743,99 @@ snapshots: storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) ts-dedent: 2.2.0 - '@storybook/addon-vitest@10.3.5(@vitest/browser-playwright@4.1.3)(@vitest/browser@4.1.3)(@vitest/runner@4.1.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vitest@4.1.3)': + '@storybook/addon-vitest@10.3.5(@vitest/browser-playwright@4.1.6)(@vitest/browser@4.1.3)(@vitest/runner@4.1.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vitest@4.1.6)': dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) optionalDependencies: - '@vitest/browser': 4.1.3(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2))(vitest@4.1.3) - '@vitest/browser-playwright': 4.1.3(playwright@1.59.1)(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2))(vitest@4.1.3) - '@vitest/runner': 4.1.3 - vitest: 4.1.3(@types/node@25.5.2)(@vitest/browser-playwright@4.1.3)(@vitest/coverage-v8@4.1.3)(jsdom@29.0.2(canvas@3.2.1))(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2)) + '@vitest/browser': 4.1.3(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2))(vitest@4.1.6) + '@vitest/browser-playwright': 4.1.6(playwright@1.60.0)(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2))(vitest@4.1.6) + '@vitest/runner': 4.1.6 + vitest: 4.1.6(@types/node@25.7.0)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.3)(jsdom@29.1.1(canvas@3.2.1))(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2)) transitivePeerDependencies: - react - react-dom - '@storybook/builder-vite@10.3.5(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2))': + '@storybook/builder-vite@10.3.5(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2))': dependencies: - '@storybook/csf-plugin': 10.3.5(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2)) - storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@storybook/csf-plugin': 10.3.5(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2)) + storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) ts-dedent: 2.2.0 - vite: 8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) + vite: 8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2) transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/csf-plugin@10.3.5(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2))': + '@storybook/builder-vite@10.3.5(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2))': + dependencies: + '@storybook/csf-plugin': 10.3.5(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2)) + storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + ts-dedent: 2.2.0 + vite: 8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2) + transitivePeerDependencies: + - esbuild + - rollup + - webpack + + '@storybook/csf-plugin@10.3.5(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2))': + dependencies: + storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + unplugin: 2.3.11 + optionalDependencies: + esbuild: 0.28.0 + rollup: 4.57.1 + vite: 8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2) + + '@storybook/csf-plugin@10.3.5(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2))': dependencies: storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) unplugin: 2.3.11 optionalDependencies: esbuild: 0.28.0 rollup: 4.57.1 - vite: 8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) + vite: 8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2) '@storybook/global@5.0.0': {} + '@storybook/icons@2.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + '@storybook/icons@2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + '@storybook/react-dom-shim@10.3.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))': + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@storybook/react-dom-shim@10.3.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': dependencies: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@storybook/react-vite@10.3.5(esbuild@0.28.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2)(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2))': + '@storybook/react-vite@10.3.5(esbuild@0.28.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@6.0.3)(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.2)(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2)) '@rollup/pluginutils': 5.3.0(rollup@4.57.1) - '@storybook/builder-vite': 10.3.5(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2)) - '@storybook/react': 10.3.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2) + '@storybook/builder-vite': 10.3.5(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2)) + '@storybook/react': 10.3.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@6.0.3) empathic: 2.0.0 magic-string: 0.30.21 - react: 19.2.4 - react-docgen: 8.0.3 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.3 + react-docgen: 8.0.2 + react-dom: 19.2.3(react@19.2.3) resolve: 1.22.11 - storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) tsconfig-paths: 4.2.0 - vite: 8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) + vite: 8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2) transitivePeerDependencies: - esbuild - rollup @@ -7781,24 +7843,60 @@ snapshots: - typescript - webpack - '@storybook/react@10.3.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.2)': + '@storybook/react-vite@10.3.5(esbuild@0.28.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.3)(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2))': + dependencies: + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2)) + '@rollup/pluginutils': 5.3.0(rollup@4.57.1) + '@storybook/builder-vite': 10.3.5(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2)) + '@storybook/react': 10.3.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.3) + empathic: 2.0.0 + magic-string: 0.30.21 + react: 19.2.4 + react-docgen: 8.0.2 + react-dom: 19.2.4(react@19.2.4) + resolve: 1.22.11 + storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + tsconfig-paths: 4.2.0 + vite: 8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2) + transitivePeerDependencies: + - esbuild + - rollup + - supports-color + - typescript + - webpack + + '@storybook/react@10.3.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@6.0.3)': + dependencies: + '@storybook/global': 5.0.0 + '@storybook/react-dom-shim': 10.3.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) + react: 19.2.3 + react-docgen: 8.0.2 + react-docgen-typescript: 2.4.0(typescript@6.0.3) + react-dom: 19.2.3(react@19.2.3) + storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@storybook/react@10.3.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@6.0.3)': dependencies: '@storybook/global': 5.0.0 '@storybook/react-dom-shim': 10.3.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) react: 19.2.4 - react-docgen: 8.0.3 - react-docgen-typescript: 2.4.0(typescript@6.0.2) + react-docgen: 8.0.2 + react-docgen-typescript: 2.4.0(typescript@6.0.3) react-dom: 19.2.4(react@19.2.4) storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) optionalDependencies: - typescript: 6.0.2 + typescript: 6.0.3 transitivePeerDependencies: - supports-color '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.28.6 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -7815,12 +7913,12 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.28.6 '@testing-library/dom': 10.4.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) @@ -7850,7 +7948,7 @@ snapshots: '@trysound/sax@0.2.0': {} - '@tybys/wasm-util@0.10.1': + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 optional: true @@ -7891,13 +7989,15 @@ snapshots: '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} '@types/mdx@2.0.13': {} - '@types/node@22.19.17': + '@types/node@22.19.9': dependencies: undici-types: 6.21.0 @@ -7905,9 +8005,9 @@ snapshots: dependencies: undici-types: 7.16.0 - '@types/node@25.5.2': + '@types/node@25.7.0': dependencies: - undici-types: 7.18.2 + undici-types: 7.21.0 '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: @@ -7921,20 +8021,20 @@ snapshots: '@types/triple-beam@1.3.5': {} - '@vitejs/plugin-react@6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2))': + '@vitejs/plugin-react@6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) + vite: 8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2) optionalDependencies: babel-plugin-react-compiler: 1.0.0 - '@vitest/browser-playwright@4.1.3(playwright@1.59.1)(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2))(vitest@4.1.3)': + '@vitest/browser-playwright@4.1.6(playwright@1.60.0)(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2))(vitest@4.1.6)': dependencies: - '@vitest/browser': 4.1.3(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2))(vitest@4.1.3) - '@vitest/mocker': 4.1.3(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2)) - playwright: 1.59.1 + '@vitest/browser': 4.1.6(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2))(vitest@4.1.6) + '@vitest/mocker': 4.1.6(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2)) + playwright: 1.60.0 tinyrainbow: 3.1.0 - vitest: 4.1.3(@types/node@25.5.2)(@vitest/browser-playwright@4.1.3)(@vitest/coverage-v8@4.1.3)(jsdom@29.0.2(canvas@3.2.1))(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2)) + vitest: 4.1.6(@types/node@25.7.0)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.3)(jsdom@29.1.1(canvas@3.2.1))(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2)) transitivePeerDependencies: - bufferutil - msw @@ -7946,21 +8046,38 @@ snapshots: '@vitest/utils': 1.6.1 magic-string: 0.30.21 sirv: 2.0.4 - vitest: 1.6.1(@types/node@25.2.1)(@vitest/browser@1.6.1)(@vitest/ui@1.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0) + vitest: 1.6.1(@types/node@25.2.1)(@vitest/browser@1.6.1)(@vitest/ui@1.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0) optionalDependencies: playwright: 1.58.0 - '@vitest/browser@4.1.3(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2))(vitest@4.1.3)': + '@vitest/browser@4.1.3(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2))(vitest@4.1.6)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.3(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2)) + '@vitest/mocker': 4.1.3(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2)) '@vitest/utils': 4.1.3 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.3(@types/node@25.5.2)(@vitest/browser-playwright@4.1.3)(@vitest/coverage-v8@4.1.3)(jsdom@29.0.2(canvas@3.2.1))(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2)) - ws: 8.20.0 + vitest: 4.1.6(@types/node@25.7.0)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.3)(jsdom@29.1.1(canvas@3.2.1))(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2)) + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@4.1.6(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2))(vitest@4.1.6)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.6(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2)) + '@vitest/utils': 4.1.6 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.0 + vitest: 4.1.6(@types/node@25.7.0)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.3)(jsdom@29.1.1(canvas@3.2.1))(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2)) + ws: 8.19.0 transitivePeerDependencies: - bufferutil - msw @@ -7982,11 +8099,11 @@ snapshots: std-env: 3.10.0 strip-literal: 2.1.1 test-exclude: 6.0.0 - vitest: 1.6.1(@types/node@25.2.1)(@vitest/browser@1.6.1)(@vitest/ui@1.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0) + vitest: 1.6.1(@types/node@25.2.1)(@vitest/browser@1.6.1)(@vitest/ui@1.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.1.3(@vitest/browser@4.1.3)(vitest@4.1.3)': + '@vitest/coverage-v8@4.1.3(@vitest/browser@4.1.3)(vitest@4.1.6)': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.1.3 @@ -7996,11 +8113,11 @@ snapshots: istanbul-reports: 3.2.0 magicast: 0.5.2 obug: 2.1.1 - std-env: 4.0.0 + std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.3(@types/node@25.5.2)(@vitest/browser-playwright@4.1.3)(@vitest/coverage-v8@4.1.3)(jsdom@29.0.2(canvas@3.2.1))(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2)) + vitest: 4.1.6(@types/node@25.7.0)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.3)(jsdom@29.1.1(canvas@3.2.1))(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2)) optionalDependencies: - '@vitest/browser': 4.1.3(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2))(vitest@4.1.3) + '@vitest/browser': 4.1.3(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2))(vitest@4.1.6) '@vitest/expect@1.6.1': dependencies: @@ -8016,22 +8133,30 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/expect@4.1.3': + '@vitest/expect@4.1.6': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.3 - '@vitest/utils': 4.1.3 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.3(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2))': + '@vitest/mocker@4.1.3(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.1.3 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) + vite: 8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2) + + '@vitest/mocker@4.1.6(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.1.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2) '@vitest/pretty-format@3.2.4': dependencies: @@ -8041,15 +8166,19 @@ snapshots: dependencies: tinyrainbow: 3.1.0 + '@vitest/pretty-format@4.1.6': + dependencies: + tinyrainbow: 3.1.0 + '@vitest/runner@1.6.1': dependencies: '@vitest/utils': 1.6.1 p-limit: 5.0.0 pathe: 1.1.2 - '@vitest/runner@4.1.3': + '@vitest/runner@4.1.6': dependencies: - '@vitest/utils': 4.1.3 + '@vitest/utils': 4.1.6 pathe: 2.0.3 '@vitest/snapshot@1.6.1': @@ -8058,10 +8187,10 @@ snapshots: pathe: 1.1.2 pretty-format: 29.7.0 - '@vitest/snapshot@4.1.3': + '@vitest/snapshot@4.1.6': dependencies: - '@vitest/pretty-format': 4.1.3 - '@vitest/utils': 4.1.3 + '@vitest/pretty-format': 4.1.6 + '@vitest/utils': 4.1.6 magic-string: 0.30.21 pathe: 2.0.3 @@ -8075,6 +8204,8 @@ snapshots: '@vitest/spy@4.1.3': {} + '@vitest/spy@4.1.6': {} + '@vitest/ui@1.6.1(vitest@1.6.1)': dependencies: '@vitest/utils': 1.6.1 @@ -8084,7 +8215,7 @@ snapshots: pathe: 1.1.2 picocolors: 1.1.1 sirv: 2.0.4 - vitest: 1.6.1(@types/node@25.2.1)(@vitest/browser@1.6.1)(@vitest/ui@1.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0) + vitest: 1.6.1(@types/node@25.2.1)(@vitest/browser@1.6.1)(@vitest/ui@1.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0) '@vitest/utils@1.6.1': dependencies: @@ -8105,6 +8236,12 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@vitest/utils@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@volar/language-core@2.4.28': dependencies: '@volar/source-map': 2.4.28 @@ -8135,7 +8272,7 @@ snapshots: de-indent: 1.0.2 he: 1.2.0 - '@vue/language-core@2.2.0(typescript@6.0.2)': + '@vue/language-core@2.2.0(typescript@6.0.3)': dependencies: '@volar/language-core': 2.4.28 '@vue/compiler-dom': 3.5.27 @@ -8146,7 +8283,7 @@ snapshots: muggle-string: 0.4.1 path-browserify: 1.0.1 optionalDependencies: - typescript: 6.0.2 + typescript: 6.0.3 '@vue/shared@3.5.27': {} @@ -8177,6 +8314,12 @@ snapshots: acorn@8.16.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + agent-base@7.1.4: {} ajv-draft-04@1.0.0(ajv@8.13.0): @@ -8187,7 +8330,7 @@ snapshots: optionalDependencies: ajv: 8.13.0 - ajv@6.14.0: + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 @@ -8229,7 +8372,7 @@ snapshots: anymatch@3.1.3: dependencies: normalize-path: 3.0.0 - picomatch: 2.3.2 + picomatch: 2.3.1 argparse@1.0.10: dependencies: @@ -8354,20 +8497,20 @@ snapshots: asynckit@0.4.0: {} - autoprefixer@10.4.27(postcss@8.5.8): + autoprefixer@10.5.0(postcss@8.5.14): dependencies: browserslist: 4.28.2 - caniuse-lite: 1.0.30001784 + caniuse-lite: 1.0.30001792 fraction.js: 5.3.4 picocolors: 1.1.1 - postcss: 8.5.8 + postcss: 8.5.14 postcss-value-parser: 4.2.0 available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 - axe-core@4.11.2: {} + axe-core@4.11.1: {} axios@0.26.1: dependencies: @@ -8375,13 +8518,15 @@ snapshots: transitivePeerDependencies: - debug - axios@1.14.0: + axios@1.16.1: dependencies: - follow-redirects: 1.15.11 + follow-redirects: 1.16.0 form-data: 4.0.5 + https-proxy-agent: 5.0.1 proxy-from-env: 2.1.0 transitivePeerDependencies: - debug + - supports-color axobject-query@4.1.0: {} @@ -8395,7 +8540,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.13: {} + baseline-browser-mapping@2.10.29: {} baseline-browser-mapping@2.9.19: {} @@ -8451,7 +8596,7 @@ snapshots: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.5: + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -8469,10 +8614,10 @@ snapshots: browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.13 - caniuse-lite: 1.0.30001784 - electron-to-chromium: 1.5.331 - node-releases: 2.0.37 + baseline-browser-mapping: 2.10.29 + caniuse-lite: 1.0.30001792 + electron-to-chromium: 1.5.355 + node-releases: 2.0.44 update-browserslist-db: 1.2.3(browserslist@4.28.2) buffer-crc32@0.2.13: {} @@ -8497,13 +8642,13 @@ snapshots: cac@6.7.14: {} - cacheable@2.3.3: + cacheable@2.3.5: dependencies: '@cacheable/memory': 2.0.8 - '@cacheable/utils': 2.4.0 + '@cacheable/utils': 2.4.1 hookified: 1.15.1 keyv: 5.6.0 - qified: 0.6.0 + qified: 0.10.1 call-bind-apply-helpers@1.0.2: dependencies: @@ -8526,7 +8671,7 @@ snapshots: caniuse-lite@1.0.30001769: {} - caniuse-lite@1.0.30001784: {} + caniuse-lite@1.0.30001792: {} canvas@3.2.1: dependencies: @@ -8721,14 +8866,14 @@ snapshots: core-util-is@1.0.3: {} - cosmiconfig@9.0.1(typescript@6.0.2): + cosmiconfig@9.0.1(typescript@6.0.3): dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 js-yaml: 4.1.1 parse-json: 5.2.0 optionalDependencies: - typescript: 6.0.2 + typescript: 6.0.3 cross-fetch@3.2.0(encoding@0.1.13): dependencies: @@ -9000,7 +9145,7 @@ snapshots: electron-to-chromium@1.5.286: {} - electron-to-chromium@1.5.331: {} + electron-to-chromium@1.5.355: {} emoji-regex@8.0.0: {} @@ -9028,6 +9173,8 @@ snapshots: entities@7.0.1: {} + entities@8.0.0: {} + env-paths@2.2.1: {} error-ex@1.3.4: @@ -9095,7 +9242,7 @@ snapshots: es-errors@1.3.0: {} - es-iterator-helpers@1.3.1: + es-iterator-helpers@1.2.2: dependencies: call-bind: 1.0.8 call-bound: 1.0.4 @@ -9112,10 +9259,9 @@ snapshots: has-symbols: 1.1.0 internal-slot: 1.1.0 iterator.prototype: 1.1.5 - math-intrinsics: 1.1.0 safe-array-concat: 1.1.3 - es-module-lexer@2.0.0: {} + es-module-lexer@2.1.0: {} es-object-atoms@1.1.1: dependencies: @@ -9193,34 +9339,34 @@ snapshots: '@esbuild/win32-ia32': 0.27.3 '@esbuild/win32-x64': 0.27.3 - esbuild@0.27.7: + esbuild@0.27.4: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.7 - '@esbuild/android-arm': 0.27.7 - '@esbuild/android-arm64': 0.27.7 - '@esbuild/android-x64': 0.27.7 - '@esbuild/darwin-arm64': 0.27.7 - '@esbuild/darwin-x64': 0.27.7 - '@esbuild/freebsd-arm64': 0.27.7 - '@esbuild/freebsd-x64': 0.27.7 - '@esbuild/linux-arm': 0.27.7 - '@esbuild/linux-arm64': 0.27.7 - '@esbuild/linux-ia32': 0.27.7 - '@esbuild/linux-loong64': 0.27.7 - '@esbuild/linux-mips64el': 0.27.7 - '@esbuild/linux-ppc64': 0.27.7 - '@esbuild/linux-riscv64': 0.27.7 - '@esbuild/linux-s390x': 0.27.7 - '@esbuild/linux-x64': 0.27.7 - '@esbuild/netbsd-arm64': 0.27.7 - '@esbuild/netbsd-x64': 0.27.7 - '@esbuild/openbsd-arm64': 0.27.7 - '@esbuild/openbsd-x64': 0.27.7 - '@esbuild/openharmony-arm64': 0.27.7 - '@esbuild/sunos-x64': 0.27.7 - '@esbuild/win32-arm64': 0.27.7 - '@esbuild/win32-ia32': 0.27.7 - '@esbuild/win32-x64': 0.27.7 + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 esbuild@0.28.0: optionalDependencies: @@ -9259,20 +9405,20 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-import-resolver-node@0.3.10: + eslint-import-resolver-node@0.3.9: dependencies: debug: 3.2.7 is-core-module: 2.16.1 - resolve: 2.0.0-next.6 + resolve: 1.22.11 transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.10)(eslint@9.39.2): + eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.9)(eslint@9.39.2): dependencies: debug: 3.2.7 optionalDependencies: eslint: 9.39.2 - eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color @@ -9286,8 +9432,8 @@ snapshots: debug: 3.2.7 doctrine: 2.1.0 eslint: 9.39.2 - eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.10)(eslint@9.39.2) + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint@9.39.2) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -9309,7 +9455,7 @@ snapshots: array-includes: 3.1.9 array.prototype.flatmap: 1.3.3 ast-types-flow: 0.0.8 - axe-core: 4.11.2 + axe-core: 4.11.1 axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 @@ -9340,7 +9486,7 @@ snapshots: array.prototype.flatmap: 1.3.3 array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 - es-iterator-helpers: 1.3.1 + es-iterator-helpers: 1.2.2 eslint: 9.39.2 estraverse: 5.3.0 hasown: 2.0.2 @@ -9350,7 +9496,7 @@ snapshots: object.fromentries: 2.0.8 object.values: 1.2.1 prop-types: 15.8.1 - resolve: 2.0.0-next.6 + resolve: 2.0.0-next.5 semver: 6.3.1 string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 @@ -9374,11 +9520,11 @@ snapshots: '@eslint/eslintrc': 3.3.5 '@eslint/js': 9.39.2 '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.7 + '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.14.0 + '@types/estree': 1.0.9 + ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3(supports-color@5.5.0) @@ -9433,7 +9579,7 @@ snapshots: events@3.3.0: {} - eventsource-parser@3.0.6: {} + eventsource-parser@3.0.8: {} execa@8.0.1: dependencies: @@ -9526,6 +9672,10 @@ snapshots: transitivePeerDependencies: - encoding + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -9534,9 +9684,9 @@ snapshots: fflate@0.8.2: {} - file-entry-cache@11.1.2: + file-entry-cache@11.1.3: dependencies: - flat-cache: 6.1.20 + flat-cache: 6.1.22 file-entry-cache@8.0.0: dependencies: @@ -9571,10 +9721,10 @@ snapshots: flatted: 3.4.2 keyv: 4.5.4 - flat-cache@6.1.20: + flat-cache@6.1.22: dependencies: - cacheable: 2.3.3 - flatted: 3.3.3 + cacheable: 2.3.5 + flatted: 3.4.2 hookified: 1.15.1 flatted@3.3.3: {} @@ -9585,6 +9735,8 @@ snapshots: follow-redirects@1.15.11: {} + follow-redirects@1.16.0: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -9657,7 +9809,7 @@ snapshots: get-caller-file@2.0.5: {} - get-east-asian-width@1.5.0: {} + get-east-asian-width@1.6.0: {} get-func-name@2.0.2: {} @@ -9717,6 +9869,12 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@13.0.1: + dependencies: + minimatch: 10.1.2 + minipass: 7.1.2 + path-scurry: 2.0.1 + glob@13.0.6: dependencies: minimatch: 10.2.5 @@ -9749,7 +9907,7 @@ snapshots: define-properties: 1.2.1 gopd: 1.2.0 - globby@16.1.1: + globby@16.2.0: dependencies: '@sindresorhus/merge-streams': 4.0.0 fast-glob: 3.3.3 @@ -9786,7 +9944,7 @@ snapshots: dependencies: has-symbols: 1.1.0 - hashery@1.5.0: + hashery@1.5.1: dependencies: hookified: 1.15.1 @@ -9806,11 +9964,13 @@ snapshots: hookified@1.15.1: {} + hookified@2.2.0: {} + hosted-git-info@2.8.9: {} html-encoding-sniffer@6.0.0: dependencies: - '@exodus/bytes': 1.15.0 + '@exodus/bytes': 1.11.0 transitivePeerDependencies: - '@noble/hashes' @@ -9833,6 +9993,13 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -9852,9 +10019,9 @@ snapshots: dependencies: safer-buffer: 2.1.2 - icss-utils@5.1.0(postcss@8.5.8): + icss-utils@5.1.0(postcss@8.5.14): dependencies: - postcss: 8.5.8 + postcss: 8.5.14 ieee754@1.2.1: {} @@ -10106,13 +10273,13 @@ snapshots: jju@1.4.0: {} - joi@18.1.2: + joi@18.2.1: dependencies: '@hapi/address': 5.1.1 '@hapi/formula': 3.0.2 '@hapi/hoek': 11.0.7 '@hapi/pinpoint': 2.0.1 - '@hapi/tlds': 1.1.6 + '@hapi/tlds': 1.1.4 '@hapi/topo': 6.0.2 '@standard-schema/spec': 1.1.0 @@ -10166,24 +10333,24 @@ snapshots: - supports-color - utf-8-validate - jsdom@29.0.2(canvas@3.2.1): + jsdom@29.1.1(canvas@3.2.1): dependencies: - '@asamuzakjp/css-color': 5.1.6 - '@asamuzakjp/dom-selector': 7.0.8 + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 '@bramus/specificity': 2.4.2 - '@csstools/css-syntax-patches-for-csstree': 1.1.2(css-tree@3.2.1) + '@csstools/css-syntax-patches-for-csstree': 1.1.4(css-tree@3.2.1) '@exodus/bytes': 1.15.0 css-tree: 3.2.1 data-urls: 7.0.0 decimal.js: 10.6.0 html-encoding-sniffer: 6.0.0 is-potential-custom-element-name: 1.0.1 - lru-cache: 11.3.2 - parse5: 8.0.0 + lru-cache: 11.3.6 + parse5: 8.0.1 saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.1 - undici: 7.24.7 + undici: 7.25.0 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 @@ -10380,9 +10547,7 @@ snapshots: lru-cache@11.2.5: {} - lru-cache@11.2.7: {} - - lru-cache@11.3.2: {} + lru-cache@11.3.6: {} lru-cache@5.1.1: dependencies: @@ -10406,7 +10571,7 @@ snapshots: magicast@0.5.2: dependencies: - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.0 '@babel/types': 7.29.0 source-map-js: 1.2.1 @@ -10416,7 +10581,7 @@ snapshots: map-stream@0.0.7: {} - marked@17.0.5: {} + marked@17.0.6: {} math-intrinsics@1.1.0: {} @@ -10488,7 +10653,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.5 + brace-expansion: 5.0.6 minimatch@3.1.2: dependencies: @@ -10564,13 +10729,6 @@ snapshots: node-addon-api@7.1.1: {} - node-exports-info@1.6.0: - dependencies: - array.prototype.flatmap: 1.3.3 - es-errors: 1.3.0 - object.entries: 1.1.9 - semver: 6.3.1 - node-fetch@2.7.0(encoding@0.1.13): dependencies: whatwg-url: 5.0.0 @@ -10579,7 +10737,7 @@ snapshots: node-releases@2.0.27: {} - node-releases@2.0.37: {} + node-releases@2.0.44: {} nodemon@3.1.14: dependencies: @@ -10764,6 +10922,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + parseurl@1.3.3: {} patch-package@8.0.1: @@ -10802,9 +10964,14 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + path-scurry@2.0.1: + dependencies: + lru-cache: 11.2.5 + minipass: 7.1.2 + path-scurry@2.0.2: dependencies: - lru-cache: 11.2.7 + lru-cache: 11.2.5 minipass: 7.1.3 path-to-regexp@8.3.0: {} @@ -10834,8 +11001,6 @@ snapshots: picomatch@2.3.1: {} - picomatch@2.3.2: {} - picomatch@4.0.3: {} picomatch@4.0.4: {} @@ -10858,7 +11023,7 @@ snapshots: playwright-core@1.58.0: {} - playwright-core@1.59.1: {} + playwright-core@1.60.0: {} playwright@1.58.0: dependencies: @@ -10866,9 +11031,9 @@ snapshots: optionalDependencies: fsevents: 2.3.2 - playwright@1.59.1: + playwright@1.60.0: dependencies: - playwright-core: 1.59.1 + playwright-core: 1.60.0 optionalDependencies: fsevents: 2.3.2 @@ -10887,48 +11052,48 @@ snapshots: postcss-media-query-parser@0.2.3: {} - postcss-modules-extract-imports@3.1.0(postcss@8.5.8): + postcss-modules-extract-imports@3.1.0(postcss@8.5.14): dependencies: - postcss: 8.5.8 + postcss: 8.5.14 - postcss-modules-local-by-default@4.2.0(postcss@8.5.8): + postcss-modules-local-by-default@4.2.0(postcss@8.5.14): dependencies: - icss-utils: 5.1.0(postcss@8.5.8) - postcss: 8.5.8 + icss-utils: 5.1.0(postcss@8.5.14) + postcss: 8.5.14 postcss-selector-parser: 7.1.1 postcss-value-parser: 4.2.0 - postcss-modules-scope@3.2.1(postcss@8.5.8): + postcss-modules-scope@3.2.1(postcss@8.5.14): dependencies: - postcss: 8.5.8 + postcss: 8.5.14 postcss-selector-parser: 7.1.1 - postcss-modules-values@4.0.0(postcss@8.5.8): + postcss-modules-values@4.0.0(postcss@8.5.14): dependencies: - icss-utils: 5.1.0(postcss@8.5.8) - postcss: 8.5.8 + icss-utils: 5.1.0(postcss@8.5.14) + postcss: 8.5.14 - postcss-modules@6.0.1(postcss@8.5.8): + postcss-modules@6.0.1(postcss@8.5.14): dependencies: generic-names: 4.0.0 - icss-utils: 5.1.0(postcss@8.5.8) + icss-utils: 5.1.0(postcss@8.5.14) lodash.camelcase: 4.3.0 - postcss: 8.5.8 - postcss-modules-extract-imports: 3.1.0(postcss@8.5.8) - postcss-modules-local-by-default: 4.2.0(postcss@8.5.8) - postcss-modules-scope: 3.2.1(postcss@8.5.8) - postcss-modules-values: 4.0.0(postcss@8.5.8) + postcss: 8.5.14 + postcss-modules-extract-imports: 3.1.0(postcss@8.5.14) + postcss-modules-local-by-default: 4.2.0(postcss@8.5.14) + postcss-modules-scope: 3.2.1(postcss@8.5.14) + postcss-modules-values: 4.0.0(postcss@8.5.14) string-hash: 1.1.3 postcss-resolve-nested-selector@0.1.6: {} - postcss-safe-parser@7.0.1(postcss@8.5.8): + postcss-safe-parser@7.0.1(postcss@8.5.14): dependencies: - postcss: 8.5.8 + postcss: 8.5.14 - postcss-scss@4.0.9(postcss@8.5.8): + postcss-scss@4.0.9(postcss@8.5.14): dependencies: - postcss: 8.5.8 + postcss: 8.5.14 postcss-selector-parser@7.1.1: dependencies: @@ -10945,7 +11110,7 @@ snapshots: source-map: 0.6.1 supports-color: 5.5.0 - postcss@8.5.8: + postcss@8.5.14: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -11026,9 +11191,9 @@ snapshots: punycode@2.3.1: {} - qified@0.6.0: + qified@0.10.1: dependencies: - hookified: 1.15.1 + hookified: 2.2.0 qs@6.14.1: dependencies: @@ -11056,15 +11221,15 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 - react-compiler-runtime@1.0.0(react@19.2.4): + react-compiler-runtime@1.0.0(react@19.2.3): dependencies: - react: 19.2.4 + react: 19.2.3 - react-docgen-typescript@2.4.0(typescript@6.0.2): + react-docgen-typescript@2.4.0(typescript@6.0.3): dependencies: - typescript: 6.0.2 + typescript: 6.0.3 - react-docgen@8.0.3: + react-docgen@8.0.2: dependencies: '@babel/core': 7.29.0 '@babel/traverse': 7.29.0 @@ -11140,7 +11305,7 @@ snapshots: readdirp@3.6.0: dependencies: - picomatch: 2.3.2 + picomatch: 2.3.1 readdirp@4.1.2: {} @@ -11195,12 +11360,9 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - resolve@2.0.0-next.6: + resolve@2.0.0-next.5: dependencies: - es-errors: 1.3.0 is-core-module: 2.16.1 - node-exports-info: 1.6.0 - object-keys: 1.1.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 @@ -11215,26 +11377,26 @@ snapshots: glob: 13.0.6 package-json-from-dist: 1.0.1 - rolldown@1.0.0-rc.13: + rolldown@1.0.0: dependencies: - '@oxc-project/types': 0.123.0 - '@rolldown/pluginutils': 1.0.0-rc.13 + '@oxc-project/types': 0.129.0 + '@rolldown/pluginutils': 1.0.0 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.13 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.13 - '@rolldown/binding-darwin-x64': 1.0.0-rc.13 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.13 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.13 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.13 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.13 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.13 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.13 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.13 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.13 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.13 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.13 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.13 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.13 + '@rolldown/binding-android-arm64': 1.0.0 + '@rolldown/binding-darwin-arm64': 1.0.0 + '@rolldown/binding-darwin-x64': 1.0.0 + '@rolldown/binding-freebsd-x64': 1.0.0 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0 + '@rolldown/binding-linux-arm64-gnu': 1.0.0 + '@rolldown/binding-linux-arm64-musl': 1.0.0 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0 + '@rolldown/binding-linux-s390x-gnu': 1.0.0 + '@rolldown/binding-linux-x64-gnu': 1.0.0 + '@rolldown/binding-linux-x64-musl': 1.0.0 + '@rolldown/binding-openharmony-arm64': 1.0.0 + '@rolldown/binding-wasm32-wasi': 1.0.0 + '@rolldown/binding-win32-arm64-msvc': 1.0.0 + '@rolldown/binding-win32-x64-msvc': 1.0.0 rollup@4.57.1: dependencies: @@ -11316,65 +11478,65 @@ snapshots: safer-buffer@2.1.2: {} - sass-embedded-all-unknown@1.98.0: + sass-embedded-all-unknown@1.99.0: dependencies: - sass: 1.98.0 + sass: 1.99.0 optional: true - sass-embedded-android-arm64@1.98.0: + sass-embedded-android-arm64@1.99.0: optional: true - sass-embedded-android-arm@1.98.0: + sass-embedded-android-arm@1.99.0: optional: true - sass-embedded-android-riscv64@1.98.0: + sass-embedded-android-riscv64@1.99.0: optional: true - sass-embedded-android-x64@1.98.0: + sass-embedded-android-x64@1.99.0: optional: true - sass-embedded-darwin-arm64@1.98.0: + sass-embedded-darwin-arm64@1.99.0: optional: true - sass-embedded-darwin-x64@1.98.0: + sass-embedded-darwin-x64@1.99.0: optional: true - sass-embedded-linux-arm64@1.98.0: + sass-embedded-linux-arm64@1.99.0: optional: true - sass-embedded-linux-arm@1.98.0: + sass-embedded-linux-arm@1.99.0: optional: true - sass-embedded-linux-musl-arm64@1.98.0: + sass-embedded-linux-musl-arm64@1.99.0: optional: true - sass-embedded-linux-musl-arm@1.98.0: + sass-embedded-linux-musl-arm@1.99.0: optional: true - sass-embedded-linux-musl-riscv64@1.98.0: + sass-embedded-linux-musl-riscv64@1.99.0: optional: true - sass-embedded-linux-musl-x64@1.98.0: + sass-embedded-linux-musl-x64@1.99.0: optional: true - sass-embedded-linux-riscv64@1.98.0: + sass-embedded-linux-riscv64@1.99.0: optional: true - sass-embedded-linux-x64@1.98.0: + sass-embedded-linux-x64@1.99.0: optional: true - sass-embedded-unknown-all@1.98.0: + sass-embedded-unknown-all@1.99.0: dependencies: - sass: 1.98.0 + sass: 1.99.0 optional: true - sass-embedded-win32-arm64@1.98.0: + sass-embedded-win32-arm64@1.99.0: optional: true - sass-embedded-win32-x64@1.98.0: + sass-embedded-win32-x64@1.99.0: optional: true - sass-embedded@1.98.0: + sass-embedded@1.99.0: dependencies: '@bufbuild/protobuf': 2.11.0 colorjs.io: 0.5.2 @@ -11384,26 +11546,26 @@ snapshots: sync-child-process: 1.0.2 varint: 6.0.0 optionalDependencies: - sass-embedded-all-unknown: 1.98.0 - sass-embedded-android-arm: 1.98.0 - sass-embedded-android-arm64: 1.98.0 - sass-embedded-android-riscv64: 1.98.0 - sass-embedded-android-x64: 1.98.0 - sass-embedded-darwin-arm64: 1.98.0 - sass-embedded-darwin-x64: 1.98.0 - sass-embedded-linux-arm: 1.98.0 - sass-embedded-linux-arm64: 1.98.0 - sass-embedded-linux-musl-arm: 1.98.0 - sass-embedded-linux-musl-arm64: 1.98.0 - sass-embedded-linux-musl-riscv64: 1.98.0 - sass-embedded-linux-musl-x64: 1.98.0 - sass-embedded-linux-riscv64: 1.98.0 - sass-embedded-linux-x64: 1.98.0 - sass-embedded-unknown-all: 1.98.0 - sass-embedded-win32-arm64: 1.98.0 - sass-embedded-win32-x64: 1.98.0 + sass-embedded-all-unknown: 1.99.0 + sass-embedded-android-arm: 1.99.0 + sass-embedded-android-arm64: 1.99.0 + sass-embedded-android-riscv64: 1.99.0 + sass-embedded-android-x64: 1.99.0 + sass-embedded-darwin-arm64: 1.99.0 + sass-embedded-darwin-x64: 1.99.0 + sass-embedded-linux-arm: 1.99.0 + sass-embedded-linux-arm64: 1.99.0 + sass-embedded-linux-musl-arm: 1.99.0 + sass-embedded-linux-musl-arm64: 1.99.0 + sass-embedded-linux-musl-riscv64: 1.99.0 + sass-embedded-linux-musl-x64: 1.99.0 + sass-embedded-linux-riscv64: 1.99.0 + sass-embedded-linux-x64: 1.99.0 + sass-embedded-unknown-all: 1.99.0 + sass-embedded-win32-arm64: 1.99.0 + sass-embedded-win32-x64: 1.99.0 - sass@1.98.0: + sass@1.99.0: dependencies: chokidar: 4.0.3 immutable: 5.1.5 @@ -11595,13 +11757,37 @@ snapshots: std-env@3.10.0: {} - std-env@4.0.0: {} + std-env@4.1.0: {} stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 internal-slot: 1.1.0 + storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@storybook/global': 5.0.0 + '@storybook/icons': 2.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@testing-library/jest-dom': 6.9.1 + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + '@vitest/expect': 3.2.4 + '@vitest/spy': 3.2.4 + '@webcontainer/env': 1.1.1 + esbuild: 0.27.4 + open: 10.2.0 + recast: 0.23.11 + semver: 7.7.4 + use-sync-external-store: 1.6.0(react@19.2.3) + ws: 8.19.0 + optionalDependencies: + prettier: 3.8.1 + transitivePeerDependencies: + - '@testing-library/dom' + - bufferutil + - react + - react-dom + - utf-8-validate + storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@storybook/global': 5.0.0 @@ -11611,12 +11797,12 @@ snapshots: '@vitest/expect': 3.2.4 '@vitest/spy': 3.2.4 '@webcontainer/env': 1.1.1 - esbuild: 0.27.7 + esbuild: 0.27.4 open: 10.2.0 recast: 0.23.11 semver: 7.7.4 use-sync-external-store: 1.6.0(react@19.2.4) - ws: 8.20.0 + ws: 8.19.0 optionalDependencies: prettier: 3.8.1 transitivePeerDependencies: @@ -11646,9 +11832,9 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.2 - string-width@8.2.0: + string-width@8.2.1: dependencies: - get-east-asian-width: 1.5.0 + get-east-asian-width: 1.6.0 strip-ansi: 7.1.2 string.prototype.codepointat@0.2.1: {} @@ -11751,7 +11937,7 @@ snapshots: '@bundled-es-modules/deepmerge': 4.3.1 '@bundled-es-modules/glob': 10.4.2 '@bundled-es-modules/memfs': 4.17.0(tslib@2.8.1) - '@types/node': 22.19.17 + '@types/node': 22.19.9 '@zip.js/zip.js': 2.8.26(patch_hash=7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95) chalk: 5.6.2 change-case: 5.4.4 @@ -11765,83 +11951,85 @@ snapshots: transitivePeerDependencies: - tslib - stylelint-config-recommended-scss@17.0.0(postcss@8.5.8)(stylelint@17.4.0(typescript@6.0.2)): + stylelint-config-recommended-scss@17.0.1(postcss@8.5.14)(stylelint@17.11.0(typescript@6.0.3)): dependencies: - postcss-scss: 4.0.9(postcss@8.5.8) - stylelint: 17.4.0(typescript@6.0.2) - stylelint-config-recommended: 18.0.0(stylelint@17.4.0(typescript@6.0.2)) - stylelint-scss: 7.0.0(stylelint@17.4.0(typescript@6.0.2)) + postcss-scss: 4.0.9(postcss@8.5.14) + stylelint: 17.11.0(typescript@6.0.3) + stylelint-config-recommended: 18.0.0(stylelint@17.11.0(typescript@6.0.3)) + stylelint-scss: 7.1.1(stylelint@17.11.0(typescript@6.0.3)) optionalDependencies: - postcss: 8.5.8 + postcss: 8.5.14 - stylelint-config-recommended@18.0.0(stylelint@17.4.0(typescript@6.0.2)): + stylelint-config-recommended@18.0.0(stylelint@17.11.0(typescript@6.0.3)): dependencies: - stylelint: 17.4.0(typescript@6.0.2) + stylelint: 17.11.0(typescript@6.0.3) - stylelint-config-standard-scss@17.0.0(postcss@8.5.8)(stylelint@17.4.0(typescript@6.0.2)): + stylelint-config-standard-scss@17.0.0(postcss@8.5.14)(stylelint@17.11.0(typescript@6.0.3)): dependencies: - stylelint: 17.4.0(typescript@6.0.2) - stylelint-config-recommended-scss: 17.0.0(postcss@8.5.8)(stylelint@17.4.0(typescript@6.0.2)) - stylelint-config-standard: 40.0.0(stylelint@17.4.0(typescript@6.0.2)) + stylelint: 17.11.0(typescript@6.0.3) + stylelint-config-recommended-scss: 17.0.1(postcss@8.5.14)(stylelint@17.11.0(typescript@6.0.3)) + stylelint-config-standard: 40.0.0(stylelint@17.11.0(typescript@6.0.3)) optionalDependencies: - postcss: 8.5.8 + postcss: 8.5.14 - stylelint-config-standard@40.0.0(stylelint@17.4.0(typescript@6.0.2)): + stylelint-config-standard@40.0.0(stylelint@17.11.0(typescript@6.0.3)): dependencies: - stylelint: 17.4.0(typescript@6.0.2) - stylelint-config-recommended: 18.0.0(stylelint@17.4.0(typescript@6.0.2)) + stylelint: 17.11.0(typescript@6.0.3) + stylelint-config-recommended: 18.0.0(stylelint@17.11.0(typescript@6.0.3)) - stylelint-scss@7.0.0(stylelint@17.4.0(typescript@6.0.2)): + stylelint-scss@7.1.1(stylelint@17.11.0(typescript@6.0.3)): dependencies: - css-tree: 3.1.0 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-syntax-patches-for-csstree': 1.1.4(css-tree@3.2.1) + '@csstools/css-tokenizer': 4.0.0 + css-tree: 3.2.1 is-plain-object: 5.0.0 known-css-properties: 0.37.0 - mdn-data: 2.27.1 postcss-media-query-parser: 0.2.3 postcss-resolve-nested-selector: 0.1.6 postcss-selector-parser: 7.1.1 postcss-value-parser: 4.2.0 - stylelint: 17.4.0(typescript@6.0.2) + stylelint: 17.11.0(typescript@6.0.3) - stylelint-use-logical-spec@5.0.1(stylelint@17.4.0(typescript@6.0.2)): + stylelint-use-logical-spec@5.0.1(stylelint@17.11.0(typescript@6.0.3)): dependencies: - stylelint: 17.4.0(typescript@6.0.2) + stylelint: 17.11.0(typescript@6.0.3) - stylelint@17.4.0(typescript@6.0.2): + stylelint@17.11.0(typescript@6.0.3): dependencies: - '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-syntax-patches-for-csstree': 1.1.0 + '@csstools/css-syntax-patches-for-csstree': 1.1.4(css-tree@3.2.1) '@csstools/css-tokenizer': 4.0.0 '@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/selector-resolve-nested': 4.0.0(postcss-selector-parser@7.1.1) '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.1) colord: 2.9.3 - cosmiconfig: 9.0.1(typescript@6.0.2) + cosmiconfig: 9.0.1(typescript@6.0.3) css-functions-list: 3.3.3 - css-tree: 3.1.0 + css-tree: 3.2.1 debug: 4.4.3(supports-color@5.5.0) fast-glob: 3.3.3 fastest-levenshtein: 1.0.16 - file-entry-cache: 11.1.2 + file-entry-cache: 11.1.3 global-modules: 2.0.0 - globby: 16.1.1 + globby: 16.2.0 globjoin: 0.1.4 html-tags: 5.1.0 ignore: 7.0.5 import-meta-resolve: 4.2.0 - imurmurhash: 0.1.4 is-plain-object: 5.0.0 mathml-tag-names: 4.0.0 meow: 14.1.0 micromatch: 4.0.8 normalize-path: 3.0.0 picocolors: 1.1.1 - postcss: 8.5.8 - postcss-safe-parser: 7.0.1(postcss@8.5.8) + postcss: 8.5.14 + postcss-safe-parser: 7.0.1(postcss@8.5.14) postcss-selector-parser: 7.1.1 postcss-value-parser: 4.2.0 - string-width: 8.2.0 + string-width: 8.2.1 supports-hyperlinks: 4.4.0 svg-tags: 1.0.0 table: 6.9.0 @@ -11979,9 +12167,14 @@ snapshots: tinycolor2@1.6.0: {} - tinyexec@1.1.1: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 @@ -11998,16 +12191,10 @@ snapshots: tldts-core@7.0.22: {} - tldts-core@7.0.28: {} - tldts@7.0.22: dependencies: tldts-core: 7.0.22 - tldts@7.0.28: - dependencies: - tldts-core: 7.0.28 - tmp@0.2.5: {} to-regex-range@5.0.1: @@ -12026,7 +12213,7 @@ snapshots: tough-cookie@6.0.1: dependencies: - tldts: 7.0.28 + tldts: 7.0.22 tr46@0.0.3: {} @@ -12110,7 +12297,7 @@ snapshots: typescript@5.8.2: {} - typescript@6.0.2: {} + typescript@6.0.3: {} ua-is-frozen@0.1.2: {} @@ -12137,9 +12324,9 @@ snapshots: undici-types@7.16.0: {} - undici-types@7.18.2: {} + undici-types@7.21.0: {} - undici@7.24.7: {} + undici@7.25.0: {} unicorn-magic@0.4.0: {} @@ -12151,7 +12338,7 @@ snapshots: dependencies: '@jridgewell/remapping': 2.3.5 acorn: 8.16.0 - picomatch: 4.0.4 + picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 update-browserslist-db@1.2.3(browserslist@4.28.1): @@ -12175,6 +12362,10 @@ snapshots: punycode: 1.4.1 qs: 6.14.1 + use-sync-external-store@1.6.0(react@19.2.3): + dependencies: + react: 19.2.3 + use-sync-external-store@1.6.0(react@19.2.4): dependencies: react: 19.2.4 @@ -12211,13 +12402,13 @@ snapshots: remove-trailing-separator: 1.1.0 replace-ext: 1.0.1 - vite-node@1.6.1(@types/node@25.2.1)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0): + vite-node@1.6.1(@types/node@25.2.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0): dependencies: cac: 6.7.14 debug: 4.4.3(supports-color@5.5.0) pathe: 1.1.2 picocolors: 1.1.1 - vite: 5.4.21(@types/node@25.2.1)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0) + vite: 5.4.21(@types/node@25.2.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0) transitivePeerDependencies: - '@types/node' - less @@ -12229,53 +12420,53 @@ snapshots: - supports-color - terser - vite-plugin-dts@4.5.4(@types/node@25.5.2)(rollup@4.57.1)(typescript@6.0.2)(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2)): + vite-plugin-dts@4.5.4(@types/node@25.7.0)(rollup@4.57.1)(typescript@6.0.3)(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2)): dependencies: - '@microsoft/api-extractor': 7.56.2(@types/node@25.5.2) + '@microsoft/api-extractor': 7.56.2(@types/node@25.7.0) '@rollup/pluginutils': 5.3.0(rollup@4.57.1) '@volar/typescript': 2.4.28 - '@vue/language-core': 2.2.0(typescript@6.0.2) + '@vue/language-core': 2.2.0(typescript@6.0.3) compare-versions: 6.1.1 debug: 4.4.3(supports-color@5.5.0) kolorist: 1.8.0 local-pkg: 1.1.2 magic-string: 0.30.21 - typescript: 6.0.2 + typescript: 6.0.3 optionalDependencies: - vite: 8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) + vite: 8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - rollup - supports-color - vite@5.4.21(@types/node@25.2.1)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0): + vite@5.4.21(@types/node@25.2.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0): dependencies: esbuild: 0.21.5 - postcss: 8.5.8 + postcss: 8.5.14 rollup: 4.57.1 optionalDependencies: '@types/node': 25.2.1 fsevents: 2.3.3 lightningcss: 1.32.0 - sass: 1.98.0 - sass-embedded: 1.98.0 + sass: 1.99.0 + sass-embedded: 1.99.0 - vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2): + vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.8 - rolldown: 1.0.0-rc.13 - tinyglobby: 0.2.15 + postcss: 8.5.14 + rolldown: 1.0.0 + tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 25.5.2 + '@types/node': 25.7.0 esbuild: 0.28.0 fsevents: 2.3.3 - sass: 1.98.0 - sass-embedded: 1.98.0 + sass: 1.99.0 + sass-embedded: 1.99.0 yaml: 2.8.2 - vitest@1.6.1(@types/node@25.2.1)(@vitest/browser@1.6.1)(@vitest/ui@1.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0): + vitest@1.6.1(@types/node@25.2.1)(@vitest/browser@1.6.1)(@vitest/ui@1.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0): dependencies: '@vitest/expect': 1.6.1 '@vitest/runner': 1.6.1 @@ -12294,8 +12485,8 @@ snapshots: strip-literal: 2.1.1 tinybench: 2.9.0 tinypool: 0.8.4 - vite: 5.4.21(@types/node@25.2.1)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0) - vite-node: 1.6.1(@types/node@25.2.1)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0) + vite: 5.4.21(@types/node@25.2.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0) + vite-node: 1.6.1(@types/node@25.2.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.2.1 @@ -12312,33 +12503,33 @@ snapshots: - supports-color - terser - vitest@4.1.3(@types/node@25.5.2)(@vitest/browser-playwright@4.1.3)(@vitest/coverage-v8@4.1.3)(jsdom@29.0.2(canvas@3.2.1))(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2)): + vitest@4.1.6(@types/node@25.7.0)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.3)(jsdom@29.1.1(canvas@3.2.1))(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2)): dependencies: - '@vitest/expect': 4.1.3 - '@vitest/mocker': 4.1.3(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2)) - '@vitest/pretty-format': 4.1.3 - '@vitest/runner': 4.1.3 - '@vitest/snapshot': 4.1.3 - '@vitest/spy': 4.1.3 - '@vitest/utils': 4.1.3 - es-module-lexer: 2.0.0 + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.0.0 + picomatch: 4.0.3 + std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 1.1.1 + tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) + vite: 8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.5.2 - '@vitest/browser-playwright': 4.1.3(playwright@1.59.1)(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2))(vitest@4.1.3) - '@vitest/coverage-v8': 4.1.3(@vitest/browser@4.1.3)(vitest@4.1.3) - jsdom: 29.0.2(canvas@3.2.1) + '@types/node': 25.7.0 + '@vitest/browser-playwright': 4.1.6(playwright@1.60.0)(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.2))(vitest@4.1.6) + '@vitest/coverage-v8': 4.1.3(@vitest/browser@4.1.3)(vitest@4.1.6) + jsdom: 29.1.1(canvas@3.2.1) transitivePeerDependencies: - msw @@ -12348,15 +12539,16 @@ snapshots: dependencies: xml-name-validator: 5.0.0 - wait-on@9.0.4: + wait-on@9.0.10: dependencies: - axios: 1.14.0 - joi: 18.1.2 + axios: 1.16.1 + joi: 18.2.1 lodash: 4.18.1 minimist: 1.2.8 rxjs: 7.8.2 transitivePeerDependencies: - debug + - supports-color wasm-pack@0.13.1: dependencies: @@ -12474,7 +12666,7 @@ snapshots: word-wrap@1.2.5: {} - workerpool@10.0.1: {} + workerpool@10.0.2: {} wrap-ansi@7.0.0: dependencies: @@ -12496,8 +12688,6 @@ snapshots: ws@8.19.0: {} - ws@8.20.0: {} - wsl-utils@0.1.0: dependencies: is-wsl: 3.1.0 diff --git a/frontend/resources/images/assets/logo-nitrate-unavailable.svg b/frontend/resources/images/assets/logo-nitrate-unavailable.svg new file mode 100644 index 0000000000..20274a3176 --- /dev/null +++ b/frontend/resources/images/assets/logo-nitrate-unavailable.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="-1466.16 8469.63 101.36 30.92" fill="currentColor"> + <path d="M-1448.93359375,8500.544921875L-1466.1591796875,8493.12890625L-1454.9228515625,8478.05078125L-1430.9052734375,8471.634765625L-1424.8349609375,8494.107421875L-1448.93359375,8500.544921875M-1457.3271484375,8492.91015625L-1450.6474609375,8495.79296875L-1454.0634765625,8483.091796875L-1458.3544921875,8488.85546875L-1457.3271484375,8492.91015625M-1451.5966796875,8481.1484375L-1449.9765625,8487.171875L-1428.296875,8481.37890625L-1429.890625,8475.349609375L-1451.5966796875,8481.1484375M-1449.1455078125,8490.2626953125L-1447.5341796875,8496.25390625L-1425.826171875,8490.455078125L-1427.44921875,8484.466796875L-1449.1455078125,8490.2626953125"/> + <path d="M-1366.314453125,8469.626953125L-1412.9775390625,8469.626953125L-1413.001953125,8492.8994140625L-1366.2548828125,8492.8984375C-1366.2548828125,8492.8984375,-1364.8046875,8489.115234375,-1364.82421875,8481.5634765625C-1364.8447265625,8473.6005859375,-1366.314453125,8469.626953125,-1366.314453125,8469.626953125M-1370.208984375,8473.4765625L-1370.208984375,8479.7119140625L-1412.96484375,8479.7119140625L-1412.9921875,8473.4765625L-1370.208984375,8473.4765625M-1370.208984375,8482.912109375L-1370.208984375,8489.115234375L-1412.9931640625,8489.115234375L-1412.9814453125,8482.912109375L-1370.208984375,8482.912109375"/> +</svg> diff --git a/frontend/resources/images/assets/penpot-logo-subtle.svg b/frontend/resources/images/assets/penpot-logo-subtle.svg new file mode 100644 index 0000000000..c89c3ece9b --- /dev/null +++ b/frontend/resources/images/assets/penpot-logo-subtle.svg @@ -0,0 +1 @@ +<svg width="24" xmlns="http://www.w3.org/2000/svg" height="24" id="screenshot-3ce8db62-a893-8010-8008-020dbf353255" viewBox="0 0 24 24" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"><g id="shape-3ce8db62-a893-8010-8008-020dbf353255"><g class="frame-container-wrapper"><g class="frame-container-blur"><g class="frame-container-shadows"><g fill="none"><g class="fills" id="fills-3ce8db62-a893-8010-8008-020dbf353255"><rect rx="0" ry="0" x="0" y="0" width="24" height="24" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" class="frame-background"/></g><g class="frame-children"><g id="shape-3ce8db62-a893-8010-8008-020da575dff9"><g class="fills" id="fills-3ce8db62-a893-8010-8008-020da575dff9"><path d="M8.376953125,2L6.275421142578125,4.960968017578125L6.275421142578125,6.636688232421875L4.4433746337890625,7.5078125L4.427734375,7.500030517578125L4.427734375,18.42181396484375L11.564437866210938,21.792938232421875L11.998077392578125,22L12.435546875,21.792938232421875L19.572250366210938,18.42181396484375L19.572250366210938,7.500030517578125L19.560562133789062,7.5078125L17.728515625,6.636688232421875L17.728515625,4.960968017578125L17.666030883789062,4.87109375L15.623046875,2L13.521514892578125,4.960968017578125L13.521514892578125,4.96478271484375L11.990234375,2.808563232421875L10.470687866210938,4.94921875L10.416046142578125,4.87109375ZM8.748046875,3.71875L9.560562133789062,4.863311767578125L7.1894378662109375,4.863311767578125L7.994171142578125,3.730499267578125ZM15.994140625,3.71875L16.806655883789062,4.863311767578125L14.435531616210938,4.863311767578125L15.240264892578125,3.730499267578125ZM12.361328125,4.531280517578125L13.173843383789062,5.67584228515625L10.802719116210938,5.67584228515625L11.607452392578125,4.54302978515625ZM6.966796875,5.445281982421875L8.091827392578125,5.445281982421875L8.091827392578125,9.23052978515625L6.966796875,8.69921875ZM8.669921875,5.445281982421875L9.787094116210938,5.445281982421875L9.787094116210938,10.03131103515625L8.669921875,9.50396728515625ZM14.212890625,5.445281982421875L15.337921142578125,5.445281982421875L15.337921142578125,9.5L14.212890625,10.03131103515625ZM15.912109375,5.445281982421875L17.029296875,5.445281982421875L17.029296875,8.69921875L15.912109375,9.2265625ZM10.576187133789062,6.25384521484375L11.701156616210938,6.25384521484375L11.701156616210938,10.937530517578125L10.576187133789062,10.406219482421875ZM12.279296875,6.25384521484375L13.396484375,6.25384521484375L13.396484375,10.41400146484375L12.279296875,10.94134521484375ZM17.724624633789062,7.496063232421875L18.712875366210938,7.90240478515625L17.724624633789062,8.37115478515625ZM6.2714691162109375,7.500030517578125L6.2714691162109375,8.37115478515625L5.2832183837890625,7.90240478515625ZM5.298858642578125,8.875L11.560546875,11.832000732421875L11.560546875,20.828125L5.298858642578125,17.871124267578125ZM18.697296142578125,8.875L18.697296142578125,17.871124267578125L12.435546875,20.828125L12.435546875,11.835968017578125Z" style="fill: rgb(143, 157, 163); fill-opacity: 1;"/></g></g></g></g></g></g></g></g></svg> \ No newline at end of file diff --git a/frontend/resources/images/features/2.15-mcp-01.gif b/frontend/resources/images/features/2.15-mcp-01.gif new file mode 100644 index 0000000000..365bb5ce63 Binary files /dev/null and b/frontend/resources/images/features/2.15-mcp-01.gif differ diff --git a/frontend/resources/images/features/2.15-mcp-02.gif b/frontend/resources/images/features/2.15-mcp-02.gif new file mode 100644 index 0000000000..b2030b9c12 Binary files /dev/null and b/frontend/resources/images/features/2.15-mcp-02.gif differ diff --git a/frontend/resources/images/features/2.15-mcp-03.gif b/frontend/resources/images/features/2.15-mcp-03.gif new file mode 100644 index 0000000000..1a79926e64 Binary files /dev/null and b/frontend/resources/images/features/2.15-mcp-03.gif differ diff --git a/frontend/resources/images/features/2.15-slide-0.jpg b/frontend/resources/images/features/2.15-slide-0.jpg new file mode 100644 index 0000000000..8e5b90b7dd Binary files /dev/null and b/frontend/resources/images/features/2.15-slide-0.jpg differ diff --git a/frontend/resources/images/penpot-link-preview.png b/frontend/resources/images/penpot-link-preview.png new file mode 100644 index 0000000000..01e3e25272 Binary files /dev/null and b/frontend/resources/images/penpot-link-preview.png differ diff --git a/frontend/resources/images/registration-illustration.png b/frontend/resources/images/registration-illustration.png index 3ccd26deab..21b785ddf0 100644 Binary files a/frontend/resources/images/registration-illustration.png and b/frontend/resources/images/registration-illustration.png differ diff --git a/frontend/resources/styles/common/base.scss b/frontend/resources/styles/common/base.scss index 37df67b3e8..e8761a123f 100644 --- a/frontend/resources/styles/common/base.scss +++ b/frontend/resources/styles/common/base.scss @@ -27,14 +27,6 @@ body { width: 100vw; height: 100vh; overflow: hidden; - - &.cursor-drag-scrub { - cursor: ew-resize !important; - - * { - cursor: ew-resize !important; - } - } } #app { diff --git a/frontend/resources/styles/common/refactor/basic-rules.scss b/frontend/resources/styles/common/refactor/basic-rules.scss index 5cb82f6c55..2c9da12a90 100644 --- a/frontend/resources/styles/common/refactor/basic-rules.scss +++ b/frontend/resources/styles/common/refactor/basic-rules.scss @@ -378,14 +378,6 @@ border: $s-1 solid var(--input-border-color); color: var(--input-foreground-color); - &:not(:focus-within) { - cursor: ew-resize; - - input { - cursor: ew-resize; - } - } - span, label { @extend %input-label; @@ -915,7 +907,7 @@ position: absolute; padding: $s-4; border-radius: $br-8; - z-index: $z-index-10; + z-index: $z-index-dropdown; color: var(--title-foreground-color-hover); background-color: var(--menu-background-color); border: $s-2 solid var(--panel-border-color); diff --git a/frontend/resources/styles/common/refactor/z-index.scss b/frontend/resources/styles/common/refactor/z-index.scss index 755b2e9fad..3d36cb37f5 100644 --- a/frontend/resources/styles/common/refactor/z-index.scss +++ b/frontend/resources/styles/common/refactor/z-index.scss @@ -11,5 +11,5 @@ $z-index-4: 4; // context menu $z-index-5: 5; // modal $z-index-10: 10; $z-index-20: 20; -$z-index-modal: 30; // When refactor finish we can reduce this number, -$z-index-alert: 40; // When refactor finish we can reduce this number, +$z-index-modal: 300; +$z-index-dropdown: 400; diff --git a/frontend/resources/templates/index.mustache b/frontend/resources/templates/index.mustache index f80b7e7759..597ab8db3a 100644 --- a/frontend/resources/templates/index.mustache +++ b/frontend/resources/templates/index.mustache @@ -3,17 +3,17 @@ <head> <meta charset="utf-8" /> <meta http-equiv="x-ua-compatible" content="ie=edge" /> - <title>Penpot - Design Freedom for Teams + Penpot | Full-stack design - + - - - - + + + + - - + + @@ -31,7 +31,6 @@ globalThis.penpotVersion = "{{& version}}"; globalThis.penpotVersionTag = "{{& version_tag}}"; globalThis.penpotBuildDate = "{{& build_date}}"; - globalThis.penpotWorkerURI = "{{& manifest.worker_main}}"; {{# manifest}} diff --git a/frontend/resources/templates/rasterizer.mustache b/frontend/resources/templates/rasterizer.mustache index 90a7f1dfdc..6a3d815e29 100644 --- a/frontend/resources/templates/rasterizer.mustache +++ b/frontend/resources/templates/rasterizer.mustache @@ -9,7 +9,6 @@ globalThis.penpotVersion = "{{& version}}"; globalThis.penpotVersionTag = "{{& version_tag}}"; globalThis.penpotBuildDate = "{{& build_date}}"; - globalThis.penpotWorkerURI = "{{& manifest.worker_main}}"; {{# manifest}} diff --git a/frontend/resources/templates/render.mustache b/frontend/resources/templates/render.mustache index 4de213f9ad..67629b075e 100644 --- a/frontend/resources/templates/render.mustache +++ b/frontend/resources/templates/render.mustache @@ -14,7 +14,7 @@ {{# manifest}} - + {{/manifest}} diff --git a/frontend/scripts/_helpers.js b/frontend/scripts/_helpers.js index d837b93e9d..c6396f99bc 100644 --- a/frontend/scripts/_helpers.js +++ b/frontend/scripts/_helpers.js @@ -207,9 +207,9 @@ async function generateManifest() { rasterizer_main: "./js/rasterizer.js", config: "./js/config.js?version=" + VERSION_TAG, + config_render: "./js/config-render.js?version=" + VERSION_TAG, polyfills: "./js/polyfills.js?version=" + VERSION_TAG, libs: "./js/libs.js?version=" + VERSION_TAG, - worker_main: "./js/worker/main.js?version=" + VERSION_TAG, default_translations: "./js/translation.en.js?version=" + VERSION_TAG, importmap: JSON.stringify({ diff --git a/frontend/scripts/test-quiet.js b/frontend/scripts/test-quiet.js new file mode 100644 index 0000000000..7b5ad068ae --- /dev/null +++ b/frontend/scripts/test-quiet.js @@ -0,0 +1,30 @@ +import { spawnSync } from "node:child_process"; + +const BUILD_STEPS = [ + { label: "Building wasm", cmd: "pnpm", args: ["run", "build:wasm"] }, + { label: "Building test bundle", cmd: "pnpm", args: ["run", "build:test"] }, +]; + +const progress = (msg) => process.stderr.write(`${msg}\n`); + +for (const step of BUILD_STEPS) { + progress(`${step.label}...`); + const result = spawnSync(step.cmd, step.args, { + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 64 * 1024 * 1024, + }); + if (result.status !== 0) { + progress(`${step.label} failed`); + if (result.stdout?.length) process.stdout.write(result.stdout); + if (result.stderr?.length) process.stderr.write(result.stderr); + process.exit(result.status ?? 1); + } +} + +progress("Running tests..."); +const result = spawnSync( + "node", + ["target/tests/test.js", ...process.argv.slice(2)], + { stdio: "inherit" }, +); +process.exit(result.status ?? 1); diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index ddcd2c08b5..d82ab25be1 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -156,14 +156,15 @@ (ts/asap #(.reload ^js location true)) true)))) +(def saas? (obj/get global "penpotIsSaas" false)) (def terms-of-service-uri (obj/get global "penpotTermsOfServiceURI")) (def oidc-name (obj/get global "penpotOIDCName")) (def privacy-policy-uri (obj/get global "penpotPrivacyPolicyURI")) (def flex-help-uri (obj/get global "penpotGridHelpURI" "https://help.penpot.app/user-guide/flexible-layouts/")) (def grid-help-uri (obj/get global "penpotGridHelpURI" "https://help.penpot.app/user-guide/flexible-layouts/")) -(def plugins-list-uri (obj/get global "penpotPluginsListUri" "https://penpot.app/penpothub/plugins")) +(def plugins-list-uri (obj/get global "penpotPluginsListURI" "https://penpot.app/penpothub/plugins")) (def plugins-whitelist (into #{} (obj/get global "penpotPluginsWhitelist" []))) -(def templates-uri (obj/get global "penpotTemplatesUri" "https://penpot.github.io/penpot-files/")) +(def templates-uri (obj/get global "penpotTemplatesURI" "https://penpot.github.io/penpot-files/")) (def upload-chunk-size (obj/get global "penpotUploadChunkSize" (* 1024 1024 25))) ;; 25 MiB ;; We set the current parsed flags under common for make @@ -190,7 +191,10 @@ public-uri)) (def worker-uri - (obj/get global "penpotWorkerURI" "/js/worker/main.js")) + (-> public-uri + (u/join "js/worker/main.js") + (get :path) + (str "?version=" version-tag))) (defn external-feature-flag [flag value] diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs index 20830e78c7..71a89331f5 100644 --- a/frontend/src/app/main.cljs +++ b/frontend/src/app/main.cljs @@ -76,11 +76,8 @@ ptk/WatchEvent (watch [_ _ stream] (rx/merge - (if (contains? cf/flags :audit-log) - (rx/of (ev/initialize)) - (rx/empty)) - - (rx/of (dp/refresh-profile)) + (rx/of (ev/initialize) + (dp/refresh-profile)) ;; Watch for profile deletion events (->> stream @@ -110,10 +107,10 @@ (defn ^:export init [options] - ;; WORKAROUND: we set this really not usefull property for signal a - ;; sideffect and prevent GCC remove it. We need it because we need + ;; WORKAROUND: we set this really not useful property for signal a + ;; side effect and prevent GCC remove it. We need it because we need ;; to populate the Date prototype with transit related properties - ;; before SES hardning is applied on loading MCP plugin + ;; before SES hardening is applied on loading MCP plugin (unchecked-set js/globalThis "penpotStartDate" (-> (ct/now) (t/encode-str) diff --git a/frontend/src/app/main/data/auth.cljs b/frontend/src/app/main/data/auth.cljs index e3aa763ad6..41ff00a6b2 100644 --- a/frontend/src/app/main/data/auth.cljs +++ b/frontend/src/app/main/data/auth.cljs @@ -61,26 +61,30 @@ (rx/of (dcm/go-to-dashboard-recent {:team-id team-id})))))))] (ptk/reify ::logged-in - ev/Event - (-data [_] - {::ev/name "signin" - ::ev/type "identify" - :email (:email profile) - :auth-backend (:auth-backend profile) - :fullname (:fullname profile) - :is-muted (:is-muted profile) - :default-team-id (:default-team-id profile) - :default-project-id (:default-project-id profile)}) - ptk/WatchEvent (watch [_ _ stream] (cf/initialize-external-context-info) + (->> (rx/merge (rx/of (dp/set-profile profile) (ws/initialize) (dtm/fetch-teams)) + ;; We schedule this event to be executed a bit later, + ;; when the profile is already set + (->> (rx/of (ev/event {::ev/name "signin" + ::ev/type "identify" + :id (:id profile) + :email (:email profile) + :auth-backend (:auth-backend profile) + :fullname (:fullname profile) + :is-muted (:is-muted profile) + :default-team-id (:default-team-id profile) + :default-project-id (:default-project-id profile)})) + (rx/observe-on :async)) + + (->> stream (rx/filter (ptk/type? ::dtm/teams-fetched)) (rx/take 1) diff --git a/frontend/src/app/main/data/changes.cljs b/frontend/src/app/main/data/changes.cljs index f529705d9f..e91fcf0f4e 100644 --- a/frontend/src/app/main/data/changes.cljs +++ b/frontend/src/app/main/data/changes.cljs @@ -124,7 +124,7 @@ "Create a commit event instance" [{:keys [commit-id redo-changes undo-changes origin save-undo? features file-id file-revn file-vern undo-group tags stack-undo? source ignore-wasm? - selected-before]}] + selected-before translation?]}] (assert (cpc/check-changes redo-changes) "expect valid vector of changes for redo-changes") @@ -151,7 +151,8 @@ :tags tags :stack-undo? stack-undo? :ignore-wasm? ignore-wasm? - :selected-before selected-before}] + :selected-before selected-before + :translation? translation?}] (ptk/reify ::commit cljs.core/IDeref @@ -189,7 +190,8 @@ - undo-group: if some consecutive changes (or even transactions) share the same undo-group, they will be undone or redone in a single step " - [{:keys [redo-changes undo-changes save-undo? undo-group tags stack-undo? file-id] + [{:keys [redo-changes undo-changes save-undo? undo-group tags stack-undo? file-id + translation?] :or {save-undo? true stack-undo? false undo-group (uuid/next) @@ -222,4 +224,5 @@ (assoc :undo-changes uchg) (assoc :redo-changes rchg) (assoc :selected-before selected) + (assoc :translation? translation?) (commit))))))))) diff --git a/frontend/src/app/main/data/comments.cljs b/frontend/src/app/main/data/comments.cljs index 57fc450ba0..3aae536949 100644 --- a/frontend/src/app/main/data/comments.cljs +++ b/frontend/src/app/main/data/comments.cljs @@ -96,12 +96,13 @@ (update-in [:comments id] assoc (:id comment) comment)))) ptk/WatchEvent - (watch [_ _ _] - (rx/of (ptk/data-event ::ev/event - {::ev/name "create-comment-thread" - ::ev/origin "workspace" - :id id - :content-size (count (:content comment))})))))) + (watch [it _ _] + (rx/of (ev/event + (merge {::ev/name "create-comment-thread" + ::ev/origin "workspace" + :id id + :content-size (count (:content comment))} + (meta it)))))))) (def ^:private schema:create-thread-on-workspace @@ -119,7 +120,7 @@ (ptk/reify ::create-thread-on-workspace ptk/WatchEvent - (watch [_ state _] + (watch [it state _] (let [page-id (:current-page-id state) objects (dsh/lookup-page-objects state page-id) frame-id (ctst/get-frame-id-by-position objects (:position params)) @@ -129,7 +130,10 @@ (->> (rp/cmd! :create-comment-thread params) (rx/mapcat #(rp/cmd! :get-comment-thread {:file-id (:file-id %) :id (:id %)})) (rx/tap on-thread-created) - (rx/map #(created-thread-on-workspace % open?)) + (rx/map + (fn [data] + (-> (created-thread-on-workspace data open?) + (with-meta (meta it))))) (rx/catch (fn [{:keys [type code] :as cause}] (if (and (= type :restriction) (= code :max-quote-reached)) @@ -152,11 +156,11 @@ ptk/WatchEvent (watch [_ _ _] - (rx/of (ptk/data-event ::ev/event - {::ev/name "create-comment-thread" - ::ev/origin "viewer" - :id id - :content-size (count (:content comment))}))))) + (rx/of (ev/event + {::ev/name "create-comment-thread" + ::ev/origin "viewer" + :id id + :content-size (count (:content comment))}))))) (def ^:private schema:create-thread-on-viewer @@ -216,11 +220,13 @@ (d/update-in-when state [:comment-threads id] assoc :is-resolved is-resolved)) ptk/WatchEvent - (watch [_ state _] + (watch [it state _] (let [share-id (-> state :viewer-local :share-id)] (rx/concat - (when is-resolved (rx/of - (ptk/event ::ev/event {::ev/name "resolve-comment-thread" :thread-id id}))) + (when is-resolved + (rx/of (ev/event + (-> {::ev/name "resolve-comment-thread" :thread-id id} + (merge (meta it)))))) (->> (rp/cmd! :update-comment-thread {:id id :is-resolved is-resolved :share-id share-id}) (rx/catch (fn [{:keys [type code] :as cause}] (if (and (= type :restriction) @@ -309,16 +315,18 @@ (update :comment-threads dissoc id))) ptk/WatchEvent - (watch [_ _ _] + (watch [it _ _] (rx/concat (->> (rp/cmd! :delete-comment-thread {:id id}) (rx/catch #(rx/throw {:type :comment-error})) (rx/tap on-delete) (rx/ignore)) - (rx/of (ptk/data-event ::ev/event - {::ev/name "delete-comment-thread" - ::ev/origin "workspace" - :id id}))))))) + (rx/of (ev/event + (merge + {::ev/name "delete-comment-thread" + ::ev/origin "workspace" + :id id} + (meta it))))))))) (defn delete-comment-thread-on-viewer [{:keys [id] :as thread}] @@ -341,10 +349,10 @@ (->> (rp/cmd! :delete-comment-thread {:id id :share-id share-id}) (rx/catch #(rx/throw {:type :comment-error})) (rx/ignore)) - (rx/of (ptk/data-event ::ev/event - {::ev/name "delete-comment-thread" - ::ev/origin "viewer" - :id id}))))))) + (rx/of (ev/event + {::ev/name "delete-comment-thread" + ::ev/origin "viewer" + :id id}))))))) (defn delete-comment [{:keys [id thread-id] :as comment}] (dm/assert! diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index eddcb5a503..c31312d90e 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -13,6 +13,7 @@ [app.common.logging :as log] [app.common.schema :as sm] [app.common.time :as ct] + [app.common.types.organization :as co] [app.common.types.project :refer [valid-project?]] [app.common.uuid :as uuid] [app.config :as cf] @@ -480,7 +481,7 @@ (->> (rp/cmd! :get-file-summary {:id id}) (rx/map (fn [summary] (when (-> summary :variants :count pos?) - (ptk/event ::ev/event {::ev/name "set-file-variants-shared" ::ev/origin "dashboard"}))))))))))) + (ev/event {::ev/name "set-file-variants-shared" ::ev/origin "dashboard"}))))))))))) (defn set-file-thumbnail [file-id thumbnail-id] @@ -686,29 +687,29 @@ (modal/hide))))) (defn handle-change-team-org - [{:keys [team-id team-name organization-id organization-name notification]}] + [{:keys [team notification]}] (ptk/reify ::handle-change-team-org ptk/WatchEvent (watch [_ state _] - (let [current-team-id (:current-team-id state)] + (let [current-team-id (:current-team-id state) + organization (:organization team)] (when (and (contains? cf/flags :nitrate) notification - (= team-id current-team-id)) - (rx/of (ntf/show {:content (tr notification organization-name) + (= (:id team) current-team-id)) + (rx/of (ntf/show {:content (tr notification (:name organization)) :type :toast :level :info :timeout nil}))))) ptk/UpdateEvent (update [_ state] (if (contains? cf/flags :nitrate) - (d/update-in-when state [:teams team-id] - (fn [team] - (cond-> team - (some? organization-id) (assoc :organization-id organization-id) - (nil? organization-id) (dissoc :organization-id) - (some? organization-name) (assoc :organization-name organization-name) - (nil? organization-name) (dissoc :organization-name) - team-name (assoc :name team-name)))) + (let [team-id (:id team) + team-name (:name team) + organization (:organization team)] + (d/update-in-when state [:teams team-id] + (fn [team] + (cond-> (co/apply-organization team organization) + team-name (assoc :name team-name))))) state)))) (defn- handle-user-org-change @@ -725,9 +726,33 @@ :timeout nil}) (dtm/fetch-teams) ;; When the user is currently on a team of the org - (when (= organization-id (:organization-id team)) + (when (= organization-id (dm/get-in team [:organization :id])) (dcm/go-to-dashboard-recent {:team-id :default})))))))) + +(defn- handle-organization-deleted + [{:keys [organization-name teams deleted-teams]}] + (ptk/reify ::handle-organization-deleted + ptk/WatchEvent + (watch [_ state _] + (when (contains? cf/flags :nitrate) + (let [team-id (:current-team-id state) + teams-set (set teams) + notify? (contains? teams-set team-id) + fetch? (some (:teams state) teams) + go-to-default? (some #{team-id} deleted-teams)] + (rx/concat + (when go-to-default? ;; If the user is currently on one of the deleted teams + (rx/of (dcm/go-to-dashboard-recent {:team-id :default}))) + + (when notify? ;; If the user is currently on one of the org teams + (rx/of (ntf/show {:content (tr "dashboard.org-deleted" organization-name) + :type :toast + :level :info + :timeout nil}))) + (when fetch? ;; If the user belonged to the org + (rx/of (dtm/fetch-teams))))))))) + (defn- process-message [{:keys [type] :as msg}] (case type @@ -736,6 +761,7 @@ :team-membership-change (dcm/team-membership-change msg) :team-org-change (handle-change-team-org msg) :user-org-change (handle-user-org-change msg) + :organization-deleted (handle-organization-deleted msg) nil)) diff --git a/frontend/src/app/main/data/event.cljs b/frontend/src/app/main/data/event.cljs index dfc925cce8..aa9df88085 100644 --- a/frontend/src/app/main/data/event.cljs +++ b/frontend/src/app/main/data/event.cljs @@ -23,8 +23,10 @@ [app.util.object :as obj] [app.util.perf :as perf] [app.util.storage :as storage] + [app.util.timers :as timers] [beicon.v2.core :as rx] [beicon.v2.operators :as rxo] + [cuerdas.core :as str] [lambdaisland.uri :as u] [potok.v2.core :as ptk])) @@ -215,7 +217,7 @@ (rx/create (fn [subs] (let [start (perf/now)] - (js/requestAnimationFrame + (timers/raf #(.postTask js/scheduler (fn [] (let [time (- (perf/now) start)] @@ -376,83 +378,105 @@ (l/debug :hint "event instrumentation initialized") - (->> (rx/merge - (->> (rx/from-atom buffer) - (rx/filter #(pos? (count %))) - (rx/debounce 2000)) - (->> stream - (rx/filter (ptk/type? :app.main.data.profile/logout)) - (rx/observe-on :async))) - (rx/map (fn [_] - (into [] (take max-chunk-size) @buffer))) - (rx/with-latest-from profile) - (rx/mapcat (fn [[chunk profile-id]] - (let [events (filterv #(= profile-id (:profile-id %)) chunk)] - (->> (persist-events events) - (rx/tap (fn [_] - (l/debug :hint "events chunk persisted" :total (count chunk)))) - (rx/map (constantly chunk)))))) - (rx/take-until stopper) - (rx/subs! (fn [chunk] - (swap! buffer remove-from-buffer (count chunk))) - (fn [cause] - (l/error :hint "unexpected error on audit persistence" :cause cause)) - (fn [] - (l/debug :hint "audit persistence terminated")))) - - (->> (rx/merge - (->> stream - (rx/with-latest-from profile) - (rx/map make-event)) - - (->> (user-input-observer) - (rx/with-latest-from profile) - (rx/map make-performance-event) - (rx/debounce debounce-browser-event-time)) - - (->> (longtask-observer) - (rx/with-latest-from profile) - (rx/map make-performance-event) - (rx/debounce debounce-longtask-time)) - - (if (and (exists? js/globalThis) - (exists? (.-requestAnimationFrame js/globalThis)) - (exists? (.-scheduler js/globalThis)) - (exists? (.-postTask (.-scheduler js/globalThis)))) - (->> stream + ;; Fetch backend flags and only start event collection if + ;; :audit-log or :telemetry is enabled. On RPC failure, proceed + ;; with event collection anyway (backend will reject if truly disabled). + (->> (rp/cmd! :get-enabled-flags) + (rx/catch (fn [cause] + (l/debug :hint "unable to fetch backend flags, proceeding with event collection" :cause cause) + (rx/of #{:telemetry}))) + (rx/mapcat (fn [flags] + (if (or (contains? flags :audit-log) + (contains? flags :telemetry)) + (do + (l/debug :hint "event collection enabled" :flags (str/join " " (map name flags))) + (rx/of true)) + (do + (l/debug :hint "event collection disabled (no audit-log or telemetry flag)") + (rx/empty))))) + (rx/take 1) + (rx/subs! + (fn [_] + ;; Start the event collection pipeline + (->> (rx/merge + (->> (rx/from-atom buffer) + (rx/filter #(pos? (count %))) + (rx/debounce 2000)) + (->> stream + (rx/filter (ptk/type? :app.main.data.profile/logout)) + (rx/observe-on :async))) + (rx/map (fn [_] + (into [] (take max-chunk-size) @buffer))) (rx/with-latest-from profile) - (rx/merge-map process-performance-event) - (rx/debounce debounce-performance-event-time)) - (rx/empty))) + (rx/mapcat (fn [[chunk profile-id]] + (let [events (filterv #(= profile-id (:profile-id %)) chunk)] + (->> (persist-events events) + (rx/tap (fn [_] + (l/debug :hint "events chunk persisted" :total (count chunk)))) + (rx/map (constantly chunk)))))) + (rx/take-until stopper) + (rx/subs! (fn [chunk] + (swap! buffer remove-from-buffer (count chunk))) + (fn [cause] + (l/error :hint "unexpected error on audit persistence" :cause cause)) + (fn [] + (l/debug :hint "audit persistence terminated")))) - (rx/filter :profile-id) - (rx/map (fn [event] - (let [session* (or @session (ct/now)) - context (-> @context - (merge (:context event)) - (assoc :session session*) - (assoc :session-id cf/session-id) - (assoc :external-session-id (cf/external-session-id)) - (add-external-context-info) - (d/without-nils))] - (reset! session session*) - (-> event - (assoc :timestamp (ct/now)) - (assoc :context context))))) + (->> (rx/merge + (->> stream + (rx/with-latest-from profile) + (rx/map make-event)) - (rx/tap (fn [event] - (l/debug :hint "event enqueued") - (swap! buffer append-to-buffer event))) + (->> (user-input-observer) + (rx/with-latest-from profile) + (rx/map make-performance-event) + (rx/debounce debounce-browser-event-time)) - (rx/switch-map #(rx/timer session-timeout)) - (rx/take-until stopper) - (rx/subs! (fn [_] - (l/debug :hint "session reinitialized") - (reset! session nil)) - (fn [cause] - (l/error :hint "error on event batching stream" :cause cause)) - (fn [] - (l/debug :hitn "events batching stream terminated")))))))) + (->> (longtask-observer) + (rx/with-latest-from profile) + (rx/map make-performance-event) + (rx/debounce debounce-longtask-time)) + + (if (and (exists? js/globalThis) + (exists? (.-requestAnimationFrame js/globalThis)) + (exists? (.-scheduler js/globalThis)) + (exists? (.-postTask (.-scheduler js/globalThis)))) + (->> stream + (rx/with-latest-from profile) + (rx/merge-map process-performance-event) + (rx/debounce debounce-performance-event-time)) + (rx/empty))) + + (rx/filter :profile-id) + (rx/map (fn [event] + (let [session* (or @session (ct/now)) + context (-> @context + (merge (:context event)) + (assoc :session session*) + (assoc :session-id cf/session-id) + (assoc :external-session-id (cf/external-session-id)) + (add-external-context-info) + (d/without-nils))] + (reset! session session*) + (-> event + (assoc :timestamp (ct/now)) + (assoc :context context))))) + + (rx/tap (fn [event] + (l/debug :hint "event enqueued") + (swap! buffer append-to-buffer event))) + + (rx/switch-map #(rx/timer session-timeout)) + (rx/take-until stopper) + (rx/subs! (fn [_] + (l/debug :hint "session reinitialized") + (reset! session nil)) + (fn [cause] + (l/error :hint "error on event batching stream" :cause cause)) + (fn [] + (l/debug :hint "events batching stream terminated"))))) + (fn [cause] + (l/warn :hint "unexpected error during event collection initialization" :cause cause)))))))) (defn event [props] diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs index 143dec67d4..9d1d8460de 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -298,8 +298,7 @@ counts)) {:png 0, :jpeg 0, :webp 0, :pdf 0, :svg 0} exports)] - (ptk/event - ::ev/event (merge types - {::ev/name "export-shapes" - ::ev/origin origin - :num-shapes (count exports)})))) + (ev/event (merge types + {::ev/name "export-shapes" + ::ev/origin origin + :num-shapes (count exports)})))) diff --git a/frontend/src/app/main/data/fonts.cljs b/frontend/src/app/main/data/fonts.cljs index d72cde8436..ed891eb463 100644 --- a/frontend/src/app/main/data/fonts.cljs +++ b/frontend/src/app/main/data/fonts.cljs @@ -14,6 +14,7 @@ [app.common.uuid :as uuid] [app.main.data.event :as ev] [app.main.data.notifications :as ntf] + [app.main.data.uploads :as uploads] [app.main.fonts :as fonts] [app.main.repo :as rp] [app.main.store :as st] @@ -24,24 +25,14 @@ [cuerdas.core :as str] [potok.v2.core :as ptk])) -(def ^:const default-chunk-size - (* 1024 1024 4)) ;; 4MiB - -(defn- chunk-array - [data chunk-size] - (let [total-size (alength data)] - (loop [offset 0 - chunks []] - (if (< offset total-size) - (let [end (min (+ offset chunk-size) total-size) - chunk (.subarray ^js data offset end)] - (recur end (conj chunks chunk))) - chunks)))) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; General purpose events & IMPL ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(def ^:private font-upload-chunk-size + "Size in bytes of each chunk when uploading font files (10 MiB)." + (* 1024 1024 10)) + (defn fonts-fetched [fonts] (letfn [;; Prepare font to the internal font database format. @@ -60,9 +51,9 @@ (prepare-font-variant [item] {:id (str (:font-style item) "-" (:font-weight item)) - :name (str (cm/font-weight->name (:font-weight item)) - (when (not= "normal" (:font-style item)) - (str " " (str/capital (:font-style item))))) + :name (cm/font-display-variant (:variant-name item) + (:font-weight item) + (:font-style item)) :style (:font-style item) :weight (str (:font-weight item)) ::fonts/woff1-file-id (:woff1-file-id item) @@ -94,9 +85,44 @@ (->> (rp/cmd! :get-font-variants {:team-id team-id}) (rx/map fonts-fetched))))) +(defn upload-font-variant + "Uploads a single font variant item using the chunked upload API. + + For each mime-type in `data`, creates a Blob and uploads it via the + session-based chunked upload. Once all sessions are created, calls + `create-font-variant` with the resulting `:uploads` map so the server + can assemble the chunks and materialise the final font-variant record. + + Returns an observable that emits the created font-variant." + [{:keys [data team-id font-id font-family font-weight font-style] :as _item}] + ;; Upload each mtype as a separate chunked session in parallel, collect + ;; all [mtype session-id] pairs, then call create-font-variant with :uploads. + (->> (rx/from (seq data)) + (rx/mapcat (fn [[mtype buffer]] + (let [blob (js/Blob. #js [buffer] #js {:type mtype})] + (->> (uploads/upload-blob-chunked blob :chunk-size font-upload-chunk-size) + (rx/map (fn [{:keys [session-id]}] + [mtype session-id])))))) + (rx/reduce (fn [acc [mtype session-id]] + (assoc acc mtype session-id)) + {}) + (rx/mapcat (fn [uploads] + (rp/cmd! :create-font-variant + {:team-id team-id + :font-id font-id + :font-family font-family + :font-weight font-weight + :font-style font-style + :uploads uploads}))))) + (defn process-upload "Given a seq of blobs and the team id, creates a ready-to-use fonts - map with temporal ID's associated to each font entry." + map with temporal ID's associated to each font entry. + + Each font entry's `:data` is a map of `{mtype -> ArrayBuffer}`. The + raw `ArrayBuffer` is kept as-is so that `upload-font-variant` can + wrap it in a `Blob` and hand it directly to `upload-blob-chunked` + without any intermediate client-side chunking." [blobs team-id] (letfn [(prepare [{:keys [font type name data] :as params}] (if font @@ -134,12 +160,13 @@ (not= hhea-ascender os2-ascent) (not= hhea-descender os2-descent)))) data (js/Uint8Array. data)] - {:content {:data (chunk-array data default-chunk-size) + {:content {:data data :name name :type type} :font-family (or family "") :font-weight (cm/parse-font-weight variant) :font-style (cm/parse-font-style variant) + :variant-name variant :height-warning? height-warning?}) ;; Font could not be parsed (woff2), extract metadata from filename (let [base-name (str/replace name #"\.[^.]+$" "") @@ -151,7 +178,7 @@ (str/trim)) family-name (if (str/blank? raw-family-name) base-name raw-family-name) data (js/Uint8Array. data)] - {:content {:data (chunk-array data default-chunk-size) + {:content {:data data :name name :type type} :font-family family-name @@ -284,12 +311,12 @@ ptk/WatchEvent (watch [_ state _] (let [team-id (:current-team-id state)] - (rx/of (ptk/data-event ::ev/event {::ev/name "add-font" - :team-id team-id - :font-id (:id font) - :font-family (:font-family font) - :font-style (:font-style font) - :font-weight (:font-weight font)})))))) + (rx/of (ev/event {::ev/name "add-font" + :team-id team-id + :font-id (:id font) + :font-family (:font-family font) + :font-style (:font-style font) + :font-weight (:font-weight font)})))))) (defn update-font [{:keys [id name] :as params}] @@ -333,9 +360,9 @@ (rx/concat (->> (rp/cmd! :delete-font {:id font-id :team-id team-id}) (rx/ignore)) - (rx/of (ptk/data-event ::ev/event {::ev/name "delete-font" - :team-id team-id - :font-id font-id}))))))) + (rx/of (ev/event {::ev/name "delete-font" + :team-id team-id + :font-id font-id}))))))) (defn delete-font-variant [id] @@ -354,9 +381,9 @@ (rx/concat (->> (rp/cmd! :delete-font-variant {:id id :team-id team-id}) (rx/ignore)) - (rx/of (ptk/data-event ::ev/event {::ev/name "delete-font-variant" - :id id - :team-id team-id}))))))) + (rx/of (ev/event {::ev/name "delete-font-variant" + :id id + :team-id team-id}))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/frontend/src/app/main/data/nitrate.cljs b/frontend/src/app/main/data/nitrate.cljs index dd2619d37e..d62d0ea330 100644 --- a/frontend/src/app/main/data/nitrate.cljs +++ b/frontend/src/app/main/data/nitrate.cljs @@ -1,6 +1,7 @@ (ns app.main.data.nitrate (:require [app.common.data.macros :as dm] + [app.common.types.nitrate-permissions :as nitrate-perms] [app.common.uri :as u] [app.config :as cf] [app.main.data.common :as dcm] @@ -13,6 +14,7 @@ [app.util.i18n :refer [tr]] [app.util.storage :as storage] [beicon.v2.core :as rx] + [cuerdas.core :as str] [potok.v2.core :as ptk])) (def ^:private nitrate-entry-active-key ::nitrate-entry-active) @@ -50,38 +52,84 @@ (rx/map (fn [connectivity] (modal/show popup-type (merge (or connectivity {}) extra-props))))))))) -(defn go-to-nitrate-cc +(defn go-to-nitrate-ac ([] - (st/emit! (rt/nav-raw :href "/control-center/"))) + (st/emit! (rt/nav-raw :href "/admin-console/"))) ([{:keys [organization-id organization-slug]}] (if (and organization-id organization-slug) - (let [href (dm/str "/control-center/org/" + (let [href (dm/str "/admin-console/org/" (u/percent-encode organization-slug) "/" (u/percent-encode (str organization-id)) "/people/")] (st/emit! (rt/nav-raw :href href))) - (st/emit! (rt/nav-raw :href "/control-center/"))))) + (st/emit! (rt/nav-raw :href "/admin-console/"))))) -(defn go-to-nitrate-cc-create-org +(defn go-to-nitrate-ac-create-org [] - (st/emit! (rt/nav-raw :href "/control-center/?action=create-org"))) + (st/emit! (rt/nav-raw :href "/admin-console/?action=create-org"))) + +(defn can-send-invitations? + [{:keys [organization profile-id team-permissions]}] + (nitrate-perms/can-send-invitations? + {:nitrate-enabled? (contains? cf/flags :nitrate) + :organization organization + :profile-id profile-id + :team-permissions team-permissions})) (def go-to-subscription-url (u/join cf/public-uri "#/settings/subscriptions")) (defn go-to-nitrate-billing [] - (let [href (dm/str "/control-center/licenses/billing?callback=" (js/encodeURIComponent go-to-subscription-url))] + (let [href (dm/str "/admin-console/licenses/billing?callback=" (js/encodeURIComponent go-to-subscription-url))] (st/emit! (rt/nav-raw :href href)))) +(def nitrate-checkout-error-token "nitrate-checkout-error") +(def nitrate-checkout-finish-error-token "nitrate-checkout-finish-error") +(def nitrate-checkout-cancelled-token "nitrate-checkout-cancelled") + +(defn- append-query-param + [url key value] + (let [assoc-q (fn [u] + (update u :query + (fn [q] + (-> (u/query-string->map (or q "")) + (assoc (name key) value) + u/map->query-string)))) + parsed (u/uri url) + fragment (:fragment parsed)] + (if (str/blank? fragment) + (str (assoc-q parsed)) + (-> parsed + (assoc :fragment (str (assoc-q (u/parse fragment)))) + str)))) + +(defn build-nitrate-callback-urls + "Build the success/error/cancel callback URLs from a base URL by appending + a `subscription` query param identifying the outcome." + [base-url] + (let [build (fn [token] + (append-query-param base-url :subscription token))] + {:success-callback (build "subscribed-to-penpot-nitrate") + :error-callback (build nitrate-checkout-error-token) + :finish-error-callback (build nitrate-checkout-finish-error-token) + :cancel-callback (build nitrate-checkout-cancelled-token)})) + (defn go-to-buy-nitrate-license - ([subscription] - (go-to-buy-nitrate-license subscription nil)) - ([subscription callback] - (let [params (cond-> {:subscription subscription} - callback (assoc :callback callback)) - href (dm/str "/control-center/licenses/start?" (u/map->query-string params))] - (st/emit! (rt/nav-raw :href href))))) + [subscription base-url] + (let [{:keys [success-callback error-callback finish-error-callback cancel-callback]} + (build-nitrate-callback-urls base-url) + params {:subscription subscription + :callback success-callback + :error_callback error-callback + :finish_error_callback finish-error-callback + :cancel_callback cancel-callback} + href (dm/str "/admin-console/licenses/start?" (u/map->query-string params))] + (st/emit! (rt/nav-raw :href href)))) + +(defn fetch-connectivity + [] + (rp/cmd! ::get-nitrate-connectivity {})) (defn is-valid-license? [profile] @@ -126,11 +174,104 @@ (defn add-team-to-org - [{:keys [team-id organization-id organization-name] :as params}] + [{:keys [team-id organization-id] :as params}] (ptk/reify ::add-team-to-org ptk/WatchEvent (watch [_ _ _] - (->> (rp/cmd! ::add-team-to-org {:team-id team-id :organization-id organization-id :organization-name organization-name}) + (->> (rp/cmd! ::add-team-to-organization {:team-id team-id :organization-id organization-id}) (rx/mapcat (fn [_] (rx/of (modal/hide)))))))) + +(defn show-add-team-to-org-modal + "Fetches fresh team/org data, then shows the add-to-org modal + restricted to orgs where the user has permission, or the no-permission + modal if none qualify." + [{:keys [team-id]}] + (ptk/reify ::show-add-team-to-org-modal + ptk/WatchEvent + (watch [_ state _] + (let [profile-id (dm/get-in state [:profile :id])] + (->> (rp/cmd! :get-teams) + (rx/mapcat + (fn [teams] + (let [all-orgs (map dt/team->organization + (filter #(and (:is-default %) (:organization %)) teams)) + orgs (filter (fn [org] + (let [perm (dm/get-in org [:permissions :create-teams]) + is-own? (= profile-id (:owner-id org))] + (or (= perm "any") is-own?))) all-orgs) + team (first (filter #(= (:id %) team-id) teams)) + on-confirm (fn [organization-id] + (st/emit! (add-team-to-org {:team-id team-id + :organization-id organization-id})))] + (rx/of (dt/teams-fetched teams) + (if (empty? orgs) + (modal/show :no-permission-modal {:type :no-orgs-create}) + (let [has-filtered? (< (count orgs) (count all-orgs)) + extra-props (when has-filtered? + {:info-message-key "dashboard.select-org-modal.permission-info"})] + (modal/show :select-organization-modal + (merge {:organizations orgs + :current-organization-id (dm/get-in team [:organization :id]) + :on-confirm on-confirm + :title-key "dashboard.select-org-modal.title" + :choose-key "dashboard.select-org-modal.choose" + :placeholder-key "dashboard.select-org-modal.select" + :accept-key "dashboard.select-org-modal.accept" + :cancel-key "labels.cancel"} + extra-props))))))))))))) + +(defn show-change-team-org-modal + "Fetches fresh team/org data, then shows the change-org modal + restricted to orgs where the user has permission, or the no-permission + modal if none qualify." + [{:keys [team-id]}] + (ptk/reify ::show-change-team-org-modal + ptk/WatchEvent + (watch [_ state _] + (let [profile-id (dm/get-in state [:profile :id])] + (->> (rp/cmd! :get-teams) + (rx/mapcat + (fn [teams] + (let [all-orgs (map dt/team->organization + (filter #(and (:is-default %) (:organization %)) teams)) + team (first (filter #(= (:id %) team-id) teams)) + source-org (:organization team) + current-org-id (:id source-org) + move-perm (dm/get-in source-org [:permissions :move-teams]) + source-owner-id (:owner-id source-org) + can-create? (fn [org] + (let [perm (dm/get-in org [:permissions :create-teams]) + is-own? (= profile-id (:owner-id org))] + (or (= perm "any") is-own?))) + orgs-by-move (case move-perm + "never" + [] + + "myOrganizations" + (filter #(= source-owner-id (:owner-id %)) all-orgs) + + ;; Default to always-allowed behavior. + all-orgs) + orgs (filter can-create? orgs-by-move) + selectable-orgs (remove #(= current-org-id (:id %)) orgs) + on-confirm (fn [organization-id] + (st/emit! (add-team-to-org {:team-id team-id + :organization-id organization-id})))] + (rx/of (dt/teams-fetched teams) + (if (empty? selectable-orgs) + (modal/show :no-permission-modal {:type :no-orgs-change}) + (let [has-filtered? (< (count orgs) (count all-orgs)) + extra-props (when has-filtered? + {:info-message-key "dashboard.select-org-modal.permission-info"})] + (modal/show :select-organization-modal + (merge {:organizations selectable-orgs + :current-organization-id current-org-id + :on-confirm on-confirm + :title-key "dashboard.change-org-modal.title" + :choose-key "dashboard.change-org-modal.choose" + :placeholder-key "dashboard.change-org-modal.select" + :accept-key "dashboard.change-org-modal.accept" + :cancel-key "labels.cancel"} + extra-props))))))))))))) diff --git a/frontend/src/app/main/data/persistence.cljs b/frontend/src/app/main/data/persistence.cljs index adcc70cbb3..c90c423f96 100644 --- a/frontend/src/app/main/data/persistence.cljs +++ b/frontend/src/app/main/data/persistence.cljs @@ -121,8 +121,10 @@ :features features} permissions (:permissions state)] - ;; Prevent commit changes by a team member without edition permission - (when (:can-edit permissions) + ;; Prevent saving changes when in version preview (read-only) mode + ;; or when the user does not have edition permission. + (when (and (:can-edit permissions) + (not (get-in state [:workspace-global :read-only?]))) (->> (rp/cmd! :update-file params) (rx/mapcat (fn [{:keys [revn lagged] :as response}] (log/debug :hint "changes persisted" :commit-id (dm/str commit-id) :lagged (count lagged)) diff --git a/frontend/src/app/main/data/profile.cljs b/frontend/src/app/main/data/profile.cljs index 2260a734f8..6a05af5f26 100644 --- a/frontend/src/app/main/data/profile.cljs +++ b/frontend/src/app/main/data/profile.cljs @@ -15,6 +15,7 @@ [app.main.data.media :as di] [app.main.data.notifications :as ntf] [app.main.data.team :as-alias dtm] + [app.main.features :as features] [app.main.repo :as rp] [app.main.router :as rt] [app.plugins.register :as plugins.register] @@ -152,10 +153,10 @@ (when (not= (:theme profile) (:theme profile')) - (rx/of (ptk/data-event ::ev/event - {::ev/name "activate-theme" - ::ev/origin "settings" - :theme (:theme profile)}))))))))) + (rx/of (ev/event + {::ev/name "activate-theme" + ::ev/origin "settings" + :theme (:theme profile)}))))))))) ;; --- Toggle Theme @@ -186,9 +187,9 @@ (watch [it state _] (let [profile (get state :profile) origin (::ev/origin (meta it))] - (rx/of (ptk/data-event ::ev/event {:theme (:theme profile) - ::ev/name "activate-theme" - ::ev/origin origin}) + (rx/of (ev/event {:theme (:theme profile) + ::ev/name "activate-theme" + ::ev/origin origin}) (persist-profile)))))) ;; --- Request Email Change @@ -291,8 +292,13 @@ ;; FIXME ptk/WatchEvent (watch [_ _ _] - (->> (rp/cmd! :update-profile-props {:props props}) - (rx/map (constantly (refresh-profile))))))) + (let [refresh-profile (->> (rp/cmd! :update-profile-props {:props props}) + (rx/map (constantly (refresh-profile)))) + recompute (when (contains? props :renderer) + (rx/of (features/recompute-features)))] + (if recompute + (rx/concat recompute refresh-profile) + refresh-profile))))) (defn mark-onboarding-as-viewed ([] (mark-onboarding-as-viewed nil)) diff --git a/frontend/src/app/main/data/render_wasm.cljs b/frontend/src/app/main/data/render_wasm.cljs index e55d98754b..c42ba1f943 100644 --- a/frontend/src/app/main/data/render_wasm.cljs +++ b/frontend/src/app/main/data/render_wasm.cljs @@ -1,5 +1,6 @@ (ns app.main.data.render-wasm (:require + [beicon.v2.core :as rx] [potok.v2.core :as ptk])) (defn context-lost @@ -7,11 +8,37 @@ (ptk/reify ::context-lost ptk/UpdateEvent (update [_ state] - (update state :render-state #(assoc % :lost true))))) + (let [already-lost? (get-in state [:render-state :lost]) + prev-read-only? (get-in state [:workspace-global :read-only?]) + prev-options-mode (get-in state [:workspace-global :options-mode])] + (-> state + (update :render-state + (fn [render-state] + (cond-> (assoc render-state :lost true) + (not already-lost?) + (assoc :pre-context-lost-read-only? prev-read-only? + :pre-context-lost-options-mode prev-options-mode)))) + (assoc-in [:workspace-global :options-mode] :inspect) + (assoc-in [:workspace-global :read-only?] true)))) + ptk/WatchEvent + (watch [_ _ _] + (rx/of :interrupt)))) (defn context-restored [] (ptk/reify ::context-restored ptk/UpdateEvent (update [_ state] - (update state :render-state #(dissoc % :lost))))) + (let [restored-read-only? (get-in state [:render-state :pre-context-lost-read-only?] + (get-in state [:workspace-global :read-only?])) + restored-options-mode (get-in state [:render-state :pre-context-lost-options-mode] + (get-in state [:workspace-global :options-mode]))] + (-> state + (update :render-state #(dissoc % :lost + :pre-context-lost-read-only? + :pre-context-lost-options-mode)) + (assoc-in [:workspace-global :options-mode] restored-options-mode) + (assoc-in [:workspace-global :read-only?] restored-read-only?)))) + ptk/WatchEvent + (watch [_ _ _] + (rx/of :interrupt)))) diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index f3bb5207bf..c4dfcc50b2 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -11,16 +11,19 @@ [app.common.exceptions :as ex] [app.common.logging :as log] [app.common.schema :as sm] + [app.common.types.nitrate-permissions :as nitrate-perms] [app.common.types.team :as ctt] [app.common.uri :as u] [app.config :as cf] [app.main.data.event :as ev] [app.main.data.media :as di] + [app.main.data.modal :as modal] [app.main.data.profile :as dp] [app.main.features :as features] [app.main.repo :as rp] [app.main.router :as rt] [app.util.clipboard :as clipboard] + [app.util.i18n :refer [tr]] [app.util.storage :as storage] [beicon.v2.core :as rx] [clojure.string :as str] @@ -50,7 +53,8 @@ :organization-name :organization-slug :organization-owner-id - :organization-avatar-bg-url))] + :organization-avatar-bg-url + :organization-permissions))] (update state :teams assoc id team-updated))) state teams))))) @@ -63,6 +67,96 @@ (->> (rp/cmd! :get-teams) (rx/map teams-fetched))))) +(defn check-and-create-team + "Fetches fresh team data from the server to ensure up-to-date org + permissions, then shows the team-form modal or a no-permission modal." + [team-id] + (ptk/reify ::check-and-create-team + ptk/WatchEvent + (watch [_ state _] + (let [profile-id (dm/get-in state [:profile :id])] + (->> (rp/cmd! :get-teams) + (rx/mapcat + (fn [teams] + (let [team (d/seek #(= (:id %) team-id) teams) + organization (:organization team) + in-org? (and (contains? cf/flags :nitrate) organization) + can-create? (if in-org? + (nitrate-perms/allowed? :create-team + {:org-perms {:owner-id (:owner-id organization) + :permissions (:permissions organization)} + :profile-id profile-id + :team-perms (:permissions team)}) + true)] + (rx/of (teams-fetched teams) + (if can-create? + (modal/show :team-form (if in-org? + {:organization-id (:id organization) + :organization-name (:name organization)} + {})) + (modal/show :no-permission-modal {:type :create-team}))))))))))) + +(defn check-and-delete-team + "Fetches fresh team data from the server to ensure up-to-date org + permissions, then shows the confirmation modal or a no-permission modal." + [{:keys [team-id delete-fn]}] + (ptk/reify ::check-and-delete-team + ptk/WatchEvent + (watch [_ state _] + (let [profile-id (dm/get-in state [:profile :id])] + (->> (rp/cmd! :get-teams) + (rx/mapcat + (fn [teams] + (let [team (d/seek #(= (:id %) team-id) teams) + org (:organization team) + in-org? (and (contains? cf/flags :nitrate) org) + can-delete? (if in-org? + (nitrate-perms/allowed? :delete-team + {:org-perms {:owner-id (:owner-id org) + :permissions (:permissions org)} + :profile-id profile-id + :team-perms (:permissions team)}) + (boolean (dm/get-in team [:permissions :is-owner]))) + message (if in-org? + (tr "modals.delete-org-team-confirm.message" (:name org)) + (tr "modals.delete-team-confirm.message"))] + (rx/of (teams-fetched teams) + (if can-delete? + (modal/show + {:type :confirm + :title (tr "modals.delete-team-confirm.title") + :message message + :accept-label (tr "modals.delete-team-confirm.accept") + :on-accept delete-fn}) + (modal/show :no-permission-modal {:type :delete-team}))))))))))) + +(defn check-and-invite-members + "Fetches fresh team data from the server to ensure up-to-date org + permissions, then shows invite-members modal or a permission error." + [{:keys [team-id origin invite-email] + :or {origin :team}}] + (ptk/reify ::check-and-invite-members + ptk/WatchEvent + (watch [_ state _] + (let [profile-id (dm/get-in state [:profile :id])] + (->> (rp/cmd! :get-teams) + (rx/mapcat + (fn [teams] + (let [team (d/seek #(= (:id %) team-id) teams) + org (:organization team) + can-invite? (nitrate-perms/can-send-invitations? + {:nitrate-enabled? (contains? cf/flags :nitrate) + :organization org + :profile-id profile-id + :team-permissions (:permissions team)})] + (rx/of (teams-fetched teams) + (if can-invite? + (modal/show {:type :invite-members + :team team + :origin origin + :invite-email invite-email}) + (modal/show :no-permission-modal {:type :invite-members}))))))))))) + ;; --- EVENT: fetch-members (defn- members-fetched @@ -82,7 +176,15 @@ (watch [_ state _] (when-let [team-id (or team-id (:current-team-id state))] (->> (rp/cmd! :get-team-members {:team-id team-id}) - (rx/map (partial members-fetched team-id)))))))) + (rx/map (partial members-fetched team-id)) + (rx/catch (fn [cause] + (let [{:keys [type]} (ex-data cause)] + (if (= :not-found type) + (do + (log/warn :hint "fetch-members: team not found, skipping" + :team-id (str team-id)) + (rx/empty)) + (rx/throw cause))))))))))) (defn- invitations-fetched [team-id invitations] @@ -185,10 +287,10 @@ (rx/of (dp/refresh-profile) (fetch-members team-id) (fetch-teams) - (ptk/data-event ::ev/event - {::ev/name "delete-team-member" - :team-id team-id - :member-id member-id}))))))))) + (ev/event + {::ev/name "delete-team-member" + :team-id team-id + :member-id member-id}))))))))) (defn- stats-fetched @@ -249,9 +351,9 @@ (rx/tap on-success) (rx/mapcat (fn [_] (rx/of (fetch-teams) - (ptk/data-event ::ev/event - {::ev/name "update-team-photo" - :team-id team-id})))) + (ev/event + {::ev/name "update-team-photo" + :team-id team-id})))) (rx/catch on-error)))))) @@ -355,10 +457,10 @@ (rx/merge (rx/of (team-leaved params) (fetch-teams) - (ptk/data-event ::ev/event - {::ev/name "leave-team" - :reassign-to reassign-to - :team-id team-id})) + (ev/event + {::ev/name "leave-team" + :reassign-to reassign-to + :team-id team-id})) (on-success)))) (rx/catch on-error)))))) @@ -548,9 +650,10 @@ (defn create-webhook [{:keys [uri mtype is-active] :as params}] - (dm/assert! (contains? valid-mtypes mtype)) - (dm/assert! (boolean? is-active)) - (dm/assert! (u/uri? uri)) + + (assert (contains? valid-mtypes mtype)) + (assert (boolean? is-active)) + (assert (u/uri? uri)) (ptk/reify ::create-webhook ptk/WatchEvent @@ -592,11 +695,6 @@ (defn team->organization [team] - {:id (:organization-id team) - :slug (:organization-slug team) - :owner-id (:organization-owner-id team) - :avatar-bg-url (:organization-avatar-bg-url team) - :custom-photo (:organization-custom-photo team) - :name (:organization-name team) - :default-team-id (:id team)}) + (when-let [org (:organization team)] + (assoc org :default-team-id (:id team)))) diff --git a/frontend/src/app/main/data/uploads.cljs b/frontend/src/app/main/data/uploads.cljs index 06e87f02d9..2721cd99cb 100644 --- a/frontend/src/app/main/data/uploads.cljs +++ b/frontend/src/app/main/data/uploads.cljs @@ -24,10 +24,6 @@ [app.main.repo :as rp] [beicon.v2.core :as rx])) -;; Size of each upload chunk in bytes. Reads the penpotUploadChunkSize global -;; variable at startup; defaults to 25 MiB (overridden in production). -(def ^:private chunk-size cf/upload-chunk-size) - (def ^:private max-parallel-chunk-uploads "Maximum number of chunk upload requests that may be in-flight at the same time within a single chunked upload session." @@ -44,8 +40,11 @@ Returns an observable that emits exactly one map: `{:session-id }` - The caller is responsible for the final step (assemble / import)." - [blob] + The caller is responsible for the final step (assemble / import). + + The optional `opts` map accepts: + `:chunk-size` – size in bytes of each chunk (default: `cf/upload-chunk-size`, 25 MiB)." + [blob & {:keys [chunk-size] :or {chunk-size cf/upload-chunk-size}}] (let [total-size (.-size blob) total-chunks (js/Math.ceil (/ total-size chunk-size))] (->> (rp/cmd! :create-upload-session diff --git a/frontend/src/app/main/data/viewer.cljs b/frontend/src/app/main/data/viewer.cljs index c1f6083d13..d2ebf4c9dc 100644 --- a/frontend/src/app/main/data/viewer.cljs +++ b/frontend/src/app/main/data/viewer.cljs @@ -82,7 +82,7 @@ (when (some? (:profile state)) (fetch-comment-threads params)) (when (:share-id params) - (rx/of (ptk/event ::ev/event {::ev/name "shared-prototipe-visited"}))))) + (rx/of (ev/event {::ev/name "shared-prototipe-visited"}))))) ptk/EffectEvent (effect [_ _ _] ;; Set the window name, the window name is used on inter-tab diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 461a611315..e5a8160398 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -72,8 +72,10 @@ [app.main.refs :as refs] [app.main.repo :as rp] [app.main.router :as rt] + [app.main.store :as st] [app.render-wasm :as wasm] [app.render-wasm.api :as wasm.api] + [app.render-wasm.wasm :as wasm-state] [app.util.dom :as dom] [app.util.globals :as ug] [app.util.http :as http] @@ -201,6 +203,38 @@ (rx/of (ptk/data-event ::all-libraries-resolved {:file-id file-id}))) (rx/take-until stopper-s)))))) + +(defn check-file-position-data + [file-id] + (ptk/reify ::fix-position-data + ptk/WatchEvent + (watch [it state _] + (let [file (dsh/lookup-file state file-id) + changes + (->> file :data :pages + (mapcat + (fn [page-id] + (->> (dsh/lookup-page-objects state file-id page-id) + (vals) + (filter cfh/text-shape?) + (filter #(nil? (:position-data %))) + (map (fn [shape] + {:type :mod-obj + :id (:id shape) + :page-id page-id + :operations + [{:type :set + :attr :position-data + :val (wasm.api/calculate-position-data shape) + :ignore-touched true + :ignore-geometry true}]}))))) + (into []))] + (rx/of (dch/commit-changes + {:redo-changes changes :undo-changes [] + :save-undo? false + :origin it + :tags #{:position-data}})))))) + (defn- workspace-initialized [file-id] (ptk/reify ::workspace-initialized @@ -318,7 +352,12 @@ (let [stoper-s (rx/filter (ptk/type? ::finalize-workspace) stream) rparams (rt/get-params state) features (features/get-enabled-features state team-id) - render-wasm? (contains? features "render-wasm/v1")] + ;; since render-wasm/v1 can be hot-toggled by the user, we need to query it + ;; from the state with active-feature? + render-wasm-enabled? #(features/active-feature? @st/state "render-wasm/v1") + render-wasm-ready? #(and (render-wasm-enabled?) + wasm-state/context-initialized? + (not @wasm-state/context-lost?))] (log/debug :hint "initialize-workspace" :team-id (dm/str team-id) @@ -329,7 +368,7 @@ (rx/concat ;; Fetch all essential data that should be loaded before the file (rx/merge - (if ^boolean render-wasm? + (if ^boolean (render-wasm-enabled?) (->> (rx/from @wasm/module) (rx/filter true?) (rx/tap (fn [_] @@ -352,7 +391,7 @@ (rx/of (du/fetch-access-tokens)))) ;; Once the essential data is fetched, lets proceed to - ;; fetch teh file bunldle + ;; fetch the file bundle (rx/of (initialize-file team-id file-id))) (->> stream @@ -405,74 +444,72 @@ (rx/take 1) (rx/map #(dwcm/navigate-to-comment-id comment-id)))) - (when render-wasm? - (->> stream - (rx/filter dch/commit?) - (rx/map deref) - (rx/mapcat - (fn [{:keys [redo-changes]}] - (let [added (->> redo-changes - (filter #(= (:type %) :add-obj)) - (map :id))] - (->> (rx/from added) - (rx/map process-wasm-object))))))) + (->> stream + (rx/filter dch/commit?) + (rx/filter render-wasm-ready?) + (rx/map deref) + (rx/mapcat + (fn [{:keys [redo-changes]}] + (let [added (->> redo-changes + (filter #(= (:type %) :add-obj)) + (map :id))] + (->> (rx/from added) + (rx/map process-wasm-object)))))) - (when render-wasm? - (let [local-commits-s - (->> stream - (rx/filter dch/commit?) - (rx/map deref) - (rx/filter #(and (= :local (:source %)) - (not (contains? (:tags %) :position-data)))) - (rx/filter (complement empty?))) + (let [local-commits-s + (->> stream + (rx/filter dch/commit?) + (rx/filter render-wasm-ready?) + (rx/map deref) + (rx/filter #(and (= :local (:source %)) + (not (contains? (:tags %) :position-data)))) + (rx/filter (complement empty?))) - notifier-s - (rx/merge - (->> local-commits-s (rx/debounce 1000)) - (->> stream (rx/filter dps/force-persist?))) + notifier-s + (rx/merge + (->> local-commits-s (rx/debounce 1000)) + (->> stream (rx/filter dps/force-persist?))) - objects-s - (rx/from-atom refs/workspace-page-objects {:emit-current-value? true}) + objects-s + (rx/from-atom refs/workspace-page-objects {:emit-current-value? true}) - current-page-id-s - (rx/from-atom refs/current-page-id {:emit-current-value? true})] + current-page-id-s + (rx/from-atom refs/current-page-id {:emit-current-value? true})] - (->> local-commits-s - (rx/buffer-until notifier-s) - (rx/with-latest-from objects-s) - (rx/map - (fn [[commits objects]] - (->> commits - (mapcat :redo-changes) - (filter #(contains? #{:mod-obj :add-obj} (:type %))) - (filter #(cfh/text-shape? objects (:id %))) - (map #(vector - (:id %) - (wasm.api/calculate-position-data (get objects (:id %)))))))) + (->> local-commits-s + (rx/buffer-until notifier-s) + (rx/with-latest-from objects-s) + (rx/map + (fn [[commits objects]] + (->> commits + (mapcat :redo-changes) + (filter #(contains? #{:mod-obj :add-obj} (:type %))) + (filter #(cfh/text-shape? objects (:id %))) + (map #(vector + (:id %) + (wasm.api/calculate-position-data (get objects (:id %)))))))) - (rx/with-latest-from current-page-id-s) - (rx/map - (fn [[text-position-data page-id]] - (let [changes - (->> text-position-data - (mapv (fn [[id position-data]] - {:type :mod-obj - :id id - :page-id page-id - :operations - [{:type :set - :attr :position-data - :val position-data - :ignore-touched true - :ignore-geometry true}]})))] - (when (d/not-empty? changes) - (dch/commit-changes - {:redo-changes changes :undo-changes [] - :save-undo? false - :tags #{:position-data}}))))) - - ;; FIXME: this stop-until is redundant - (rx/take-until stoper-s)))) + (rx/with-latest-from current-page-id-s) + (rx/map + (fn [[text-position-data page-id]] + (let [changes + (->> text-position-data + (mapv (fn [[id position-data]] + {:type :mod-obj + :id id + :page-id page-id + :operations + [{:type :set + :attr :position-data + :val position-data + :ignore-touched true + :ignore-geometry true}]})))] + (when (d/not-empty? changes) + (dch/commit-changes + {:redo-changes changes :undo-changes [] + :save-undo? false + :tags #{:position-data}}))))) + (rx/take-until stoper-s))) (->> stream (rx/filter dch/commit?) @@ -511,7 +548,8 @@ :workspace-persistence :workspace-presence :workspace-tokens - :workspace-undo) + :workspace-undo + :workspace-versions) (update :workspace-global dissoc :read-only?) (assoc-in [:workspace-global :options-mode] :design) (update :files d/update-vals #(dissoc % :data)))) @@ -1255,6 +1293,24 @@ (pcb/mod-page {:background (:color color)}))] (rx/of (dch/commit-changes changes))))))) +(defn change-pixel-grid-color + "Update the pixel grid color (and optional alpha) for the given page. + Mirrors `change-canvas-color` — stored on the page so the choice + travels with the file and persists across sessions." + ([color] + (change-pixel-grid-color nil color)) + ([page-id color] + (ptk/reify ::change-pixel-grid-color + ptk/WatchEvent + (watch [it state _] + (let [page-id (or page-id (:current-page-id state)) + page (dsh/lookup-page state page-id) + changes (-> (pcb/empty-changes it) + (pcb/with-page page) + (pcb/mod-page {:pixel-grid-color (:color color) + :pixel-grid-opacity (:opacity color)}))] + (rx/of (dch/commit-changes changes))))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -1359,7 +1415,7 @@ (rx/concat (rx/of (dch/commit-changes changes)) (when (nil? annotation) - (rx/of (ptk/data-event ::ev/event {::ev/name "delete-component-annotation"})))))))) + (rx/of (ev/event {::ev/name "delete-component-annotation"})))))))) (defn set-annotations-expanded [expanded] @@ -1381,7 +1437,7 @@ ptk/WatchEvent (watch [_ _ _] (when (some? id) - (rx/of (ptk/data-event ::ev/event {::ev/name "create-component-annotation"})))))) + (rx/of (ev/event {::ev/name "create-component-annotation"})))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Preview blend modes @@ -1554,6 +1610,7 @@ (dm/export dwv/initialize-viewport) (dm/export dwv/update-viewport-position) (dm/export dwv/update-viewport-size) +(dm/export dwv/sync-wasm-workspace-viewport) (dm/export dwv/start-panning) (dm/export dwv/finish-panning) diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 72f94ecdc4..e1c35c9241 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -34,6 +34,7 @@ [app.config :as cf] [app.main.data.changes :as dch] [app.main.data.event :as ev] + [app.main.data.exports.wasm :as wasm.exports] [app.main.data.helpers :as dsh] [app.main.data.notifications :as ntf] [app.main.data.persistence :as-alias dps] @@ -303,13 +304,30 @@ (and (instance? js/DOMException cause) (= (.-name cause) "NotAllowedError"))) +(defn- clipboard-unavailable-error? + "Check if the given error is a clipboard API unavailable error + (thrown when navigator.clipboard is undefined, e.g. on insecure + origins per the W3C Secure Contexts spec)." + [cause] + (and (instance? js/Error cause) + (str/starts-with? (.-message cause) "Clipboard API is unavailable."))) + (defn- on-clipboard-permission-error [cause] - (if (clipboard-permission-error? cause) + (cond + (clipboard-permission-error? cause) (rx/of (ntf/show {:content (tr "errors.clipboard-permission-denied") :type :toast :level :warning :timeout 5000})) + + (clipboard-unavailable-error? cause) + (rx/of (ntf/show {:content (tr "errors.clipboard-api-unavailable") + :type :toast + :level :warning + :timeout 5000})) + + :else (rx/throw cause))) (defn paste-from-clipboard @@ -511,6 +529,12 @@ :level :warning :timeout 5000})) + (clipboard-unavailable-error? cause) + (rx/of (ntf/show {:content (tr "errors.clipboard-api-unavailable") + :type :toast + :level :warning + :timeout 5000})) + (:not-implemented (ex-data cause)) (rx/of (ntf/warn (tr "errors.clipboard-not-implemented"))) @@ -547,8 +571,8 @@ (defn- frame-same-size? [paste-obj frame-obj] (and - (= (:heigth (:selrect (first (vals paste-obj)))) - (:heigth (:selrect frame-obj))) + (= (:height (:selrect (first (vals paste-obj)))) + (:height (:selrect frame-obj))) (= (:width (:selrect (first (vals paste-obj)))) (:width (:selrect frame-obj))))) @@ -987,7 +1011,7 @@ (ptk/data-event :layout/update {:ids [frame-id]}) (dwu/commit-undo-transaction undo-id) (when add-component-to-variant? - (ptk/event ::ev/event {::ev/name "add-component-to-variant"}))))))))) + (ev/event {::ev/name "add-component-to-variant"}))))))))) (defn- as-content [text] (let [paragraphs (->> (str/lines text) @@ -1131,10 +1155,11 @@ :enabled true :name ""} - params {:exports [export] - :profile-id (:profile-id state) - :cmd :export-shapes - :wait true}] + ;; Create a deferred promise immediately, before any async operations. + ;; Registering the clipboard write NOW preserves the user-gesture security + ;; context; the actual blob is supplied asynchronously once the export finishes. + deferred (p/deferred) + write-promise (clipboard/to-clipboard-promise "image/png" deferred)] (rx/concat ;; Ensure current state persisted before exporting. @@ -1144,21 +1169,34 @@ (rx/first) (rx/timeout 400 (rx/empty))) - ;; Exporting itself can time its time, better to notify that we are busy. + ;; Exporting itself can take its time, better to notify that we are busy. (rx/of (ntf/info (tr "workspace.clipboard.copying"))) - ;; Call exporter to get image URI, then fetch and copy blob. - (->> (rp/cmd! :export params) + ;; Call exporter to get image URI, then fetch blob and resolve the deferred. + (->> (if (and (features/active-feature? state "render-wasm/v1") + (contains? cf/flags :wasm-export)) + (rx/of {:uri (wasm.exports/export-image-uri export)}) + (rp/cmd! :export + {:exports [export] + :profile-id (:profile-id state) + :cmd :export-shapes + :wait true})) + (rx/mapcat (fn [{:keys [uri]}] (http/send! {:method :get :uri uri :response-type :blob}))) (rx/map :body) - (rx/tap (fn [blob] - (clipboard/to-clipboard-promise "image/png" (p/resolved blob)))) + (rx/mapcat (fn [blob] + ;; Resolve the deferred with the fetched blob; the browser + ;; will now complete the clipboard write it started earlier. + (p/resolve! deferred blob) + (rx/from write-promise))) (rx/map (fn [_] (ntf/success (tr "workspace.clipboard.image-copied")))) (rx/catch (fn [e] - (js/console.error "clipboard blocked:" e) - (ntf/error (tr "workspace.clipboard.image-copy-failed")) - (rx/empty))))))))) + (js/console.error "clipboard error:" e) + ;; Reject the deferred in case the error occurred before the + ;; blob was fetched, so the pending clipboard write is cancelled. + (p/reject! deferred e) + (rx/of (ntf/error (tr "workspace.clipboard.image-copy-failed"))))))))))) diff --git a/frontend/src/app/main/data/workspace/collapse.cljs b/frontend/src/app/main/data/workspace/collapse.cljs index 1143a6f4d8..b5b4998c6b 100644 --- a/frontend/src/app/main/data/workspace/collapse.cljs +++ b/frontend/src/app/main/data/workspace/collapse.cljs @@ -49,3 +49,19 @@ (update [_ state] (update state :workspace-local dissoc :expanded)))) +(defn expand-subtree + "Recursively expand the layer subtree rooted at `id`, marking the shape + and all of its descendants as expanded in the Layers sidebar. + + Closes the gap with `collapse-all`: there was no symmetric way to + open every nested level of a single subtree, so unfolding a deep + shape required clicking each disclosure indicator one by one + (O(siblings × depth) clicks)." + [id objects] + (ptk/reify ::expand-subtree + ptk/UpdateEvent + (update [_ state] + (let [ids (cfh/get-children-ids-with-self objects id) + expansions (into {} (map (fn [descendant-id] [descendant-id true])) ids)] + (update-in state [:workspace-local :expanded] merge expansions))))) + diff --git a/frontend/src/app/main/data/workspace/colors.cljs b/frontend/src/app/main/data/workspace/colors.cljs index fba1728d8c..7f21245f49 100644 --- a/frontend/src/app/main/data/workspace/colors.cljs +++ b/frontend/src/app/main/data/workspace/colors.cljs @@ -1048,7 +1048,15 @@ (when-let [color (-> state (select-keys [:image :gradient :color :opacity]) (not-empty))] - (rx/of (add-recent-color color)))))))) + ;; Closing the dialog while an image-fill upload is still in + ;; flight (or a gradient is mid-edit) leaves the colorpicker + ;; with a partial selection — opacity-only, or with stops not + ;; yet committed. ``add-recent-color`` runs the value through + ;; ``check-color`` and asserts; gate on the same schema here + ;; so the partial value is silently dropped instead of crashing + ;; the workspace. + (when (clr/valid-color? color) + (rx/of (add-recent-color color))))))))) (defn update-colorpicker-gradient [changes] diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index be0b8b6dc6..6c5625a9e7 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -433,7 +433,7 @@ ([id-ref ids] (ptk/reify ::add-component ptk/WatchEvent - (watch [_ state _] + (watch [it state _] (let [objects (dsh/lookup-page-objects state) selected (->> (d/nilv ids (dsh/lookup-selected state)) (cfh/clean-loops objects)) @@ -442,7 +442,8 @@ can-make-component (every? true? (map #(ctn/valid-shape-for-component? objects %) selected-objects))] (when can-make-component - (rx/of (add-component2 id-ref selected)))))))) + (rx/of (-> (add-component2 id-ref selected) + (with-meta (meta it)))))))))) (defn add-multiple-components "Add several new components to current file library, from the currently selected shapes." @@ -644,11 +645,12 @@ (when id-ref (reset! id-ref (:id new-shape))) - (rx/of (ptk/event ::ev/event - {::ev/name "use-library-component" - ::ev/origin origin - :external-library (not= file-id current-file-id) - :is-variant (ctk/is-variant? component)}) + (rx/of (ev/event + (-> {::ev/name "use-library-component" + ::ev/origin origin + :external-library (not= file-id current-file-id) + :is-variant (ctk/is-variant? component)} + (merge (meta it)))) (dwu/start-undo-transaction undo-id) (dch/commit-changes changes) (ptk/data-event :layout/update {:ids [(:id new-shape)]}) @@ -696,7 +698,7 @@ (let [page-id (:current-page-id state) file-id (:current-file-id state) - ;; FIXME: revisit, innefficient access + ;; FIXME: revisit, inefficient access objects (dsh/lookup-page-objects state page-id) libraries (dsh/lookup-libraries state) @@ -960,6 +962,12 @@ (dwt.wasm/render-thumbnail file-id page-id root-id) (dwt/update-thumbnail file-id page-id root-id tag "update-component-thumbnail-sync")))) +(defn- render-component-thumbnail-event + [[component-id file-id]] + (let [file-id (or file-id (:current-file-id @st/state))] + (update-component-thumbnail-sync + @st/state component-id file-id "component"))) + (defn update-component-sync ([shape-id file-id] (update-component-sync shape-id file-id nil)) ([shape-id file-id undo-group] @@ -1358,15 +1366,14 @@ (->> (rx/from-atom refs/workspace-data {:emit-current-value? true}) (rx/share)) + ;; Pair every commit with the file data right before it + ;; (so deleted shapes still exist when we inspect the change). workspace-buffer-s (->> (rx/concat - (rx/take 1 workspace-data-s) (rx/take 1 workspace-data-s) workspace-data-s) - ;; Need to get the file data before the change, so deleted shapes - ;; still exist, for example. We initialize the buffer with three - ;; copies of the initial state - (rx/buffer 3 1)) + (rx/buffer 2 1) + (rx/map first)) changes-s (->> stream @@ -1376,9 +1383,15 @@ (rx/observe-on :async)) check-changes - (fn [[event [old-data _mid_data _new-data]]] - (if (nil? old-data) + (fn [[event old-data]] + (cond + (nil? old-data) (rx/empty) + + (:translation? event) + (rx/empty) + + :else (let [{:keys [file-id changes save-undo? undo-group]} event changed-components @@ -1392,11 +1405,9 @@ (do (log/info :hint "detected component changes" :ids (map str changed-components) :undo-group undo-group) - (->> (rx/from changed-components) (rx/map #(component-changed % (:id old-data) undo-group)))) - ;; even if save-undo? is false, we need to update the :modified-date of the component - ;; (for example, for undos) + ;; save-undo? false (undos): just bump :modified-at (->> (rx/from changed-components) (rx/map touch-component))) @@ -1418,8 +1429,19 @@ (->> (rx/merge changes-s - ;; Persist thumbnails to the server in batches after user - ;; becomes inactive for 5 seconds. + ;; WASM only: render the thumbnail on every component + ;; change so single edits (fill, etc.) update instantly. + ;; Non-WASM persists on every render, so it stays on the + ;; debounced path below to avoid per-edit backend posts. + (if (features/active-feature? @st/state "render-wasm/v1") + (->> changes-s + (rx/filter (ptk/type? ::component-changed)) + (rx/map deref) + (rx/map render-component-thumbnail-event)) + (rx/empty)) + + ;; Persist to the server in batches, 5s after the user + ;; goes idle. (->> changes-s (rx/filter (ptk/type? ::component-changed)) (rx/map deref) @@ -1428,15 +1450,11 @@ (rx/map (fn [[component-id file-id]] (update-component-thumbnail component-id file-id)))) - ;; Immediately update the component thumbnail on undos, - ;; which emit touch-component instead of component-changed. + ;; Undo/redo emit touch-component instead. (->> changes-s (rx/filter (ptk/type? ::touch-component)) (rx/map deref) - (rx/map (fn [[component-id file-id]] - (let [file-id (or file-id (:current-file-id @st/state))] - (update-component-thumbnail-sync - @st/state component-id file-id "component")))))) + (rx/map render-component-thumbnail-event))) (rx/take-until stopper-s))))))) @@ -1470,7 +1488,7 @@ vals (some ctk/is-variant?))] (if has-variants? - (rx/of (ptk/event ::ev/event {::ev/name "set-file-variants-shared" ::ev/origin "workspace"})) + (rx/of (ev/event {::ev/name "set-file-variants-shared" ::ev/origin "workspace"})) (rx/empty))))))))) ;; --- Link and unlink Files @@ -1534,11 +1552,32 @@ (when (pos? variants-count) (->> (rp/cmd! :get-library-usage {:file-id library-id}) (rx/map (fn [library-usage] - (ptk/event ::ev/event {::ev/name "attach-library-variants" - :file-id file-id - :library-id library-id - :variants-count variants-count - :library-used-in (:used-in library-usage)})))))))))) + (ev/event {::ev/name "attach-library-variants" + :file-id file-id + :library-id library-id + :variants-count variants-count + :library-used-in (:used-in library-usage)})))))))))) + +(defn cleanup-unlinked-libraries + "Remove libraries from state that are no longer linked to the given file. + This is used after unlinking a library to clean up transitive dependencies." + [file-id libraries] + (ptk/reify ::cleanup-unlinked-libraries + ptk/UpdateEvent + (update [_ state] + (let [linked-ids (into #{} (map :id) libraries)] + (update state :files + (fn [files] + (reduce-kv + (fn [acc id file] + (if (and (= (:library-of file) file-id) + (not (contains? linked-ids id)) + (not= id file-id)) + (dissoc acc id) + acc)) + files + files))))))) + (defn unlink-file-from-library [file-id library-id] @@ -1555,7 +1594,11 @@ ptk/WatchEvent (watch [_ _ _] - (let [params {:file-id file-id - :library-id library-id}] - (->> (rp/cmd! :unlink-file-from-library params) - (rx/ignore)))))) + ;; Unlink the library, then fetch the current list of linked libraries + ;; and remove any that are no longer linked (e.g., transitive dependencies) + (->> (rp/cmd! :unlink-file-from-library {:file-id file-id :library-id library-id}) + (rx/mapcat (fn [_] + (rp/cmd! :get-file-libraries {:file-id file-id}))) + (rx/map (partial cleanup-unlinked-libraries file-id)))))) + + diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs index f4a9c9bacc..2884803a68 100644 --- a/frontend/src/app/main/data/workspace/mcp.cljs +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -85,7 +85,7 @@ (update state :mcp assoc :connected-tab id) (and (= "disconnected" (:connection-status data)) - (= id (:connection-status mcp-state))) + (= id (:connected-tab mcp-state))) (update state :mcp dissoc :connected-tab) :else @@ -185,11 +185,11 @@ {:content (tr "notifications.mcp.active-in-another-tab") :cancel {:label (tr "labels.dismiss") :callback #(st/emit! (ntf/hide) - (ev/event {::ev/name "confirm-mcp-tab-switch" + (ev/event {::ev/name "dismiss-mcp-tab-switch" ::ev/origin "workspace-notification"}))} :accept {:label (tr "labels.switch") :callback #(st/emit! (connect-mcp) - (ev/event {::ev/name "dismiss-mcp-tab-switch" + (ev/event {::ev/name "confirm-mcp-tab-switch" ::ev/origin "workspace-notification"}))}}))) (rx/of (ntf/hide))))))) diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index ed8e44e3ca..f767cc9409 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -30,7 +30,9 @@ [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.undo :as dwu] [app.main.features :as features] + [app.main.streams :as ms] [app.render-wasm.api :as wasm.api] + [app.render-wasm.gesture :as wasm-gesture] [app.render-wasm.shape :as wasm.shape] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) @@ -38,21 +40,26 @@ (def ^:private xf:without-uuid-zero (remove #(= % uuid/zero))) +;; Lets set-wasm-modifiers call clean-modifiers only on the +;; non-translation→translation transition instead of every frame. +(def ^:private wasm-structure-modifiers-active? (volatile! false)) + ;; Tracks whether the WASM renderer is currently in "interactive ;; transform" mode (a drag / resize / rotate gesture in progress). ;; Paired with `set-modifiers-start` / `set-modifiers-end` so the ;; native side only toggles once per gesture, regardless of how many ;; `set-wasm-modifiers` calls fire in between. -(defonce ^:private interactive-transform-active? (atom false)) +;; State lives in `app.render-wasm.gesture` so `reload-renderer!` can reset it after +;; `_clean_up` without an api ↔ modifiers circular dependency. (defn- ensure-interactive-transform-start! [] - (when (compare-and-set! interactive-transform-active? false true) + (when (wasm-gesture/try-begin-interactive-transform!) (wasm.api/set-modifiers-start))) (defn- ensure-interactive-transform-end! [] - (when (compare-and-set! interactive-transform-active? true false) + (when (wasm-gesture/try-end-interactive-transform!) (wasm.api/set-modifiers-end))) (def ^:private transform-attrs @@ -302,6 +309,7 @@ ;; skip shadows / blur). (ensure-interactive-transform-end!) (wasm.api/clean-modifiers) + (vreset! wasm-structure-modifiers-active? false) (set-wasm-props! (dsh/lookup-page-objects state) (:wasm-props state) []))) ptk/UpdateEvent @@ -617,32 +625,56 @@ (defn set-temporary-selrect [selrect] (ptk/reify ::set-temporary-selrect - ptk/UpdateEvent - (update [_ state] - (assoc state :workspace-selrect selrect)))) + ptk/EffectEvent + (effect [_ _ _] + (rx/push! ms/workspace-selrect selrect)))) (defn set-temporary-modifiers [modifiers] (ptk/reify ::set-temporary-modifiers - ptk/UpdateEvent - (update [_ state] - (assoc state :workspace-wasm-modifiers modifiers)))) + ptk/EffectEvent + (effect [_ _ _] + (rx/push! ms/wasm-modifiers (into {} modifiers))))) (def ^:private xf:map-key (map key)) +(defn- translate-selrect + "Shift `selrect`'s center by (tx, ty). Width/height/transform are + invariant under pure translation, so only `:center` moves." + [selrect tx ty] + (update selrect :center + (fn [c] (gpt/point (+ (:x c) tx) (+ (:y c) ty))))) + +(defn- cached-translation-selrect + "Translation-only fast path for the live selection rect. Avoids a WASM + `get-selection-rect` call per drag frame by caching the gesture-start + base: on the first emission, ask WASM and back out the current delta + to recover the base; on every later emission, shift the cached base + by the new (tx, ty)." + [ids ^js first-matrix cache] + (let [tx (.-e first-matrix) + ty (.-f first-matrix)] + (if-let [base @cache] + (translate-selrect base tx ty) + (let [computed (wasm.api/get-selection-rect ids)] + (vreset! cache (translate-selrect computed (- tx) (- ty))) + computed)))) + #_:clj-kondo/ignore (defn set-wasm-modifiers - [modif-tree & {:keys [ignore-constraints ignore-snap-pixel] + [modif-tree & {:keys [ignore-constraints ignore-snap-pixel + subtree-ids-by-id selection-rect-cache] :or {ignore-constraints false ignore-snap-pixel false} :as params}] (ptk/reify ::set-wasm-modifiers ptk/UpdateEvent (update [_ state] - (let [property-changes - (extract-property-changes modif-tree)] - (-> state - (assoc :prev-wasm-props (:wasm-props state)) - (assoc :wasm-props property-changes)))) + (let [property-changes (extract-property-changes modif-tree)] + (if (d/not-empty? property-changes) + (-> state + (assoc :prev-wasm-props (:wasm-props state)) + (assoc :wasm-props property-changes)) + state))) ptk/WatchEvent (watch [_ state _] @@ -652,22 +684,35 @@ ;; thread is not blocked. The pair is closed in ;; `clear-local-transform`. (ensure-interactive-transform-start!) - (wasm.api/clean-modifiers) - (let [prev-wasm-props (:prev-wasm-props state) - wasm-props (:wasm-props state) - objects (dsh/lookup-page-objects state) - snap-pixel? - (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))] - (set-wasm-props! objects prev-wasm-props wasm-props) - (let [structure-entries (parse-structure-modifiers modif-tree)] - (wasm.api/set-structure-modifiers structure-entries) - (let [geometry-entries (parse-geometry-modifiers modif-tree) - modifiers (wasm.api/propagate-modifiers geometry-entries snap-pixel?)] - (wasm.api/set-modifiers modifiers) - (let [ids (into [] xf:map-key geometry-entries) - selrect (wasm.api/get-selection-rect ids)] - (rx/of (set-temporary-selrect selrect) - (set-temporary-modifiers modifiers))))))))) + (let [snap-pixel? (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) + translation? (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] + + (if translation? + ;; Pure translation: no structure changes needed. If structure + ;; modifiers were active from a previous non-translation frame + ;; (e.g. shape hovered over a frame then dragged back out), + ;; clear them now so the shape is not clipped by the old frame. + (when @wasm-structure-modifiers-active? + (wasm.api/clean-modifiers) + (vreset! wasm-structure-modifiers-active? false)) + (let [objects (dsh/lookup-page-objects state)] + (set-wasm-props! objects (:prev-wasm-props state) (:wasm-props state)) + (wasm.api/clean-modifiers) + (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)) + (vreset! wasm-structure-modifiers-active? true))) + (let [geometry-entries (parse-geometry-modifiers modif-tree) + root-modifiers (into [] (map (fn [[id data]] [id (:transform data)])) geometry-entries) + modifiers + (if (and translation? (not snap-pixel?)) + root-modifiers + (wasm.api/propagate-modifiers geometry-entries snap-pixel?))] + (wasm.api/set-modifiers modifiers) + (let [ids (into [] xf:map-key geometry-entries) + selrect (if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers)) + (cached-translation-selrect ids (second (first modifiers)) selection-rect-cache) + (wasm.api/get-selection-rect ids))] + (rx/of (set-temporary-selrect selrect) + (set-temporary-modifiers modifiers)))))))) (defn propagate-structure-modifiers [modif-tree objects] @@ -694,86 +739,110 @@ #_:clj-kondo/ignore (defn apply-wasm-modifiers - [modif-tree & {:keys [ignore-constraints ignore-snap-pixel snap-ignore-axis undo-transation?] + [modif-tree & {:keys [ignore-constraints ignore-snap-pixel snap-ignore-axis undo-transation? + subtree-ids-by-id] :or {ignore-constraints false ignore-snap-pixel false snap-ignore-axis nil undo-transation? true} :as params}] (ptk/reify ::apply-wasm-modifiesr ptk/WatchEvent (watch [_ state _] - (wasm.api/clean-modifiers) - (let [structure-entries (parse-structure-modifiers modif-tree)] - (wasm.api/set-structure-modifiers structure-entries)) + (let [translation? + (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] + (wasm.api/clean-modifiers) + (when-not translation? + (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))) - ;; Apply property changes (e.g. grow-type) to WASM shapes before - ;; propagating geometry, so propagate_modifiers sees the updated state. - (doseq [[id {:keys [property value]}] (extract-property-changes modif-tree)] - (when (= property :grow-type) - (wasm.api/use-shape id) - (wasm.api/set-shape-grow-type value))) + ;; Apply property changes (e.g. grow-type) to WASM shapes before + ;; propagating geometry, so propagate_modifiers sees the updated state. + (doseq [[id {:keys [property value]}] (extract-property-changes modif-tree)] + (when (= property :grow-type) + (wasm.api/use-shape id) + (wasm.api/set-shape-grow-type value))) - (let [objects (dsh/lookup-page-objects state) + (let [objects (dsh/lookup-page-objects state) - geometry-entries - (parse-geometry-modifiers modif-tree) + geometry-entries + (parse-geometry-modifiers modif-tree) - snap-pixel? - (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) + snap-pixel? + (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) - transforms - (into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?)) + transforms + (if (and translation? (not snap-pixel?)) + ;; Mirror WASM `propagate_modifiers` in CLJS: splat the + ;; translation matrix onto every descendant. Without + ;; this step the commit would only touch the dragged + ;; 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. + (reduce + (fn [acc [id data]] + (let [t (:transform data) + subtree-ids + (or (get subtree-ids-by-id id) + (cfh/get-children-ids-with-self objects id))] + (reduce (fn [a sid] (assoc a sid t)) acc subtree-ids))) + {} + geometry-entries) + (into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?))) - ignore-tree - (calculate-ignore-tree-wasm transforms objects) + ignore-tree + (calculate-ignore-tree-wasm transforms objects) - options - (-> params - (assoc :reg-objects? true) - (assoc :ignore-tree ignore-tree) - ;; Attributes that can change in the transform. This - ;; way we don't have to check all the attributes - (assoc :attrs transform-attrs)) + options + (-> params + (assoc :reg-objects? true) + (assoc :ignore-tree ignore-tree) + (assoc :translation? translation?) + ;; Attributes that can change in the transform. This + ;; way we don't have to check all the attributes + (assoc :attrs transform-attrs)) - modif-tree - (propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state)) + modif-tree + (propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state)) - ids - (into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms)) + ids + (into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms)) - update-shape - (fn [shape] - (let [shape-id (dm/get-prop shape :id) - transform (get transforms shape-id) - modifiers (dm/get-in modif-tree [shape-id :modifiers])] - (-> shape - (gsh/apply-transform transform) - (ctm/apply-structure-modifiers modifiers)))) + update-shape + (fn [shape] + (let [shape-id (dm/get-prop shape :id) + transform (get transforms shape-id) + modifiers (dm/get-in modif-tree [shape-id :modifiers])] + (-> shape + (gsh/apply-transform transform) + (ctm/apply-structure-modifiers modifiers)))) - bool-ids - (into #{} - (comp - (mapcat (partial cfh/get-parents-with-self objects)) - (filter cfh/bool-shape?) - (map :id)) - ids) + bool-ids + (into #{} + (comp + (mapcat (partial cfh/get-parents-with-self objects)) + (filter cfh/bool-shape?) + (map :id)) + ids) - undo-id (js/Symbol)] - (rx/concat - (if undo-transation? - (rx/of (dwu/start-undo-transaction undo-id)) - (rx/empty)) - (rx/of - (clear-local-transform) - (ptk/event ::dwg/move-frame-guides {:ids ids :transforms transforms}) - (ptk/event ::dwcm/move-frame-comment-threads transforms) - (dwsh/update-shapes ids update-shape options) + undo-id (js/Symbol)] + (rx/concat + (if undo-transation? + (rx/of (dwu/start-undo-transaction undo-id)) + (rx/empty)) + (rx/of + (clear-local-transform) + (ptk/event ::dwg/move-frame-guides {:ids ids :transforms transforms}) + (ptk/event ::dwcm/move-frame-comment-threads transforms) + (dwsh/update-shapes ids update-shape options) - ;; The update to the bool path needs to be in a different operation because it - ;; needs to have the updated children info - (dwsh/update-shapes bool-ids path/update-bool-shape (assoc options :with-objects? true))) + ;; The update to the bool path needs to be in a different operation because it + ;; needs to have the updated children info + (dwsh/update-shapes bool-ids path/update-bool-shape (assoc options :with-objects? true))) - (if undo-transation? - (rx/of (dwu/commit-undo-transaction undo-id)) - (rx/empty))))))) + (if undo-transation? + (rx/of (dwu/commit-undo-transaction undo-id)) + (rx/empty)))))))) (def ^:private xf-rotation-shape diff --git a/frontend/src/app/main/data/workspace/selection.cljs b/frontend/src/app/main/data/workspace/selection.cljs index d0ce0c5c2f..f9202e8ebd 100644 --- a/frontend/src/app/main/data/workspace/selection.cljs +++ b/frontend/src/app/main/data/workspace/selection.cljs @@ -27,6 +27,7 @@ [app.main.data.workspace.pages :as-alias dwpg] [app.main.data.workspace.specialized-panel :as-alias dwsp] [app.main.data.workspace.undo :as dwu] + [app.main.data.workspace.viewport-wasm :as dwvw] [app.main.data.workspace.zoom :as dwz] [app.main.refs :as refs] [app.main.router :as rt] @@ -602,6 +603,10 @@ (assoc :workspace-pre-focus (:workspace-local state))) state)))) + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state)) + ptk/WatchEvent (watch [_ state stream] (let [stopper (rx/filter #(or (= ::toggle-focus-mode (ptk/type %)) diff --git a/frontend/src/app/main/data/workspace/shape_layout.cljs b/frontend/src/app/main/data/workspace/shape_layout.cljs index bbff39663a..c02e096b78 100644 --- a/frontend/src/app/main/data/workspace/shape_layout.cljs +++ b/frontend/src/app/main/data/workspace/shape_layout.cljs @@ -305,11 +305,11 @@ (ptk/data-event :layout/update {:ids ids}) (dwu/commit-undo-transaction undo-id) (when (or (:layout-align-content changes) (:layout-justify-content changes)) - (ptk/event ::ev/event - {::ev/name "layout-change-alignment"})) + (ev/event + {::ev/name "layout-change-alignment"})) (when (or (:layout-padding changes) (:layout-gap changes)) - (ptk/event ::ev/event - {::ev/name "layout-change-margin"})))))))) + (ev/event + {::ev/name "layout-change-margin"})))))))) (defn add-layout-track ([ids type value] @@ -555,10 +555,9 @@ ;; change parent to fixed (and row? auto-height? (every? ctl/fill-height? all-children)) (assoc :layout-item-v-sizing :fix)))) - (defn update-layout-child - ([ids changes] (update-layout-child ids changes nil)) - ([ids changes options] + ([ids attrs] (update-layout-child ids attrs nil)) + ([ids attrs options] (ptk/reify ::update-layout-child ptk/WatchEvent (watch [_ state _] @@ -568,20 +567,20 @@ children-ids (->> ids (mapcat #(cfh/get-children-ids objects %))) parent-ids (->> ids (map #(cfh/get-parent-id objects %))) undo-id (js/Symbol) - margin-attrs (-> (get changes :layout-item-margin) + margin-attrs (-> (get attrs :layout-item-margin) keys set)] (rx/of (dwu/start-undo-transaction undo-id) - (dwsh/update-shapes ids (d/patch-object changes) + (dwsh/update-shapes ids (d/patch-object attrs) (cond-> options (seq margin-attrs) (assoc :changed-sub-attr margin-attrs))) - (dwsh/update-shapes children-ids (partial fix-child-sizing objects changes) options) + (dwsh/update-shapes children-ids (partial fix-child-sizing objects attrs) options) (dwsh/update-shapes parent-ids (fn [parent objects] (-> parent - (fix-parent-sizing objects (set ids) changes) + (fix-parent-sizing objects (set ids) attrs) (cond-> (ctl/grid-layout? parent) (ctl/assign-cells objects)))) (merge options {:with-objects? true})) diff --git a/frontend/src/app/main/data/workspace/shapes.cljs b/frontend/src/app/main/data/workspace/shapes.cljs index 022dd11d21..cb2bcad26c 100644 --- a/frontend/src/app/main/data/workspace/shapes.cljs +++ b/frontend/src/app/main/data/workspace/shapes.cljs @@ -28,6 +28,8 @@ [beicon.v2.core :as rx] [potok.v2.core :as ptk])) +;; If anything a translation can mutate is added here, drop the +;; `(when-not translation? …)` guard in `update-shapes` below. (def ^:private update-layout-attr? #{:hidden}) (defn- add-undo-group @@ -46,11 +48,113 @@ (cond-> changes add-undo-group? (assoc :undo-group undo-group)))) -(defn update-shapes - ([ids update-fn] (update-shapes ids update-fn nil)) +(defn update-shapes-buffer-start + [] + (ptk/reify ::update-shapes-buffer-start + ptk/UpdateEvent + (update [_ state] + (assoc state ::update-shapes-buffer true)))) + +(defn update-shapes-buffer-stop + [] + (ptk/reify ::update-shapes-buffer-stop + ptk/UpdateEvent + (update [_ state] + (assoc state ::update-shapes-buffer false)))) + +(defn update-shapes-buffer-commit + [] + (ptk/reify ::update-shapes-buffer-commit + ptk/WatchEvent + (watch [_ state _] + (->> (get state ::update-shapes-buffer-changes) + (vals) + (map dch/commit-changes) + (rx/from))))) + +;; Looks for the objects data in the state, if there is an "in progress" +;; update-shapes-buffer will return the objeccts inside the current changes +;; to be applied. +(defn lookup-changed-objects + [state page-id] + (let [changes-objects + (-> (get-in state [::update-shapes-buffer-changes page-id]) + (pcb/lookup-objects))] + (or changes-objects (dsh/lookup-page-objects state page-id)))) + +;; Accumulates the update shapes changes into a single commit-changes +;; The accumulation is marked between the events `start` and `stop` in between +;; those events all the `update-shapes` will be agregated together with this event. +;; After a `stop` arrives the `commit` will send the changes at the same time. +(defn update-shapes-buffer + ([ids update-fn] + (update-shapes-buffer ids update-fn nil)) ([ids update-fn {:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id - ignore-touched undo-group with-objects? changed-sub-attr] + ignore-touched undo-group with-objects? changed-sub-attr translation?] + :or {reg-objects? false + save-undo? true + stack-undo? false + ignore-touched false + with-objects? false} + :as props}] + (let [cur-event (js/Symbol)] + (ptk/reify ::update-shapes-buffer + ptk/UpdateEvent + (update [it state] + (if (nil? (::update-shapes-buffer-event state)) + (assoc state ::update-shapes-buffer-event cur-event) + + (let [page-id (or page-id (get state :current-page-id)) + objects (dsh/lookup-page-objects state page-id)] + (-> state + (update-in + [::update-shapes-buffer-changes page-id] + (fn [changes] + (-> (or changes + (-> (pcb/empty-changes it page-id) + (pcb/with-objects objects) + (pcb/set-save-undo? save-undo?) + (pcb/set-stack-undo? stack-undo?) + (cond-> undo-group + (pcb/set-undo-group undo-group)))) + (cls/generate-update-shapes + ids + update-fn + nil + {:attrs attrs + :changed-sub-attr changed-sub-attr + :ignore-tree ignore-tree + :ignore-touched ignore-touched + :with-objects? with-objects?}) + (cond-> reg-objects? (pcb/resize-parents ids)) + (pcb/set-translation? translation?)))))))) + + ptk/WatchEvent + (watch [_ state stream] + (if (= (::update-shapes-buffer-event state) cur-event) + (let [stopper (->> stream (rx/filter (ptk/type? ::update-shapes-buffer-stop)))] + (rx/concat + (rx/merge + (->> stream + (rx/filter (ptk/type? ::update-shapes-buffer)) + (rx/take-until stopper) + (rx/last) + (rx/map update-shapes-buffer-commit)) + (rx/of (update-shapes-buffer ids update-fn props))) + + (rx/of #(dissoc % + ::update-shapes-buffer-changes + ::update-shapes-buffer-event)))) + (rx/empty))))))) + +(defn update-shapes + ([ids update-fn] + (update-shapes ids update-fn nil)) + ([ids update-fn + {:as props + :keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id + ignore-touched undo-group with-objects? changed-sub-attr translation?] :or {reg-objects? false save-undo? true stack-undo? false @@ -63,48 +167,55 @@ (ptk/reify ::update-shapes ptk/WatchEvent (watch [it state _] - (let [page-id (or page-id (get state :current-page-id)) - objects (dsh/lookup-page-objects state page-id) - ids (into [] (filter some?) ids) - xf-update-layout - (comp - (map (d/getf objects)) - (filter #(some update-layout-attr? (pcb/changed-attrs % objects update-fn {:attrs attrs :with-objects? with-objects?}))) - (map :id)) + (if (::update-shapes-buffer state) + (rx/of (update-shapes-buffer ids update-fn props)) - update-layout-ids - (->> (into [] xf-update-layout ids) - (not-empty)) + (let [page-id (or page-id (get state :current-page-id)) + objects (dsh/lookup-page-objects state page-id) + ids (into [] (filter some?) ids) - changes - (-> (pcb/empty-changes it page-id) - (pcb/set-save-undo? save-undo?) - (pcb/set-stack-undo? stack-undo?) - (cls/generate-update-shapes ids - update-fn - objects - {:attrs attrs - :changed-sub-attr changed-sub-attr - :ignore-tree ignore-tree - :ignore-touched ignore-touched - :with-objects? with-objects?}) - (cond-> undo-group - (pcb/set-undo-group undo-group))) + xf-update-layout + (comp + (map (d/getf objects)) + (filter #(some update-layout-attr? (pcb/changed-attrs % objects update-fn {:attrs attrs :with-objects? with-objects?}))) + (map :id)) - changes - (add-undo-group changes state)] + update-layout-ids + (when-not translation? + (->> (into [] xf-update-layout ids) + (not-empty))) - (rx/concat - (if (seq (:redo-changes changes)) - (let [changes (cond-> changes reg-objects? (pcb/resize-parents ids))] - (rx/of (dch/commit-changes changes))) - (rx/empty)) + changes + (-> (pcb/empty-changes it page-id) + (pcb/set-save-undo? save-undo?) + (pcb/set-stack-undo? stack-undo?) + (cls/generate-update-shapes ids + update-fn + objects + {:attrs attrs + :changed-sub-attr changed-sub-attr + :ignore-tree ignore-tree + :ignore-touched ignore-touched + :with-objects? with-objects? + :translation? translation?}) + (cond-> undo-group + (pcb/set-undo-group undo-group)) + (pcb/set-translation? translation?)) - ;; Update layouts for properties marked - (if update-layout-ids - (rx/of (ptk/data-event :layout/update {:ids update-layout-ids})) - (rx/empty)))))))) + changes + (add-undo-group changes state)] + + (rx/concat + (if (seq (:redo-changes changes)) + (let [changes (cond-> changes reg-objects? (pcb/resize-parents ids))] + (rx/of (dch/commit-changes changes))) + (rx/empty)) + + ;; Update layouts for properties marked + (if update-layout-ids + (rx/of (ptk/data-event :layout/update {:ids update-layout-ids})) + (rx/empty))))))))) (defn add-shape ([shape] diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index a89662e8d3..2d13e7acbb 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -6,12 +6,11 @@ (ns app.main.data.workspace.shortcuts (:require - [app.common.data.macros :as dm] + [app.config :as cf] [app.main.data.common :as dcm] [app.main.data.event :as ev] [app.main.data.exports.assets :as de] [app.main.data.modal :as modal] - [app.main.data.plugins :as dpl] [app.main.data.preview :as dp] [app.main.data.profile :as du] [app.main.data.shortcuts :as ds] @@ -31,9 +30,7 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.hooks.resize :as r] - [app.util.dom :as dom] - [beicon.v2.core :as rx] - [potok.v2.core :as ptk])) + [app.util.dom :as dom])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Shortcuts @@ -44,6 +41,19 @@ (-> (dw/toggle-layout-flag flag) (vary-meta assoc ::ev/origin "workspace-shortcuts"))) +(defn on-display-guides-keydown + [^js event] + (let [mod? (if (cf/check-platform? :macos) + (.-metaKey event) + (.-ctrlKey event)) + shift? (.-shiftKey event) + code (.-code event)] + (when (and mod? + (or (and (not shift?) (= "Quote" code)) + (and shift? (= "Backslash" code)))) + (.preventDefault event) + (st/emit! (toggle-layout-flag :display-guides))))) + (defn- emit-when-no-readonly [& events] (let [can-edit? (:can-edit (deref refs/permissions)) @@ -51,17 +61,6 @@ (when (and can-edit? (not read-only?)) (run! st/emit! events)))) -(def esc-pressed - (ptk/reify ::esc-pressed - ptk/WatchEvent - (watch [_ state _] - (rx/of - :interrupt - (let [selection (dm/get-in state [:workspace-local :selected])] - (if (empty? selection) - (dpl/close-current-plugin) - (dw/deselect-all true))))))) - ;; Shortcuts format https://github.com/ccampbell/mousetrap (def base-shortcuts @@ -149,7 +148,7 @@ :escape {:tooltip (ds/esc) :command "escape" :subsections [:edit] - :fn #(st/emit! esc-pressed)} + :fn #(st/emit! :interrupt (dw/deselect-all true))} :find {:tooltip (ds/meta "F") :command (ds/c-mod "f") :subsections [:edit] :fn #(st/emit! (dw/open-layers-search :find))} @@ -514,17 +513,17 @@ :fn #(st/emit! (dw/decrease-zoom))} :reset-zoom {:tooltip (ds/shift "0") - :command "shift+0" + :command ["shift+0" "shift+num0"] :subsections [:zoom-workspace] :fn #(st/emit! dw/reset-zoom)} :fit-all {:tooltip (ds/shift "1") - :command "shift+1" + :command ["shift+1" "shift+num1"] :subsections [:zoom-workspace] :fn #(st/emit! dw/zoom-to-fit-all)} :zoom-selected {:tooltip (ds/shift "2") - :command ["shift+2" "@" "\""] + :command ["shift+2" "shift+num2" "@" "\""] :subsections [:zoom-workspace] :fn #(st/emit! dw/zoom-to-selected-shape)} @@ -612,7 +611,7 @@ :subsections [:basics] :fn #(when (features/active-feature? @st/state "plugins/runtime") (st/emit! - (ptk/event ::ev/event {::ev/name "open-plugins-manager" ::ev/origin "workspace:shortcuts"}) + (ev/event {::ev/name "open-plugins-manager" ::ev/origin "workspace:shortcuts"}) (modal/show :plugin-management {})))}}) (def debug-shortcuts @@ -626,7 +625,7 @@ (range 10) (map (fn [n] [(keyword (str "opacity-" n)) {:tooltip (str n) - :command (str n) + :command [(str n) (str "num" n)] :subsections [:modify-layers] :fn #(emit-when-no-readonly (dwly/pressed-opacity n))}]))))) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index e3eccc05ab..d492a43b4b 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -36,6 +36,7 @@ [app.main.fonts :as fonts] [app.main.router :as rt] [app.render-wasm.api :as wasm.api] + [app.render-wasm.text-editor :as wasm.text-editor] [app.util.text-editor :as ted] [app.util.text.content.styles :as styles] [app.util.timers :as ts] @@ -465,13 +466,23 @@ (when-not (some? (get-in state [:workspace-editor-state id])) (let [objects (dsh/lookup-page-objects state) shape (get objects id) + wasm? (features/active-feature? state "render-wasm/v1") update-node? (fn [node] (or (txt/is-text-node? node) (txt/is-paragraph-node? node))) shape-ids (cond (cfh/text-shape? shape) [id] - (cfh/group-shape? shape) (cfh/get-children-ids objects id))] - (rx/of (dwsh/update-shapes shape-ids #(txt/update-text-content % update-node? d/txt-merge attrs)))))))) + (cfh/group-shape? shape) (cfh/get-children-ids objects id)) + ;; Keep WASM editor cache in sync with merged :content so a following + ;; `apply-styles-to-selection` in `update-attrs` does not read stale + ;; `shape-text-contents` and overwrite per-run fills (e.g. line-height). + merge-shape + (fn [sh] + (let [updated-shape (txt/update-text-content sh update-node? d/txt-merge attrs)] + (when wasm? + (wasm.text-editor/cache-shape-text-content! (:id updated-shape) (:content updated-shape))) + updated-shape))] + (rx/of (dwsh/update-shapes shape-ids merge-shape))))))) (defn migrate-node [node] @@ -851,11 +862,13 @@ (effect [_ state _] (when (features/active-feature? state "text-editor/v2") (when-let [instance (:workspace-editor state)] - (let [attrs-to-override (some-> (editor.v2/getCurrentStyle instance) - (styles/get-styles-from-style-declaration)) - overriden-attrs (merge attrs-to-override attrs) - styles (styles/attrs->styles overriden-attrs)] - (editor.v2/applyStylesToSelection instance styles))))))) + (when (seq attrs) + ;; DOM `getCurrentStyle` reflects one resolved style (e.g. caret color). Merging + ;; it with sidebar `attrs` and applying to the whole selection collapses mixed + ;; fills/fonts when the user only changes one property (e.g. line-height). + ;; Apply only the explicit attributes from this action. + (let [styles (styles/attrs->styles attrs)] + (editor.v2/applyStylesToSelection instance styles)))))))) (defn update-all-attrs [ids attrs] @@ -935,6 +948,12 @@ (d/concat-vec txt/text-font-attrs txt/text-spacing-attrs txt/text-transform-attrs))) + values (cond-> values + (number? (:line-height values)) + (update :line-height str) + + (number? (:letter-spacing values)) + (update :letter-spacing str)) typ-id (uuid/next) typ (-> (if multiple? @@ -947,8 +966,8 @@ (rx/concat (rx/of (dwl/add-typography typ) - (ptk/event ::ev/event {::ev/name "add-asset-to-library" - :asset-type "typography"})) + (ev/event {::ev/name "add-asset-to-library" + :asset-type "typography"})) (when (not multiple?) (rx/of (update-attrs (:id shape) diff --git a/frontend/src/app/main/data/workspace/thumbnails.cljs b/frontend/src/app/main/data/workspace/thumbnails.cljs index ffa3181a4e..cce984b413 100644 --- a/frontend/src/app/main/data/workspace/thumbnails.cljs +++ b/frontend/src/app/main/data/workspace/thumbnails.cljs @@ -96,12 +96,14 @@ ptk/UpdateEvent (update [_ state] - (update state :thumbnails - (fn [thumbs] - (if-let [uri (get thumbs object-id)] - (do (vreset! pending uri) - (dissoc thumbs object-id)) - thumbs)))) + (-> state + (update :thumbnails + (fn [thumbs] + (if-let [uri (get thumbs object-id)] + (do (vreset! pending uri) + (dissoc thumbs object-id)) + thumbs))) + (update :thumbnails-meta dissoc object-id))) ptk/WatchEvent (watch [_ _ _] @@ -124,10 +126,13 @@ (ptk/reify ::assoc-thumbnail ptk/UpdateEvent (update [_ state] - (let [prev-uri (dm/get-in state [:thumbnails object-id])] + (let [prev-uri (dm/get-in state [:thumbnails object-id]) + now (.now js/Date)] (some->> prev-uri (vreset! prev-uri*)) (l/trc :hint "assoc thumbnail" :object-id object-id :uri uri) - (update state :thumbnails assoc object-id uri))) + (-> state + (update :thumbnails assoc object-id uri) + (update :thumbnails-meta assoc object-id {:rendered-at now})))) ptk/EffectEvent (effect [_ _ _] diff --git a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs index ff82e15972..612bdd2e53 100644 --- a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs +++ b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs @@ -25,6 +25,7 @@ [app.main.repo :as rp] [app.main.store :as st] [app.render-wasm.api :as wasm.api] + [app.util.timers :as timers] [app.util.webapi :as wapi] [beicon.v2.core :as rx] [cuerdas.core :as str] @@ -58,13 +59,14 @@ (rx/create (fn [subs] (let [req-id - (js/requestAnimationFrame + (timers/raf (fn [_] (try (let [objects (dsh/lookup-page-objects @st/state file-id page-id)] (if-let [frame (get objects frame-id)] - (let [{:keys [width height]} (:selrect frame) - max-size (mth/max width height) + (let [{ext-w :width ext-h :height} (wasm.api/get-shape-extrect frame-id) + {sel-w :width sel-h :height} (:selrect frame) + max-size (mth/max (or ext-w sel-w) (or ext-h sel-h)) scale (mth/max 1 (/ target-size max-size)) png-bytes (wasm.api/render-shape-pixels frame-id scale)] (if (or (nil? png-bytes) (zero? (.-length png-bytes))) @@ -82,7 +84,7 @@ (rx/error! subs "Frame not found"))) (catch :default err (rx/error! subs err)))))] - #(js/cancelAnimationFrame req-id))))) + #(timers/cancel-af! req-id))))) (defn render-thumbnail "Renders a component thumbnail via WASM and updates the UI immediately. diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index 89cccdd869..45950e08f6 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -98,7 +98,8 @@ (udw/trigger-bounding-box-cloaking shape-ids) (udw/increase-rotation shape-ids value nil {:page-id page-id - :ignore-touched true}))))))) + :ignore-touched true + :no-wasm? true}))))))) (defn update-stroke-width ([value shape-ids attributes] (update-stroke-width value shape-ids attributes nil)) @@ -254,7 +255,8 @@ (->> (rx/from shape-ids) (rx/map #(dwtr/update-position % (zipmap attributes (repeat value)) {:ignore-touched true - :page-id page-id}))))))))) + :page-id page-id + :no-wasm? true}))))))))) (defn update-layout-gap [value shape-ids attributes page-id] @@ -493,8 +495,8 @@ (watch [_ _ _] (when (number? value) (rx/of - (when (:width attributes) (dwtr/update-dimensions shape-ids :width value {:ignore-touched true :page-id page-id})) - (when (:height attributes) (dwtr/update-dimensions shape-ids :height value {:ignore-touched true :page-id page-id})))))))) + (when (:width attributes) (dwtr/update-dimensions shape-ids :width value {:ignore-touched true :page-id page-id :no-wasm? true})) + (when (:height attributes) (dwtr/update-dimensions shape-ids :height value {:ignore-touched true :page-id page-id :no-wasm? true})))))))) (defn- attributes->actions [{:keys [value shape-ids attributes page-id]}] @@ -659,16 +661,13 @@ (assert (ctob/token? token) "apply-token event requires a valid token") (ptk/reify ::apply-token ptk/WatchEvent - (watch [_ state _] + (watch [it state _] ;; We do not allow to apply tokens while text editor is open. - ;; The classic text editor sets :workspace-editor-state; the WASM text editor - ;; does not, so we also check :workspace-local :edition for text shapes. (let [edition (get-in state [:workspace-local :edition]) objects (dsh/lookup-page-objects state) text-editing? (and (some? edition) (= :text (:type (get objects edition))))] - (if (and (empty? (get state :workspace-editor-state)) - (some? token) + (if (and (some? token) (not text-editing?)) (let [attributes-to-remove ;; Remove atomic typography tokens when applying composite and vice-versa @@ -706,10 +705,12 @@ type (:type token)] (rx/concat (rx/of - (st/emit! (ev/event {::ev/name "apply-tokens" - :type type - :applied-to attributes - :applied-to-variant any-variant?})) + (st/emit! (ev/event + (-> {::ev/name "apply-tokens" + :type type + :applied-to attributes + :applied-to-variant any-variant?} + (merge (meta it))))) (dwu/start-undo-transaction undo-id) (dwsh/update-shapes shape-ids (fn [shape] (cond-> shape @@ -725,20 +726,21 @@ (rx/of res)))) (rx/of (dwu/commit-undo-transaction undo-id))))))))) - (rx/of (ntf/show {:content (tr "workspace.tokens.error-text-edition") - :type :toast - :level :warning - :timeout 3000}))))))) + (when text-editing? + (rx/of (ntf/show {:content (tr "workspace.tokens.error-text-edition") + :type :toast + :level :warning + :timeout 3000})))))))) (defn apply-spacing-token-separated "Handles edge-case for spacing token when applying token via toggle button. - Splits out `shape-ids` into seperate default actions: + Splits out `shape-ids` into separate default actions: - Layouts take the `default` update function - Shapes inside layout will only take margin" [{:keys [token shapes attr]}] (ptk/reify ::apply-spacing-token-separated ptk/WatchEvent - (watch [_ state _] + (watch [it state _] (let [objects (dsh/lookup-page-objects state) {:keys [attributes on-update-shape]} @@ -748,14 +750,17 @@ (group-by #(if (ctsl/any-layout-immediate-child? objects %) :frame-children :other) shapes)] (rx/of - (apply-token {:attributes (or attr attributes) - :token token - :shape-ids (map :id other) - :on-update-shape on-update-shape}) - (apply-token {:attributes ctt/spacing-margin-keys - :token token - :shape-ids (map :id frame-children) - :on-update-shape update-layout-item-margin})))))) + (-> (apply-token {:attributes (or attr attributes) + :token token + :shape-ids (map :id other) + :on-update-shape on-update-shape}) + (with-meta (meta it))) + + (-> (apply-token {:attributes ctt/spacing-margin-keys + :token token + :shape-ids (map :id frame-children) + :on-update-shape update-layout-item-margin}) + (with-meta (meta it)))))))) (defn unapply-token "Removes `attributes` that match `token` for `shape-ids`. @@ -772,12 +777,25 @@ (fn [shape] (update shape :applied-tokens remove-token)))))))) +(defn unapply-multiple-tokens + "Removes `attributes` for `shape-ids` without knowing the token, used when a token is deleted." + [{:keys [attributes shape-ids] :as _props}] + + (ptk/reify ::unapply-multiple-tokens + ptk/WatchEvent + (watch [_ _ _] + (rx/of + (dwsh/update-shapes + shape-ids + (fn [shape] + (update shape :applied-tokens #(when % (apply dissoc % attributes))))))))) + (defn toggle-token [{:keys [token attrs shape-ids expand-with-children]}] (ptk/reify ::on-toggle-token ptk/WatchEvent - (watch [_ state _] + (watch [it state _] (let [objects (dsh/lookup-page-objects state) shapes (into [] (keep (d/getf objects)) shape-ids) @@ -814,15 +832,17 @@ (cond (and (= (:type token) :spacing) (nil? attrs)) - (apply-spacing-token-separated {:token token - :attr attrs - :shapes shapes}) + (-> (apply-spacing-token-separated {:token token + :attr attrs + :shapes shapes}) + (with-meta (meta it))) :else - (apply-token {:attributes (if (empty? attrs) attributes attrs) - :token token - :shape-ids shape-ids - :on-update-shape on-update-shape})))))))) + (-> (apply-token {:attributes (if (empty? attrs) attributes attrs) + :token token + :shape-ids shape-ids + :on-update-shape on-update-shape}) + (with-meta (meta it)))))))))) (defn apply-token-from-input [{:keys [token attrs shape-ids expand-with-children]}] diff --git a/frontend/src/app/main/data/workspace/tokens/errors.cljs b/frontend/src/app/main/data/workspace/tokens/errors.cljs index 30ab2e30b9..fb2b75a685 100644 --- a/frontend/src/app/main/data/workspace/tokens/errors.cljs +++ b/frontend/src/app/main/data/workspace/tokens/errors.cljs @@ -12,109 +12,114 @@ (def error-codes {:error.import/json-parse-error {:error/code :error.import/json-parse-error - :error/fn #(tr "workspace.tokens.error-parse")} + :error/fn #(tr "errors.tokens.error-parse")} :error.import/no-token-files-found {:error/code :error.import/no-token-files-found - :error/fn #(tr "workspace.tokens.no-token-files-found")} + :error/fn #(tr "errors.tokens.no-token-files-found")} :error.import/invalid-json-data {:error/code :error.import/invalid-json-data - :error/fn #(tr "workspace.tokens.invalid-json")} + :error/fn #(tr "errors.tokens.invalid-json")} :error.import/invalid-token-name {:error/code :error.import/invalid-token-name - :error/fn #(tr "workspace.tokens.invalid-json-token-name") - :error/detail #(tr "workspace.tokens.invalid-json-token-name-detail" %)} + :error/fn #(tr "errors.tokens.invalid-json-token-name") + :error/detail #(tr "errors.tokens.invalid-json-token-name-detail" %)} + + :error.import/duplicated-token-name + {:error/code :error.import/duplicated-token-name + :error/fn #(tr "workspace.tokens.duplicated-json-token-name") + :error/detail #(tr "workspace.tokens.duplicated-json-token-name-detail" %)} :error.import/style-dictionary-reference-errors {:error/code :error.import/style-dictionary-reference-errors - :error/fn #(str (tr "workspace.tokens.import-error") "\n\n" (first %)) + :error/fn #(str (tr "errors.tokens.import-error") "\n\n" (first %)) :error/detail #(str/join "\n\n" (rest %))} :error.import/style-dictionary-unknown-error {:error/code :error.import/style-dictionary-reference-errors - :error/fn #(tr "workspace.tokens.import-error")} + :error/fn #(tr "errors.tokens.import-error")} :error.token/empty-input {:error/code :error.token/empty-input - :error/fn #(tr "workspace.tokens.empty-input")} + :error/fn #(tr "errors.tokens.empty-input")} :error.token/direct-self-reference {:error/code :error.token/direct-self-reference - :error/fn #(tr "workspace.tokens.self-reference")} + :error/fn #(tr "errors.tokens.self-reference")} :error.token/invalid-color {:error/code :error.token/invalid-color - :error/fn #(str (tr "workspace.tokens.invalid-color" %))} + :error/fn #(str (tr "errors.tokens.invalid-color" %))} :error.token/number-too-large {:error/code :error.token/number-too-large - :error/fn #(str (tr "workspace.tokens.number-too-large" %))} + :error/fn #(str (tr "errors.tokens.number-too-large" %))} :error.style-dictionary/missing-reference {:error/code :error.style-dictionary/missing-reference - :error/fn #(str (tr "workspace.tokens.missing-references") (str/join " " %))} + :error/fn #(str (tr "errors.tokens.missing-references") (str/join " " %))} :error.style-dictionary/invalid-token-value {:error/code :error.style-dictionary/invalid-token-value - :error/fn #(str (tr "workspace.tokens.invalid-value" %))} + :error/fn #(str (tr "errors.tokens.invalid-value" %))} :error.style-dictionary/value-with-units {:error/code :error.style-dictionary/value-with-units - :error/fn #(str (tr "workspace.tokens.value-with-units"))} + :error/fn #(str (tr "errors.tokens.value-with-units"))} :error.style-dictionary/value-with-percent {:error/code :error.style-dictionary/value-with-percent - :error/fn #(str (tr "workspace.tokens.value-with-percent"))} + :error/fn #(str (tr "errors.tokens.value-with-percent"))} :error.style-dictionary/invalid-token-value-opacity {:error/code :error.style-dictionary/invalid-token-value-opacity - :error/fn #(str/join "\n" [(str (tr "workspace.tokens.invalid-value" %) ".") (tr "workspace.tokens.opacity-range")])} + :error/fn #(str/join "\n" [(str (tr "errors.tokens.invalid-value" %) ".") (tr "errors.tokens.opacity-range")])} :error.style-dictionary/invalid-token-value-stroke-width {:error/code :error.style-dictionary/invalid-token-value-stroke-width - :error/fn #(str/join "\n" [(str (tr "workspace.tokens.invalid-value" %) ".") (tr "workspace.tokens.stroke-width-range")])} + :error/fn #(str/join "\n" [(str (tr "errors.tokens.invalid-value" %) ".") (tr "errors.tokens.stroke-width-range")])} :error.style-dictionary/invalid-token-value-text-case {:error/code :error.style-dictionary/invalid-token-value-text-case - :error/fn #(tr "workspace.tokens.invalid-text-case-token-value" %)} + :error/fn #(tr "errors.tokens.invalid-text-case-token-value" %)} :error.style-dictionary/invalid-token-value-text-decoration {:error/code :error.style-dictionary/invalid-token-value-text-decoration - :error/fn #(tr "workspace.tokens.invalid-text-decoration-token-value" %)} + :error/fn #(tr "errors.tokens.invalid-text-decoration-token-value" %)} :error.style-dictionary/invalid-token-value-font-weight {:error/code :error.style-dictionary/invalid-token-value-font-weight - :error/fn #(tr "workspace.tokens.invalid-font-weight-token-value" %)} + :error/fn #(tr "errors.tokens.invalid-font-weight-token-value" %)} :error.style-dictionary/invalid-token-value-font-family {:error/code :error.style-dictionary/invalid-token-value-font-family - :error/fn #(tr "workspace.tokens.invalid-font-family-token-value" %)} + :error/fn #(tr "errors.tokens.invalid-font-family-token-value" %)} :error.style-dictionary/invalid-token-value-typography {:error/code :error.style-dictionary/invalid-token-value-typography - :error/fn #(tr "workspace.tokens.invalid-token-value-typography" %)} + :error/fn #(tr "errors.tokens.invalid-token-value-typography" %)} :error.style-dictionary/composite-line-height-needs-font-size {:error/code :error.style-dictionary/composite-line-height-needs-font-size - :error/fn #(tr "workspace.tokens.composite-line-height-needs-font-size" %)} + :error/fn #(tr "errors.tokens.composite-line-height-needs-font-size" %)} :error.style-dictionary/invalid-token-value-shadow-type {:error/code :error.style-dictionary/invalid-token-value-shadow-type - :error/fn #(tr "workspace.tokens.invalid-shadow-type-token-value" %)} + :error/fn #(tr "errors.tokens.invalid-shadow-type-token-value" %)} :error.style-dictionary/invalid-token-value-shadow-blur {:error/code :error.style-dictionary/invalid-token-value-shadow-blur - :error/fn #(tr "workspace.tokens.shadow-blur-range")} + :error/fn #(tr "errors.tokens.shadow-blur-range")} :error.style-dictionary/invalid-token-value-shadow-spread {:error/code :error.style-dictionary/invalid-token-value-shadow-spread - :error/fn #(tr "workspace.tokens.shadow-spread-range")} + :error/fn #(tr "errors.tokens.shadow-spread-range")} :error.style-dictionary/invalid-token-value-shadow {:error/code :error.style-dictionary/invalid-token-value-shadow - :error/fn #(tr "workspace.tokens.invalid-token-value-shadow" %)} + :error/fn #(tr "errors.tokens.invalid-token-value-shadow" %)} :error/unknown {:error/code :error/unknown diff --git a/frontend/src/app/main/data/workspace/tokens/import_export.cljs b/frontend/src/app/main/data/workspace/tokens/import_export.cljs index f9793d38c8..87a6ee6067 100644 --- a/frontend/src/app/main/data/workspace/tokens/import_export.cljs +++ b/frontend/src/app/main/data/workspace/tokens/import_export.cljs @@ -20,6 +20,12 @@ [beicon.v2.core :as rx] [cuerdas.core :as str])) +(defn- extract-error-with-code + "Return the error if it has an error code generated from Penpot code" + [err] + (when (contains? (ex-data err) :error/code) + (wte/error-ex-info (:error/code (ex-data err)) (ex-message err) err))) + (defn- extract-reference-errors "Extracts reference errors from errors produced by StyleDictionary." [err] @@ -75,7 +81,8 @@ {:tokens-lib (ctob/parse-decoded-json decoded-json file-name) :unknown-tokens (ctob/get-tokens-of-unknown-type decoded-json {})} (catch js/Error e - (let [err (or (extract-name-error e) + (let [err (or (extract-error-with-code e) + (extract-name-error e) (wte/error-ex-info :error.import/invalid-json-data decoded-json e))] (throw err))))) diff --git a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs index 4de3430986..749bdd8bf4 100644 --- a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs +++ b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs @@ -203,7 +203,22 @@ (remove-path path paths) (add-path path paths)))))))) - +(defn toggle-nested-token-path + [token-type new-name] + (ptk/reify ::toggle-nested-token-path + ptk/UpdateEvent + (update [_ state] + (let [type-str (name token-type) + segments (str/split new-name ".") + n-groups (dec (count segments))] + (if (pos? n-groups) + (update-in state [:workspace-tokens :folded-token-paths] + (fn [paths] + (reduce (fn [ps i] + (remove-path (str type-str "." (str/join "." (take i segments))) ps)) + (or paths []) + (range 1 (inc n-groups))))) + state))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; TOKENS Actions @@ -523,7 +538,8 @@ token))] (rx/of (dch/commit-changes changes) - (ptk/data-event ::ev/event {::ev/name "create-token" :type token-type}))) + (ev/event (-> {::ev/name "create-token" :type token-type} + (merge (meta it)))))) (rx/of (create-token-with-set token))))))) @@ -581,8 +597,10 @@ (pcb/set-token (ctob/get-id token-set) id token'))] + (toggle-token-path (str (name token-type) "." (:name token))) (rx/of (dch/commit-changes changes) - (ptk/data-event ::ev/event {::ev/name "edit-token" :type token-type}))))))) + (ev/event (-> {::ev/name "edit-token" :type token-type} + (merge (meta it)))))))))) (defn bulk-update-tokens [set-id token-ids type old-path new-path] @@ -631,7 +649,8 @@ (pcb/with-library-data data) (pcb/set-token set-id token-id nil))] (rx/of (dch/commit-changes changes) - (ptk/data-event ::ev/event {::ev/name "delete-token" :type token-type})))))) + (ev/event (-> {::ev/name "delete-token" :type token-type} + (merge (meta it))))))))) (defn bulk-delete-tokens [set-id token-ids] @@ -647,7 +666,7 @@ (pcb/with-library-data data)) token-ids)] (rx/of (dch/commit-changes changes) - (ptk/data-event ::ev/event {::ev/name "delete-token-node"})))))) + (ev/event {::ev/name "delete-token-node"})))))) (defn duplicate-token [token-id] diff --git a/frontend/src/app/main/data/workspace/tokens/propagation.cljs b/frontend/src/app/main/data/workspace/tokens/propagation.cljs index d20b095e98..42e642571f 100644 --- a/frontend/src/app/main/data/workspace/tokens/propagation.cljs +++ b/frontend/src/app/main/data/workspace/tokens/propagation.cljs @@ -195,9 +195,20 @@ (rx/of (-> (ts/resolve-tokens tokens-tree) (d/update-vals #(update % :resolved-value ts/tokenscript-symbols->penpot-unit)))) (sd/resolve-tokens tokens-tree)) - (rx/mapcat (fn [sd-tokens] - (let [undo-id (js/Symbol)] - (rx/concat - (rx/of (dwu/start-undo-transaction undo-id :timeout false)) - (propagate-tokens state sd-tokens) - (rx/of (dwu/commit-undo-transaction undo-id))))))))))) + (rx/mapcat + (fn [sd-tokens] + (let [undo-id (js/Symbol)] + (rx/concat + (rx/of (dwu/start-undo-transaction undo-id :timeout false)) + + ;; FIXME: now the tokens propagations is done by accumulating the update-shapes + ;; into a single commit-changes. This is not really the best way, the token application + ;; should be done with a changes_builder and sending only one `commit-changes` instead + ;; of creating lots of `update-shapes`. + (rx/of (dwsh/update-shapes-buffer-start)) + (->> (propagate-tokens state sd-tokens) + (rx/catch #(rx/concat + (rx/of (dwsh/update-shapes-buffer-stop)) + (rx/throw %)))) + (rx/of (dwsh/update-shapes-buffer-stop)) + (rx/of (dwu/commit-undo-transaction undo-id))))))))))) diff --git a/frontend/src/app/main/data/workspace/tokens/warnings.cljs b/frontend/src/app/main/data/workspace/tokens/warnings.cljs index f59047e600..6f3d4161aa 100644 --- a/frontend/src/app/main/data/workspace/tokens/warnings.cljs +++ b/frontend/src/app/main/data/workspace/tokens/warnings.cljs @@ -12,11 +12,11 @@ (def warning-codes {:warning.style-dictionary/invalid-referenced-token-value-opacity {:warning/code :warning.style-dictionary/invalid-referenced-token-value-opacity - :warning/fn (fn [value] (str/join "\n" [(str (tr "workspace.tokens.resolved-value" value) ".") (tr "workspace.tokens.opacity-range")]))} + :warning/fn (fn [value] (str/join "\n" [(str (tr "workspace.tokens.resolved-value" value) ".") (tr "errors.tokens.opacity-range")]))} :warning.style-dictionary/invalid-referenced-token-value-stroke-width {:warning/code :warning.style-dictionary/invalid-referenced-token-value-stroke-width - :warning/fn (fn [value] (str/join "\n" [(str (tr "workspace.tokens.resolved-value" value) ".") (tr "workspace.tokens.stroke-width-range")]))} + :warning/fn (fn [value] (str/join "\n" [(str (tr "workspace.tokens.resolved-value" value) ".") (tr "errors.tokens.stroke-width-range")]))} :warning/unknown {:warning/code :warning/unknown diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index 58982b33f0..1dcf6a2688 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -33,6 +33,7 @@ [app.main.data.workspace.collapse :as dwc] [app.main.data.workspace.modifiers :as dwm] [app.main.data.workspace.selection :as dws] + [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.undo :as dwu] [app.main.features :as features] [app.main.snap :as snap] @@ -137,9 +138,12 @@ (ptk/reify ::finish-transform ptk/UpdateEvent (update [_ state] - (-> state - (update :workspace-local dissoc :transform :duplicate-move-started?) - (dissoc :workspace-selrect :workspace-wasm-modifiers))))) + (update state :workspace-local dissoc :transform :duplicate-move-started?)) + + ptk/EffectEvent + (effect [_ _ _] + (rx/push! ms/wasm-modifiers nil) + (rx/push! ms/workspace-selrect nil)))) ;; -- Resize -------------------------------------------------------- @@ -373,7 +377,7 @@ "Change size of shapes, from the sidebar options form (will ignore pixel snap)" ([ids attr value] (update-dimensions ids attr value nil)) - ([ids attr value options] + ([ids attr value {:keys [no-wasm?] :as options}] (assert (number? value)) (assert (every? uuid? ids) "expected valid coll of uuids") @@ -388,7 +392,7 @@ (get state :current-page-id)) objects - (dsh/lookup-page-objects state page-id) + (dwsh/lookup-changed-objects state page-id) get-modifier (fn [shape] @@ -408,7 +412,7 @@ modif-tree (dwm/build-modif-tree ids objects get-modifier)] - (if (features/active-feature? state "render-wasm/v1") + (if (and (features/active-feature? state "render-wasm/v1") (not no-wasm?)) (rx/of (dwm/apply-wasm-modifiers modif-tree (assoc options :ignore-snap-pixel true))) (let [modif-tree (gm/set-objects-modifiers modif-tree objects)] @@ -532,11 +536,11 @@ "Rotate shapes a fixed angle, from a keyboard action." ([ids rotation] (increase-rotation ids rotation nil)) - ([ids rotation {:keys [center delta?] :as params} & {:as options}] + ([ids rotation {:keys [center delta?] :as params} & {:keys [no-wasm?] :as options}] (ptk/reify ::increase-rotation ptk/WatchEvent (watch [_ state _] - (if (features/active-feature? state "render-wasm/v1") + (if (and (features/active-feature? state "render-wasm/v1") (not no-wasm?)) (let [objects (dsh/lookup-page-objects state) get-modifier @@ -558,6 +562,7 @@ (rx/concat (rx/of (dwm/set-delta-rotation-modifiers rotation shapes (assoc params :page-id page-id))) (rx/of (dwm/apply-modifiers options))))))))) + ;; -- Move ---------------------------------------------------------- (declare start-move) @@ -699,7 +704,21 @@ (rx/map #(array pos %)))))))] (if (empty? shapes) (rx/of (finish-transform)) - (let [move-stream + ;; Per-gesture caches: `shapes`/`objects`/`libraries` are + ;; stable for the gesture, so build once and thread through. + (let [parent-validation-cache + (ctn/parent-validation-cache objects shapes libraries) + + subtree-ids-by-id + (into {} + (map (fn [id] + [id (cfh/get-children-ids-with-self objects id)])) + ids) + + selection-rect-cache + (volatile! nil) + + move-stream (->> position ;; We ask for the snap position but we continue even if the result is not available (rx/with-latest-from snap-delta) @@ -714,14 +733,15 @@ (let [position (gpt/add from-position move-vector) exclude-frames (if mod? exclude-frames exclude-frames-siblings) target-frame (ctst/top-nested-frame objects position exclude-frames) - [target-frame _] (ctn/find-valid-parent-and-frame-ids target-frame objects shapes false libraries) + [target-frame _] (ctn/find-valid-parent-and-frame-ids target-frame objects shapes false libraries parent-validation-cache) flex-layout? (ctl/flex-layout? objects target-frame) grid-layout? (ctl/grid-layout? objects target-frame) drop-index (when flex-layout? (gslf/get-drop-index target-frame objects position)) cell-data (when (and grid-layout? (not mod?)) (get-drop-cell target-frame objects position))] (array move-vector target-frame drop-index cell-data)))) - (rx/take-until stopper)) + (rx/take-until stopper) + (rx/share)) modifiers-stream (->> move-stream @@ -761,9 +781,15 @@ (rx/merge (->> modifiers-stream (rx/take-until duplicate-stopper) + ;; Sample at a fixed cadence to keep preview smooth. Unlike a throttle, + ;; this tends to avoid perceptible "jumps" while still capping WASM work. + (rx/sample 16) (rx/map (fn [[modifiers snap-ignore-axis]] - (dwm/set-wasm-modifiers modifiers :snap-ignore-axis snap-ignore-axis)))) + (dwm/set-wasm-modifiers modifiers + :snap-ignore-axis snap-ignore-axis + :subtree-ids-by-id subtree-ids-by-id + :selection-rect-cache selection-rect-cache)))) (->> move-stream (rx/with-latest-from ms/mouse-position-alt) @@ -788,7 +814,8 @@ (dwu/start-undo-transaction undo-id) (dwm/apply-wasm-modifiers modifiers :snap-ignore-axis snap-ignore-axis - :undo-transation? false) + :undo-transation? false + :subtree-ids-by-id subtree-ids-by-id) (move-shapes-to-frame ids target-frame drop-index drop-cell) (finish-transform) (dwu/commit-undo-transaction undo-id)))))))) @@ -1028,7 +1055,7 @@ The position is a map that can have a partial position (it means it can receive {:x 10}." ([id position] (update-position id position nil)) - ([id position options] + ([id position {:keys [no-wasm?] :as options}] (assert (uuid? id) "expected a valid uuid for `id`") (assert (map? position) "expected a valid map for `position`") @@ -1048,7 +1075,7 @@ delta (calculate-delta position bbox frame) modifiers (dwm/create-modif-tree [id] (ctm/move-modifiers delta))] - (if (features/active-feature? state "render-wasm/v1") + (if (and (features/active-feature? state "render-wasm/v1") (not no-wasm?)) (rx/of (dwm/apply-wasm-modifiers modifiers {:ignore-constraints false :ignore-touched (:ignore-touched options) diff --git a/frontend/src/app/main/data/workspace/undo.cljs b/frontend/src/app/main/data/workspace/undo.cljs index 2296aed447..c8e7c678bd 100644 --- a/frontend/src/app/main/data/workspace/undo.cljs +++ b/frontend/src/app/main/data/workspace/undo.cljs @@ -62,7 +62,16 @@ [:undo-group ::sm/uuid] [:tags [:set :keyword]] [:selected-before {:optional true} [:maybe [:set ::sm/uuid]]] - [:selected-after {:optional true} [:maybe [:set ::sm/uuid]]]]) + [:selected-after {:optional true} [:maybe [:set ::sm/uuid]]] + ;; When the entry was pushed onto the stack; used by the actions + ;; history panel to show a relative timestamp next to each entry. + ;; Issue #7660. + [:timestamp {:optional true} [:maybe some?]] + ;; Display name of the profile that created the entry. The undo + ;; stack is client-side per profile, so this is always the current + ;; user; still stored explicitly so the UI does not need to reach + ;; into profile state while rendering. Issue #7660. + [:by {:optional true} [:maybe :string]]]) (def check-undo-entry (sm/check-fn schema:undo-entry)) @@ -87,6 +96,26 @@ (update [_ state] (update state :workspace-undo assoc :index index)))) +(defn- profile-display-name + "Best-effort display name for the current profile. Prefers the full + name, falls back to the email, and finally to nil so the UI can + simply skip the 'by …' suffix when we have nothing useful to show. + Issue #7660." + [state] + (let [profile (get state :profile)] + (or (:fullname profile) + (:email profile)))) + +(defn- stamp-entry + "Attach creation metadata to an undo entry. We only stamp the timestamp + and author when they are missing so already-enriched entries (e.g. + coming from an accumulated transaction that was opened earlier) keep + their original creation time and attribution. Issue #7660." + [state entry] + (cond-> entry + (nil? (:timestamp entry)) (assoc :timestamp (ct/now)) + (nil? (:by entry)) (assoc :by (profile-display-name state)))) + (defn- add-undo-entry [state entry] (if (and entry @@ -95,7 +124,7 @@ (let [index (get-in state [:workspace-undo :index] -1) items (get-in state [:workspace-undo :items] []) items (->> items (take (inc index)) (into [])) - items (conj-undo-entry items entry)] + items (conj-undo-entry items (stamp-entry state entry))] (-> state (update :workspace-undo assoc :items items :index (min (inc index) @@ -104,7 +133,9 @@ (defn- stack-undo-entry "Extends the current undo entry in the workspace with new changes if it - exists, or creates a new entry if it doesn't." + exists, or creates a new entry if it doesn't. When stacking onto an + existing entry, the entry's original timestamp is preserved so the + history panel keeps showing when the action originated. Issue #7660." [state {:keys [undo-changes redo-changes selected-after] :as entry}] (let [index (get-in state [:workspace-undo :index] -1)] (if (>= index 0) diff --git a/frontend/src/app/main/data/workspace/variants.cljs b/frontend/src/app/main/data/workspace/variants.cljs index b21afdc8b7..fe05919fae 100644 --- a/frontend/src/app/main/data/workspace/variants.cljs +++ b/frontend/src/app/main/data/workspace/variants.cljs @@ -616,7 +616,7 @@ [ids {:keys [page-id trigger variant-id]}] (ptk/reify ::combine-as-variants ptk/WatchEvent - (watch [_ state stream] + (watch [it state stream] (let [current-page (:current-page-id state) combine @@ -665,7 +665,8 @@ (dwsh/relocate-shapes #{variant-id} common-parent index) (dwt/update-dimensions [variant-id] :width (+ (:width rect) 60)) (dwt/update-dimensions [variant-id] :height (+ (:height rect) 60)) - (ev/event {::ev/name "combine-as-variants" ::ev/origin trigger :number-of-combined (count ids)})) + (ev/event (-> {::ev/name "combine-as-variants" ::ev/origin trigger :number-of-combined (count ids)} + (merge (meta it))))) ;; NOTE: we need to schedule a commit into a ;; microtask for ensure that all the scheduled @@ -701,7 +702,7 @@ [shape {:keys [pos val] :as params}] (ptk/reify ::variant-switch ptk/WatchEvent - (watch [_ state _] + (watch [it state _] (let [libraries (dsh/lookup-libraries state) component-id (:component-id shape) component (ctf/get-component libraries (:component-file shape) component-id :include-deleted? false)] @@ -735,20 +736,23 @@ (rx/empty)) (rx/of (dwl/component-swap shape (:component-file shape) (:id nearest-comp) true) - (ev/event {::ev/name "variant-switch" ::ev/origin "workspace:design-tab"})))))))))) + (ev/event (-> {::ev/name "variant-switch" ::ev/origin "workspace:design-tab"} + (merge (meta it))))))))))))) (defn variants-switch "Switch each shape (that must be a variant copy head) for the closest one with the property value passed as parameter" [{:keys [shapes] :as params}] (ptk/reify ::variants-switch ptk/WatchEvent - (watch [_ _ _] + (watch [it _ _] (let [ids (into (d/ordered-set) d/xf:map-id shapes) undo-id (js/Symbol)] (rx/concat (rx/of (dwu/start-undo-transaction undo-id)) (->> (rx/from shapes) - (rx/map #(variant-switch % params))) + (rx/map (fn [data] + (-> (variant-switch data params) + (with-meta (meta it)))))) (rx/of (dwu/commit-undo-transaction undo-id) (dws/select-shapes ids))))))) diff --git a/frontend/src/app/main/data/workspace/versions.cljs b/frontend/src/app/main/data/workspace/versions.cljs index 85630cfccb..67b10ca760 100644 --- a/frontend/src/app/main/data/workspace/versions.cljs +++ b/frontend/src/app/main/data/workspace/versions.cljs @@ -8,23 +8,28 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.logging :as log] [app.common.schema :as sm] [app.common.time :as ct] [app.main.data.event :as ev] + [app.main.data.helpers :as dsh] [app.main.data.notifications :as ntf] [app.main.data.persistence :as dwp] [app.main.data.workspace :as dw] [app.main.data.workspace.pages :as dwpg] [app.main.data.workspace.thumbnails :as th] + [app.main.features :as features] [app.main.refs :as refs] [app.main.repo :as rp] + [app.util.i18n :refer [tr]] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) (defonce default-state {:status :loading :data nil - :editing nil}) + :editing nil + :preview-id nil}) (declare fetch-versions) @@ -66,7 +71,7 @@ ;; Force persist before creating snapshot, otherwise we could loss changes (rx/concat (rx/of ::dwp/force-persist - (ptk/event ::ev/event {::ev/name "create-version"})) + (ev/event {::ev/name "create-version"})) (->> (rx/from-atom refs/persistence-state {:emit-current-value? true}) (rx/filter #(or (nil? %) (= :saved %))) @@ -88,8 +93,8 @@ (let [file-id (:current-file-id state)] (rx/merge (rx/of (update-versions-state {:editing nil}) - (ptk/event ::ev/event {::ev/name "rename-version" - :file-id file-id})) + (ev/event {::ev/name "rename-version" + :file-id file-id})) (->> (rp/cmd! :update-file-snapshot {:id id :label label}) (rx/map fetch-versions))))))) @@ -122,32 +127,6 @@ (rx/take 1) (rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id snapshot-id})))) -(defn restore-version - [id origin] - (assert (uuid? id) "expected valid uuid for `id`") - (ptk/reify ::restore-version - ptk/WatchEvent - (watch [_ state _] - (let [file-id (:current-file-id state) - team-id (:current-team-id state) - event-name (case origin - :version "restore-pin-version" - :snapshot "restore-autosave" - :plugin "restore-version-plugin")] - - (rx/concat - (rx/of ::dwp/force-persist - (dw/remove-layout-flag :document-history)) - - (->> (wait-for-persistence file-id id) - (rx/map #(initialize-version))) - - (if event-name - (rx/of (ev/event {::ev/name event-name - :file-id file-id - :team-id team-id})) - (rx/empty))))))) - (defn delete-version [id] (assert (uuid? id) "expected valid uuid for `id`") @@ -173,7 +152,7 @@ (rx/mapcat (fn [_] (rx/of (update-versions-state {:editing id}) (fetch-versions) - (ptk/event ::ev/event {::ev/name "pin-version"})))))))))) + (ev/event {::ev/name "pin-version"})))))))))) (defn lock-version [id] @@ -193,6 +172,151 @@ (->> (rp/cmd! :unlock-file-snapshot {:id id}) (rx/map fetch-versions))))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; RESTORE VERSION EVENTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- restore-version + [id] + (assert (uuid? id) "expected valid uuid for `id`") + (ptk/reify ::restore-version + ptk/UpdateEvent + (update [_ state] + ;; Clear preview state if we're restoring from preview mode + (-> state + (update :workspace-versions dissoc :backup) + (update :workspace-global dissoc :read-only? :preview-id))) + ptk/WatchEvent + (watch [_ state _] + (let [file-id (:current-file-id state)] + (rx/concat + (rx/of ::dwp/force-persist + (dw/remove-layout-flag :document-history)) + + (->> (wait-for-persistence file-id id) + (rx/map #(initialize-version)))))))) + +(defn enter-restore + [id] + (assert (uuid? id) "expected valid uuid for `id`") + (ptk/reify ::enter-restore + ptk/WatchEvent + (watch [_ _ _] + (let [output-s (rx/subject)] + (rx/merge + output-s + (rx/of (ntf/dialog + :content (tr "workspace.versions.restore-warning") + :controls :inline-actions + :cancel {:label (tr "workspace.updates.dismiss") + :callback #(do + (rx/push! output-s (ntf/hide :tag :restore-dialog)) + (rx/end! output-s))} + :accept {:label (tr "labels.restore") + :callback #(do + (rx/push! output-s (restore-version id)) + (rx/end! output-s))} + :tag :restore-dialog))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; PREVIEW VERSION EVENTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- apply-snapshot + "Swap the file data in app state with the provided snapshot-file + response. Used by the version preview feature to show historical + file content without modifying the database" + [{:keys [id] :as snapshot}] + (ptk/reify ::apply-snapshot-data + ptk/UpdateEvent + (update [_ state] + (update state :files assoc id snapshot)))) + +(defn exit-preview + "Exit from preview mode and reload the live file data" + [] + (ptk/reify ::exit-preview + ptk/UpdateEvent + (update [_ state] + (let [backup (dm/get-in state [:workspace-versions :backup])] + (-> state + (update :workspace-versions dissoc :backup) + (update :workspace-global dissoc :read-only? :preview-id) + (update :files assoc (:id backup) backup)))) + + ptk/WatchEvent + (watch [_ state _] + (let [file-id (:current-file-id state) + page-id (:current-page-id state)] + + (rx/of (dwpg/initialize-page file-id page-id)))))) + +(defn enter-preview + "Load a snapshot into the workspace for read-only preview without + modifying any database state. Sets a read-only flag so no changes + are persisted while previewing and enter on the preview mode" + [id] + (assert (uuid? id) "expected valid uuid for `id`") + + (ptk/reify ::enter-preview + ptk/UpdateEvent + (update [_ state] + (let [file (dsh/lookup-file state)] + (-> state + (update :workspace-versions assoc :backup file) + (update :workspace-global assoc :read-only? true :preview-id id)))) + + ptk/WatchEvent + (watch [_ state _] + (let [file-id (:current-file-id state) + page-id (:current-page-id state) + team-id (:current-team-id state) + features (features/get-enabled-features state team-id) + snapshot (->> (dm/get-in state [:workspace-versions :data]) + (d/seek #(= id (:id %)))) + label (or (:label snapshot) + (tr "workspace.versions.preview.unnamed")) + output-s (rx/subject)] + (rx/merge + output-s + + (rx/of (ntf/dialog + :content (tr "workspace.versions.preview-banner-title" label) + :controls :inline-actions + :cancel {:label (tr "labels.exit") + :callback #(do + (rx/push! output-s (ntf/hide)) + (rx/push! output-s (exit-preview)) + (rx/end! output-s))} + :accept {:label (tr "labels.restore") + :callback #(do + (rx/push! output-s (ntf/hide)) + (rx/push! output-s (restore-version id)) + (rx/end! output-s))} + :tag :preview-dialog)) + + (->> (rp/cmd! :get-file-snapshot + {:file-id file-id + :id id + :features features}) + (rx/mapcat + (fn [snapshot] + (rx/of + ;; Swap the file data in state with snapshot content. + ;; Passing id sets workspace-file-version-id, which + ;; causes the WASM viewport to reload its shape buffer. + (apply-snapshot snapshot) + ;; Re-initialize the page to rebuild its search index + ;; and page-local state with the new snapshot + ;; objects. + (dwpg/initialize-page file-id page-id)))) + + (rx/catch (fn [err] + ;; On error roll back the read-only flag so the + ;; user is not stuck in a broken preview state. + (log/error :hint "failed to load snapshot" :cause err :file-id file-id :snapshot-id id) + (rx/of (exit-preview)))))))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; PLUGINS SPECIFIC EVENTS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -215,9 +339,6 @@ (let [current-file-id (:current-file-id state)] ;; Force persist before creating snapshot, otherwise we could loss changes (->> (rx/concat - (rx/of (ptk/event ::ev/event {::ev/origin "plugins" - ::ev/name "create-version"})) - (when (= file-id current-file-id) (rx/of ::dwp/force-persist)) @@ -241,26 +362,28 @@ (rx/empty)))))))) (defn restore-version-from-plugin - [file-id id resolve _reject] + [file-id id resolve reject] (assert (uuid? id) "expected valid uuid for `id`") (ptk/reify ::restore-version-from-plugins ptk/WatchEvent - (watch [_ state _] - (let [team-id (:current-team-id state)] - (rx/concat - (rx/of (ev/event {::ev/name "restore-version-plugin" - :file-id file-id - :team-id team-id}) - ::dwp/force-persist) + (watch [_ _ _] + (->> (rx/concat + (rx/of (ev/event {::ev/name "restore-version" + ::ev/origin "plugins"}) + ::dwp/force-persist) - (->> (wait-for-persistence file-id id) - (rx/map #(initialize-version))) - - (->> (rx/of 1) - (rx/tap resolve) - (rx/ignore))))))) + (->> (wait-for-persistence file-id id) + (rx/map #(initialize-version))) + (->> (rx/of 1) + (rx/tap resolve) + (rx/ignore))) + + ;; On error reject the promise and empty the stream + (rx/catch (fn [error] + (reject error) + (rx/empty))))))) diff --git a/frontend/src/app/main/data/workspace/viewport.cljs b/frontend/src/app/main/data/workspace/viewport.cljs index 2687bb9113..67fb95eb7f 100644 --- a/frontend/src/app/main/data/workspace/viewport.cljs +++ b/frontend/src/app/main/data/workspace/viewport.cljs @@ -16,10 +16,20 @@ [app.common.math :as mth] [app.main.data.event :as ev] [app.main.data.helpers :as dsh] + [app.main.data.workspace.viewport-wasm :as dwvw] [app.util.mouse :as mse] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) +(defn sync-wasm-workspace-viewport + "Effect-only: pushes the current workspace zoom/view box to WASM after other + events (e.g. `update-viewport-size`) have updated the store." + [] + (ptk/reify ::sync-wasm-workspace-viewport + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state)))) + (defn initialize-viewport [{:keys [width height] :as size}] @@ -82,7 +92,11 @@ (update [_ state] (update state :workspace-local (fn [local] - (setup state local))))))) + (setup state local)))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state))))) (defn calculate-centered-viewbox "Updates the viewbox coordinates for a given center position" @@ -101,7 +115,13 @@ (ptk/reify ::update-viewport-position-center ptk/UpdateEvent (update [_ state] - (update state :workspace-local calculate-centered-viewbox position)))) + (if (dwvw/render-context-lost? state) + state + (update state :workspace-local calculate-centered-viewbox position))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state)))) (defn update-viewport-position [{:keys [x y] :or {x identity y identity}}] @@ -118,11 +138,17 @@ ptk/UpdateEvent (update [_ state] - (update-in state [:workspace-local :vbox] - (fn [vbox] - (-> vbox - (update :x x) - (update :y y))))))) + (if (dwvw/render-context-lost? state) + state + (update-in state [:workspace-local :vbox] + (fn [vbox] + (-> vbox + (update :x x) + (update :y y)))))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state)))) (defn update-viewport-size [resize-type {:keys [width height] :as size}] @@ -166,23 +192,35 @@ (assoc-in [:vbox :width] vbox-width') (assoc-in [:vbox :height] vbox-height'))))))))) +(defn- activate-panning [] + (ptk/reify ::activate-panning + ptk/UpdateEvent + (update [_ state] + (-> state + (assoc-in [:workspace-local :panning] true))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-view-interaction-start! state)))) + (defn start-panning [] (ptk/reify ::start-panning ptk/WatchEvent (watch [_ state stream] (let [stopper (->> stream (rx/filter (ptk/type? ::finish-panning))) zoom (get-in state [:workspace-local :zoom])] - (when-not (get-in state [:workspace-local :panning]) + (when (and (not (dwvw/render-context-lost? state)) + (not (get-in state [:workspace-local :panning]))) (rx/concat - (rx/of #(-> % (assoc-in [:workspace-local :panning] true))) + (rx/of (activate-panning)) (->> stream (rx/filter mse/pointer-event?) - (rx/filter #(= :delta (:source %))) + (rx/filter #(some? (mse/get-pointer-movement %))) (rx/take-until stopper) ;; Some events are executed in synchronous way like panning with backspace pressed (rx/observe-on :af) (rx/map (fn [event] - (let [delta (dm/get-prop event :pt)] + (let [delta (mse/get-pointer-movement event)] (update-viewport-position {:x #(- % (/ (:x delta) zoom)) :y #(- % (/ (:y delta) zoom))}))))))))))) @@ -191,4 +229,8 @@ ptk/UpdateEvent (update [_ state] (-> state - (update :workspace-local dissoc :panning))))) + (update :workspace-local dissoc :panning))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-view-interaction-end! state)))) diff --git a/frontend/src/app/main/data/workspace/viewport_wasm.cljs b/frontend/src/app/main/data/workspace/viewport_wasm.cljs new file mode 100644 index 0000000000..4115589ab0 --- /dev/null +++ b/frontend/src/app/main/data/workspace/viewport_wasm.cljs @@ -0,0 +1,30 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.data.workspace.viewport-wasm + (:require + [app.main.features :as features] + [app.render-wasm.api :as wasm.api])) + +(defn render-context-lost? + [state] + (true? (get-in state [:render-state :lost]))) + +(defn maybe-sync-workspace-local-viewport! + "When `render-wasm/v1` is active, pushes workspace zoom and vbox into WASM." + [state] + (when (and (features/active-feature? state "render-wasm/v1") (not (render-context-lost? state))) + (wasm.api/sync-workspace-local-viewport! state))) + +(defn maybe-view-interaction-start! + [state] + (when (and (features/active-feature? state "render-wasm/v1") (not (render-context-lost? state))) + (wasm.api/view-interaction-start!))) + +(defn maybe-view-interaction-end! + [state] + (when (and (features/active-feature? state "render-wasm/v1") (not (render-context-lost? state))) + (wasm.api/view-interaction-end!))) \ No newline at end of file diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index 7effaef13e..9a18d0d83a 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -17,6 +17,7 @@ [app.common.types.modifiers :as ctm] [app.main.data.helpers :as dsh] [app.main.data.workspace.modifiers :as dwm] + [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.undo :as dwu] [app.render-wasm.api :as wasm.api] [app.render-wasm.api.fonts :as wasm.fonts] @@ -180,20 +181,31 @@ (let [font-data (wasm.fonts/make-font-data font)] (wasm.fonts/font-stored? font-data (:emoji? font-data))))))] - (if (not fonts-loaded?) - (->> (rx/of (resize-wasm-text-debounce id opts)) - (rx/delay 20)) + (if fonts-loaded? (let [pass-opts (when (or (some? undo-group) (some? undo-id)) (cond-> {} (some? undo-group) (assoc :undo-group undo-group) (some? undo-id) (assoc :undo-id undo-id)))] - (rx/of (resize-wasm-text-debounce-inner id pass-opts))))))))) + (rx/of (resize-wasm-text-debounce-inner id pass-opts))) + + ;; Fonts not loaded; retry after 20 msecs + (->> (rx/of (resize-wasm-text-debounce id opts)) + (rx/delay 20)))))))) (defn resize-wasm-text-all "Resize all text shapes (auto-width/auto-height) from a collection of ids." [ids] (ptk/reify ::resize-wasm-text-all ptk/WatchEvent - (watch [_ _ _] - (->> (rx/from ids) - (rx/map resize-wasm-text-debounce))))) + (watch [_ state stream] + (let [resize-stream + (->> (rx/from ids) + (rx/map resize-wasm-text-debounce))] + (if (::dwsh/update-shapes-buffer state) + ;; If we're in the middle of a token propagation we wait until is finished to + ;; recalculate the text sizes + (->> stream + (rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit)) + (rx/take 1) + (rx/mapcat (constantly resize-stream))) + resize-stream))))) diff --git a/frontend/src/app/main/data/workspace/zoom.cljs b/frontend/src/app/main/data/workspace/zoom.cljs index 33f1846407..91aa48ad89 100644 --- a/frontend/src/app/main/data/workspace/zoom.cljs +++ b/frontend/src/app/main/data/workspace/zoom.cljs @@ -16,6 +16,7 @@ [app.common.geom.shapes :as gsh] [app.main.data.event :as ev] [app.main.data.helpers :as dsh] + [app.main.data.workspace.viewport-wasm :as dwvw] [app.main.streams :as ms] [app.util.mouse :as mse] [beicon.v2.core :as rx] @@ -43,9 +44,15 @@ ptk/UpdateEvent (update [_ state] - (let [center (if (= center ::auto) @ms/mouse-position center)] - (update state :workspace-local - #(impl-update-zoom % center (fn [z] (min (* z 1.3) 200))))))))) + (if (dwvw/render-context-lost? state) + state + (let [center (if (= center ::auto) @ms/mouse-position center)] + (update state :workspace-local + #(impl-update-zoom % center (fn [z] (min (* z 1.3) 200))))))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state))))) (defn decrease-zoom ([] @@ -56,9 +63,15 @@ ptk/UpdateEvent (update [_ state] - (let [center (if (= center ::auto) @ms/mouse-position center)] - (update state :workspace-local - #(impl-update-zoom % center (fn [z] (max (/ z 1.3) 0.01))))))))) + (if (dwvw/render-context-lost? state) + state + (let [center (if (= center ::auto) @ms/mouse-position center)] + (update state :workspace-local + #(impl-update-zoom % center (fn [z] (max (/ z 1.3) 0.01))))))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state))))) (defn set-zoom ([scale] @@ -69,68 +82,92 @@ ptk/UpdateEvent (update [_ state] - (let [vp (dm/get-in state [:workspace-local :vbox]) - x (+ (:x vp) (/ (:width vp) 2)) - y (+ (:y vp) (/ (:height vp) 2)) - center (d/nilv center (gpt/point x y))] - (update state :workspace-local - #(impl-update-zoom % center (fn [z] (-> (* z scale) - (max 0.01) - (min 200)))))))))) + (if (dwvw/render-context-lost? state) + state + (let [vp (dm/get-in state [:workspace-local :vbox]) + x (+ (:x vp) (/ (:width vp) 2)) + y (+ (:y vp) (/ (:height vp) 2)) + center (d/nilv center (gpt/point x y))] + (update state :workspace-local + #(impl-update-zoom % center (fn [z] (-> (* z scale) + (max 0.01) + (min 200)))))))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state))))) (def reset-zoom (ptk/reify ::reset-zoom ptk/UpdateEvent (update [_ state] - (update state :workspace-local - #(impl-update-zoom % nil 1))))) + (if (dwvw/render-context-lost? state) + state + (update state :workspace-local + #(impl-update-zoom % nil 1)))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state)))) (def zoom-to-fit-all (ptk/reify ::zoom-to-fit-all ptk/UpdateEvent (update [_ state] - (let [page-id (:current-page-id state) - objects (dsh/lookup-page-objects state page-id) - shapes (cfh/get-immediate-children objects) - srect (gsh/shapes->rect shapes)] - (if (empty? shapes) - state - (update state :workspace-local - (fn [{:keys [vport] :as local}] - (let [srect (gal/adjust-to-viewport vport srect {:padding 160 :min-zoom 0.01}) - zoom (/ (:width vport) (:width srect))] - (-> local - (assoc :zoom zoom) - (assoc :zoom-inverse (/ 1 zoom)) - (update :vbox merge srect)))))))))) + (if (dwvw/render-context-lost? state) + state + (let [page-id (:current-page-id state) + objects (dsh/lookup-page-objects state page-id) + shapes (cfh/get-immediate-children objects) + srect (gsh/shapes->rect shapes)] + (if (empty? shapes) + state + (update state :workspace-local + (fn [{:keys [vport] :as local}] + (let [srect (gal/adjust-to-viewport vport srect {:padding 160 :min-zoom 0.01}) + zoom (/ (:width vport) (:width srect))] + (-> local + (assoc :zoom zoom) + (assoc :zoom-inverse (/ 1 zoom)) + (update :vbox merge srect))))))))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state)))) (def zoom-to-selected-shape (ptk/reify ::zoom-to-selected-shape ptk/UpdateEvent (update [_ state] - (let [selected (dsh/lookup-selected state)] - (if (empty? selected) - state - (let [page-id (:current-page-id state) - objects (dsh/lookup-page-objects state page-id) - srect (->> selected - (map #(get objects %)) - (gsh/shapes->rect))] - (update state :workspace-local - (fn [{:keys [vport] :as local}] - (let [srect (gal/adjust-to-viewport vport srect {:padding 40 :min-zoom 0.01}) - zoom (/ (:width vport) (:width srect))] - (-> local - (assoc :zoom zoom) - (assoc :zoom-inverse (/ 1 zoom)) - (update :vbox merge srect))))))))))) + (if (dwvw/render-context-lost? state) + state + (let [selected (dsh/lookup-selected state)] + (if (empty? selected) + state + (let [page-id (:current-page-id state) + objects (dsh/lookup-page-objects state page-id) + srect (->> selected + (map #(get objects %)) + (gsh/shapes->rect))] + (update state :workspace-local + (fn [{:keys [vport] :as local}] + (let [srect (gal/adjust-to-viewport vport srect {:padding 40 :min-zoom 0.01}) + zoom (/ (:width vport) (:width srect))] + (-> local + (assoc :zoom zoom) + (assoc :zoom-inverse (/ 1 zoom)) + (update :vbox merge srect)))))))))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state)))) (defn fit-to-shapes [ids] (ptk/reify ::fit-to-shapes ptk/UpdateEvent (update [_ state] - (if (empty? ids) + (if (or (dwvw/render-context-lost? state) (empty? ids)) state (let [page-id (:current-page-id state) objects (dsh/lookup-page-objects state page-id) @@ -148,20 +185,26 @@ (-> local (assoc :zoom zoom) (assoc :zoom-inverse (/ 1 zoom)) - (update :vbox merge srect)))))))))) + (update :vbox merge srect)))))))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state)))) (defn start-zooming [pt] (ptk/reify ::start-zooming ptk/WatchEvent (watch [_ state stream] (let [stopper (->> stream (rx/filter (ptk/type? ::finish-zooming)))] - (when-not (get-in state [:workspace-local :zooming]) + (when (and (not (dwvw/render-context-lost? state)) + (not (get-in state [:workspace-local :zooming]))) (rx/concat + (rx/of (fn [s] (dwvw/maybe-view-interaction-start! s) s)) (rx/of #(-> % (assoc-in [:workspace-local :zooming] true))) (->> stream (rx/filter mse/pointer-event?) - (rx/filter #(= :delta (:source %))) - (rx/map :pt) + (rx/filter #(some? (mse/get-pointer-movement %))) + (rx/map mse/get-pointer-movement) (rx/take-until stopper) (rx/map (fn [delta] (let [scale (+ 1 (/ (:y delta) 100))] ;; this number may be adjusted after user testing @@ -172,4 +215,7 @@ ptk/UpdateEvent (update [_ state] (-> state - (update :workspace-local dissoc :zooming))))) + (update :workspace-local dissoc :zooming))) + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-view-interaction-end! state)))) diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index eaaebedff2..4b5a0309f7 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -221,9 +221,11 @@ (when-let [cause (::instance error)] (ex/print-throwable cause) (let [code (get error :code)] - (if (or (= code :panic) - (= code :webgl-context-lost)) + (cond + (= code :panic) (st/emit! (rt/assign-exception error)) + + :else (flash :type :handled :cause cause))))) ;; We receive a explicit authentication error; If the uri is for @@ -352,6 +354,7 @@ (derive :not-found ::exceptional-state) (derive :bad-gateway ::exceptional-state) (derive :service-unavailable ::exceptional-state) +(derive :nitrate-unavailable ::exceptional-state) (defmethod ptk/handle-error ::exceptional-state [error] diff --git a/frontend/src/app/main/features.cljs b/frontend/src/app/main/features.cljs index cc3f23052e..4ee041fd4d 100644 --- a/frontend/src/app/main/features.cljs +++ b/frontend/src/app/main/features.cljs @@ -30,8 +30,10 @@ [features state] (let [params (rt/get-params state) wasm (get params :wasm) - enable-wasm (= "true" wasm) - disable-wasm (= "false" wasm) + renderer (when (contains? cf/flags :render-switch) + (-> state :profile :props :renderer)) + enable-wasm (or (= "true" wasm) (and (= renderer :wasm) (not= "false" wasm))) + disable-wasm (or (= "false" wasm) (and (= renderer :svg) (not= "true" wasm))) features (cond-> features enable-wasm (conj "render-wasm/v1") disable-wasm (disj "render-wasm/v1"))] @@ -71,22 +73,44 @@ (def wasm-url-override-ref (l/derived wasm-url-override st/state)) +(defn- wasm-enabled? + [state] + (let [override (wasm-url-override state) + renderer (when (contains? cf/flags :render-switch) + (-> state :profile :props :renderer))] + (cond + (some? override) + override + + (contains? cf/flags :render-switch) + (case renderer + :wasm true + :svg false + ;; SVG renderer as default until profile data arrives OR if render-switch + ;; flag is disabled. + false) + + (contains? cfeat/no-migration-features "render-wasm/v1") + (enabled-without-migration? state "render-wasm/v1") + + :else + (enabled-by-flags? state "render-wasm/v1")))) + (defn active-feature? "Given a state and feature, check if feature is enabled." [state feature] (assert (contains? cfeat/supported-features feature) "feature not supported") - (let [wasm-override (when (= feature "render-wasm/v1") (wasm-url-override state))] - (cond - (some? wasm-override) - wasm-override + (cond + (= feature "render-wasm/v1") + (wasm-enabled? state) - (contains? cfeat/no-migration-features feature) - (enabled-without-migration? state feature) + (contains? cfeat/no-migration-features feature) + (enabled-without-migration? state feature) - :else - (enabled-by-flags? state feature)))) + :else + (enabled-by-flags? state feature))) (defn active-features? "Given a state and a set of features, check if the features are all enabled." @@ -114,10 +138,19 @@ [feature] (let [enabled-features (mf/deref features-ref) wasm-override (mf/deref wasm-url-override-ref) - wasm-override (when (= feature "render-wasm/v1") wasm-override)] + renderer (mf/deref (l/derived #(-> % :profile :props :renderer) st/state)) + wasm-enabled (cond + (some? wasm-override) + wasm-override + + (contains? cf/flags :render-switch) + (= renderer :wasm) + + :else + (contains? enabled-features "render-wasm/v1"))] (cond - (some? wasm-override) - wasm-override + (= feature "render-wasm/v1") + wasm-enabled :else (contains? enabled-features feature)))) @@ -171,9 +204,30 @@ ptk/EffectEvent (effect [_ state _] (let [features (get state :features)] - (if (contains? features "render-wasm/v1") + (if (active-feature? state "render-wasm/v1") (wasm/initialize true) (wasm/initialize false)) (log/inf :hint "initialized" :enabled (str/join " " features)))))) + +(defn recompute-features + [] + (ptk/reify ::recompute-features + ptk/UpdateEvent + (update [_ state] + (let [previous (or (get state :features) #{}) + features (setup-wasm-features previous state)] + (if (= previous features) + state + (assoc state :features features)))) + + ptk/EffectEvent + (effect [_ state _] + (let [features (get state :features)] + (if (active-feature? state "render-wasm/v1") + (wasm/initialize true) + (wasm/initialize false)) + + (log/inf :hint "recomputed features" + :enabled (str/join " " features)))))) diff --git a/frontend/src/app/main/fonts.cljs b/frontend/src/app/main/fonts.cljs index 56e59667b2..a3df61290e 100644 --- a/frontend/src/app/main/fonts.cljs +++ b/frontend/src/app/main/fonts.cljs @@ -39,6 +39,8 @@ {:id "300italic" :name "300 Italic" :weight "300" :style "italic" :suffix "lightitalic" :ttf-url "sourcesanspro-lightitalic.ttf"} {:id "regular" :name "400" :weight "400" :style "normal" :ttf-url "sourcesanspro-regular.ttf"} {:id "italic" :name "400 Italic" :weight "400" :style "italic" :ttf-url "sourcesanspro-italic.ttf"} + {:id "600" :name "600" :weight "600" :style "normal" :suffix "semibold" :ttf-url "sourcesanspro-semibold.ttf"} + {:id "600italic" :name "600 Italic" :weight "600" :style "italic" :suffix "semibolditalic" :ttf-url "sourcesanspro-semibolditalic.ttf"} {:id "bold" :name "700" :weight "700" :style "normal" :ttf-url "sourcesanspro-bold.ttf"} {:id "bolditalic" :name "700 Italic" :weight "700" :style "italic" :ttf-url "sourcesanspro-bolditalic.ttf"} {:id "black" :name "900" :weight "900" :style "normal" :ttf-url "sourcesanspro-black.ttf"} @@ -151,7 +153,7 @@ (->> (http/send! {:method :get :uri url :mode :cors :response-type :text}) (rx/map :body) (rx/catch (fn [err] - (.warn js/console "Cannot find the font" (obj/get err "message")) + (log/wrn :hint "cannot find the font" :cause err) (rx/empty))))) (defmethod load-font :google diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index 8b65b32fb6..a68f577b6b 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -17,6 +17,8 @@ [app.main.data.helpers :as dsh] [app.main.data.workspace.tokens.selected-set :as dwts] [app.main.store :as st] + [app.main.streams :as ms] + [beicon.v2.core :as rx] [okulary.core :as l])) ;; ---- Global refs @@ -161,7 +163,9 @@ (l/derived :workspace-tokens st/state)) (def workspace-selrect - (l/derived :workspace-selrect st/state)) + (let [a (atom nil)] + (rx/sub! ms/workspace-selrect #(reset! a %)) + a)) ;; WARNING: Don't use directly from components, this is a proxy to ;; improve performance of selected-shapes and @@ -385,7 +389,9 @@ (l/derived :workspace-wasm-editor-styles st/state)) (def workspace-wasm-modifiers - (l/derived :workspace-wasm-modifiers st/state)) + (let [a (atom nil)] + (rx/sub! ms/wasm-modifiers #(reset! a %)) + a)) (def ^:private workspace-modifiers-with-objects (l/derived @@ -582,6 +588,12 @@ (cf/resolve-media))) st/state)) +(defn workspace-thumbnail-rendered-at + [object-id] + (l/derived + #(dm/get-in % [:thumbnails-meta object-id :rendered-at]) + st/state)) + (def workspace-text-modifier (l/derived :workspace-text-modifier st/state)) @@ -651,3 +663,9 @@ (def progress (l/derived :progress st/state)) + +(def access-tokens + (l/derived :access-tokens st/state)) + +(def access-token-created + (l/derived :access-token-created st/state)) diff --git a/frontend/src/app/main/render.cljs b/frontend/src/app/main/render.cljs index a53971c452..b5af6c58c0 100644 --- a/frontend/src/app/main/render.cljs +++ b/frontend/src/app/main/render.cljs @@ -244,7 +244,7 @@ (remove cfh/frame-shape?) (mapcat #(cfh/get-children-with-self objects (:id %)))) fonts (ff/shapes->fonts shapes)] - [:& ff/fontfaces-style {:fonts fonts}]) + [:> ff/fontfaces-style* {:fonts fonts}]) (for [item shapes] [:& shape-wrapper {:shape item @@ -481,7 +481,7 @@ :style {:-webkit-print-color-adjust :exact} :fill "none"} - [:& ff/fontfaces-style {:fonts fonts}] + [:> ff/fontfaces-style* {:fonts fonts}] [:& shape-wrapper {:shape object}]]]])) (mf/defc objects-svg @@ -520,7 +520,7 @@ :xmlnsXlink "http://www.w3.org/1999/xlink" :style {:-webkit-print-color-adjust :exact} :fill "none"} - [:& ff/fontfaces-style {:fonts fonts}] + [:> ff/fontfaces-style* {:fonts fonts}] (for [shape shapes] [:& shape-wrapper {:key (dm/str (:id shape)) :shape shape}])]]])) diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs index 9b5b9872ab..502c5ba655 100644 --- a/frontend/src/app/main/repo.cljs +++ b/frontend/src/app/main/repo.cljs @@ -86,6 +86,10 @@ (= 502 status) (rx/throw (ex-info "http error" {:type :bad-gateway})) + (and (= 503 status) + (= :nitrate-unavailable (:type body))) + (rx/throw (ex-info "http error" {:type :nitrate-unavailable})) + (= 503 status) (rx/throw (ex-info "http error" {:type :service-unavailable})) diff --git a/frontend/src/app/main/router.cljs b/frontend/src/app/main/router.cljs index 405c8b6664..0f3575f61d 100644 --- a/frontend/src/app/main/router.cljs +++ b/frontend/src/app/main/router.cljs @@ -76,8 +76,7 @@ (when send-event-info? (let [route (dm/get-in match [:data :name]) params (get match :query-params)] - (rx/of (ptk/event - ::ev/event + (rx/of (ev/event (assoc params ::ev/name "navigate" :route (name route))))))) diff --git a/frontend/src/app/main/snap.cljs b/frontend/src/app/main/snap.cljs index f485880536..5831a1fa1b 100644 --- a/frontend/src/app/main/snap.cljs +++ b/frontend/src/app/main/snap.cljs @@ -93,23 +93,12 @@ (rx/take 1) (rx/map (remove-from-snap-points remove-snap?))))) -(defn- search-snap - [page-id frame-id points coord remove-snap? zoom] - (let [snap-accuracy (/ snap-accuracy zoom) - ranges (->> points - (map coord) - (mapv #(vector (- % snap-accuracy) - (+ % snap-accuracy)))) - vbox @refs/vbox] - (->> (mw/ask! {:cmd :index/query-snap - :page-id page-id - :frame-id frame-id - :axis coord - :bounds vbox - :ranges ranges}) - (rx/take 1) - (rx/map (remove-from-snap-points remove-snap?)) - (rx/map (get-min-distance-snap points coord))))) +(defn- snap-accuracy-ranges + [points coord zoom] + (let [acc (/ snap-accuracy zoom)] + (->> points + (map coord) + (mapv #(vector (- % acc) (+ % acc)))))) (defn snap->vector [[[from-x to-x] [from-y to-y]]] (when (or from-x to-x from-y to-y) @@ -119,10 +108,21 @@ (defn- closest-snap [page-id frame-id points remove-snap? zoom] - (let [snap-x (search-snap page-id frame-id points :x remove-snap? zoom) - snap-y (search-snap page-id frame-id points :y remove-snap? zoom)] - (->> (rx/combine-latest snap-x snap-y) - (rx/map snap->vector)))) + (let [vbox @refs/vbox + x-ranges (snap-accuracy-ranges points :x zoom) + y-ranges (snap-accuracy-ranges points :y zoom) + rm (remove-from-snap-points remove-snap?) + gmx (get-min-distance-snap points :x) + gmy (get-min-distance-snap points :y)] + (->> (mw/ask! {:cmd :index/query-snap-xy + :page-id page-id + :frame-id frame-id + :bounds vbox + :x-ranges x-ranges + :y-ranges y-ranges}) + (rx/take 1) + (rx/map (fn [{:keys [x y]}] + (snap->vector [(gmx (rm x)) (gmy (rm y))])))))) (defn sr-distance [coord sr1 sr2] (let [c1 (if (= coord :x) :x1 :y1) diff --git a/frontend/src/app/main/streams.cljs b/frontend/src/app/main/streams.cljs index d6ebf074b7..0cae9a246f 100644 --- a/frontend/src/app/main/streams.cljs +++ b/frontend/src/app/main/streams.cljs @@ -22,6 +22,16 @@ (or ^boolean (kbd/keyboard-event? event) ^boolean (mse/mouse-event? event))) +;; Live preview state for an interactive transform. Pushed by drag +;; events at the cadence set by upstream `rx/sample` (see +;; `transforms.cljs`); subscribed via plain atoms in `app.main.refs` so +;; updates bypass the Redux store and don't re-run unrelated lenses. +(defonce wasm-modifiers + (rx/behavior-subject nil)) + +(defonce workspace-selrect + (rx/behavior-subject nil)) + ;; --- Derived streams (defonce ^:private pointer diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index 39ff4493dd..970fe31d09 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -18,8 +18,8 @@ [app.main.router :as rt] [app.main.store :as st] [app.main.ui.context :as ctx] - [app.main.ui.debug.icons-preview :refer [icons-preview]] - [app.main.ui.debug.playground :refer [playground]] + [app.main.ui.debug.icons-preview :refer [icons-preview*]] + [app.main.ui.debug.playground :refer [playground*]] [app.main.ui.ds.product.loader :refer [loader*]] [app.main.ui.error-boundary :refer [error-boundary*]] [app.main.ui.exports.files] @@ -211,11 +211,11 @@ :debug-icons-preview (when *assert* - [:& icons-preview]) + [:> icons-preview*]) :debug-playground (when *assert* - [:& playground]) + [:> playground*]) (:dashboard-search :dashboard-recent @@ -362,7 +362,7 @@ :share share}]) :frame-preview - [:& frame-preview/frame-preview] + [:> frame-preview/frame-preview*] nil)])) diff --git a/frontend/src/app/main/ui/alert.cljs b/frontend/src/app/main/ui/alert.cljs index bfcdc958cf..0a19710be2 100644 --- a/frontend/src/app/main/ui/alert.cljs +++ b/frontend/src/app/main/ui/alert.cljs @@ -65,8 +65,8 @@ (when (seq link-message) [:h3 {:class (stl/css :modal-msg)} [:span (:before link-message)] - [:& lk/link {:action (:on-click link-message) - :class (stl/css :link)} + [:> lk/link* {:action (:on-click link-message) + :class (stl/css :link)} (:text link-message)] [:span (:after link-message)]]) (when (and (string? scd-message) (not= scd-message "")) diff --git a/frontend/src/app/main/ui/auth/login.cljs b/frontend/src/app/main/ui/auth/login.cljs index 4382f95327..9ec9a61a5d 100644 --- a/frontend/src/app/main/ui/auth/login.cljs +++ b/frontend/src/app/main/ui/auth/login.cljs @@ -191,9 +191,9 @@ (or (contains? cf/flags :login) (contains? cf/flags :login-with-password))) [:div {:class (stl/css :fields-row :forgot-password)} - [:& lk/link {:action on-recovery-request - :class (stl/css :forgot-pass-link) - :data-testid "forgot-password"} + [:> lk/link* {:action on-recovery-request + :class (stl/css :forgot-pass-link) + :data-testid "forgot-password"} (tr "auth.forgot-password")]]) [:div {:class (stl/css :buttons-stack)} @@ -284,7 +284,7 @@ [:div {:class (stl/css :register)} [:span {:class (stl/css :register-text)} (tr "auth.register") " "] - [:& lk/link {:action go-register - :class (stl/css :register-link) - :data-testid "register-submit"} + [:> lk/link* {:action go-register + :class (stl/css :register-link) + :data-testid "register-submit"} (tr "auth.register-submit")]])]])) diff --git a/frontend/src/app/main/ui/auth/recovery_request.cljs b/frontend/src/app/main/ui/auth/recovery_request.cljs index 74f19116b4..e2d162c648 100644 --- a/frontend/src/app/main/ui/auth/recovery_request.cljs +++ b/frontend/src/app/main/ui/auth/recovery_request.cljs @@ -98,9 +98,9 @@ [:& recovery-form {:params params :on-success-callback on-success-callback}] [:hr {:class (stl/css :separator)}] [:div {:class (stl/css :go-back)} - [:& lk/link {:action go-back - :class (stl/css :go-back-link) - :data-testid "go-back-link"} + [:> lk/link* {:action go-back + :class (stl/css :go-back-link) + :data-testid "go-back-link"} (tr "labels.go-back")]]])) diff --git a/frontend/src/app/main/ui/auth/register.cljs b/frontend/src/app/main/ui/auth/register.cljs index 60fd3e0167..8dcc862084 100644 --- a/frontend/src/app/main/ui/auth/register.cljs +++ b/frontend/src/app/main/ui/auth/register.cljs @@ -81,6 +81,7 @@ on-error (mf/use-fn (fn [cause] + (reset! submitted? false) (let [{:keys [type code] :as edata} (ex-data cause)] (condp = [type code] [:restriction :email-does-not-match-invitation] @@ -98,6 +99,9 @@ [:restriction :email-has-complaints] (st/emit! (ntf/error (tr "errors.email-has-permanent-bounces" (:email edata)))) + [:validation :email-already-exists] + (st/emit! (ntf/error (tr "errors.email-already-exists"))) + [:validation :email-as-password] (swap! form assoc-in [:errors :password] {:message (tr "errors.email-as-password")}) @@ -208,17 +212,17 @@ [:div {:class (stl/css :links)} [:div {:class (stl/css :account)} [:span {:class (stl/css :account-text)} (tr "auth.already-have-account") " "] - [:& lk/link {:action #(st/emit! (rt/nav :auth-login params)) - :class (stl/css :account-link) - :data-testid "login-here-link"} + [:> lk/link* {:action #(st/emit! (rt/nav :auth-login params)) + :class (stl/css :account-link) + :data-testid "login-here-link"} (tr "auth.login-here")]] (when (contains? cf/flags :demo-users) [:* [:hr {:class (stl/css :separator)}] [:div {:class (stl/css :demo-account)} - [:& lk/link {:action login/create-demo-profile - :class (stl/css :demo-account-link)} + [:> lk/link* {:action login/create-demo-profile + :class (stl/css :demo-account-link)} (tr "auth.create-demo-account")]]])]]) @@ -351,6 +355,6 @@ [:div {:class (stl/css :links)} [:div {:class (stl/css :go-back)} - [:& lk/link {:action #(st/emit! (rt/nav :auth-register {})) - :class (stl/css :go-back-link)} + [:> lk/link* {:action #(st/emit! (rt/nav :auth-register {})) + :class (stl/css :go-back-link)} (tr "labels.go-back")]]]]) diff --git a/frontend/src/app/main/ui/auth/verify_token.cljs b/frontend/src/app/main/ui/auth/verify_token.cljs index 6ac2a1b60f..b647bcf27d 100644 --- a/frontend/src/app/main/ui/auth/verify_token.cljs +++ b/frontend/src/app/main/ui/auth/verify_token.cljs @@ -25,11 +25,19 @@ (defmulti handle-token (fn [token] (:iss token))) (defmethod handle-token :verify-email - [data] + [{:keys [invitation-token] :as data}] (cf/external-notify-register-success (:profile-id data)) (let [msg (tr "dashboard.notifications.email-verified-successfully")] (ts/schedule 1000 #(st/emit! (ntf/success msg))) - (st/emit! (da/login-from-token data)))) + ;; If the verify-email JWE carries an :invitation-token, it means + ;; the user registered via a team-invitation flow but had to verify + ;; their email first. Log them in and then redirect to + ;; :auth-verify-token with the invitation token, which will accept + ;; the invitation as a logged-in user. + (if invitation-token + (st/emit! (da/login-from-token data) + (rt/nav :auth-verify-token {:token invitation-token})) + (st/emit! (da/login-from-token data))))) (defmethod handle-token :change-email [_data] @@ -68,8 +76,15 @@ (mf/defc verify-token* [{:keys [route]}] - (let [token (get-in route [:query-params :token]) - bad-token (mf/use-state false)] + (let [token (get-in route [:query-params :token]) + ;; Holds the specific failure reason when the token fails, or + ;; nil while still loading / on success. Any non-nil keyword is + ;; truthy, so this single state replaces the previous pair of + ;; (bad-token? + bad-token-reason) hooks. Reasons: + ;; :token-expired -> JWT past its :exp + ;; :email-mismatch -> invitation email != logged-in email + ;; :invalid-token -> corrupted / unknown / fallback + bad-token-reason (mf/use-state nil)] (mf/with-effect [] (dom/set-html-title (tr "title.default")) @@ -78,7 +93,7 @@ (fn [tdata] (handle-token tdata)) (fn [cause] - (let [{:keys [type code team-id] :as error} (ex-data cause)] + (let [{:keys [type code team-id reason] :as error} (ex-data cause)] (cond (= :invalid-token-already-member code) (st/emit! @@ -91,8 +106,12 @@ (or (= :validation type) (= :invalid-token code) - (= :token-expired (:reason error))) - (reset! bad-token true) + (= :token-expired reason)) + (reset! bad-token-reason + (cond + (= :token-expired reason) :token-expired + (= :email-mismatch reason) :email-mismatch + :else :invalid-token)) (= :email-already-exists code) (let [msg (tr "errors.email-already-exists")] @@ -109,8 +128,8 @@ (ts/schedule 100 #(st/emit! (ntf/error msg))) (st/emit! (rt/nav :auth-login))))))))) - (if @bad-token - [:> static/invalid-token {}] + (if @bad-token-reason + [:> static/invalid-token {:reason @bad-token-reason}] [:> loader* {:title (tr "labels.loading") :overlay true}]))) diff --git a/frontend/src/app/main/ui/comments.cljs b/frontend/src/app/main/ui/comments.cljs index 45660b3bb8..8ee83d7411 100644 --- a/frontend/src/app/main/ui/comments.cljs +++ b/frontend/src/app/main/ui/comments.cljs @@ -95,7 +95,7 @@ ([text] (-> (dom/create-element "span") (dom/set-data! "type" "text") - (dom/set-html! (if (empty? text) zero-width-space text))))) + (dom/set-html! (if (empty? text) zero-width-space (dom/escape-html text)))))) (defn- create-mention-node "Creates a mention node" @@ -334,7 +334,7 @@ after-span (create-text-node (dm/str " " suffix)) sel (wapi/get-selection)] - (dom/set-html! span-node (if (empty? prefix) zero-width-space prefix)) + (dom/set-html! span-node (if (empty? prefix) zero-width-space (dom/escape-html prefix))) (dom/insert-after! node span-node mention-span) (dom/insert-after! node mention-span after-span) (wapi/set-cursor-after! after-span) @@ -351,7 +351,7 @@ (let [node-text (dom/get-text span-node) at-symbol (if (blank-content? node-text) "@" " @")] - (dom/set-html! span-node (str/concat node-text at-symbol)) + (dom/set-html! span-node (str/concat (dom/escape-html node-text) at-symbol)) (wapi/set-cursor-after! span-node)))))) handle-key-down @@ -399,7 +399,7 @@ (when span-node (let [txt (.-textContent span-node)] - (dom/set-html! span-node (dm/str (subs txt 0 offset) "\n" zero-width-space (subs txt offset))) + (dom/set-html! span-node (dm/str (dom/escape-html (subs txt 0 offset)) "\n" zero-width-space (dom/escape-html (subs txt offset)))) (wapi/set-cursor! span-node (inc offset)) (handle-input))))) @@ -562,8 +562,7 @@ [:div {:class (stl/css :comments-mentions-email)} email]]))]))) (mf/defc mentions-button* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [] (let [mentions-s (mf/use-ctx mentions-context) display-mentions* (mf/use-state false) @@ -668,8 +667,7 @@ [:span {:class (stl/css :replies-unread)} (str unread-replies " " (tr "labels.replies.new"))]))])]]) (mf/defc comment-form-buttons* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [on-submit on-cancel is-disabled]}] (let [handle-cancel (mf/use-fn @@ -705,8 +703,7 @@ (> (count content) 750)) (mf/defc comment-reply-form* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [on-submit]}] (let [content (mf/use-state "") diff --git a/frontend/src/app/main/ui/components/code_block.cljs b/frontend/src/app/main/ui/components/code_block.cljs index b15f4eb33d..86c163dae0 100644 --- a/frontend/src/app/main/ui/components/code_block.cljs +++ b/frontend/src/app/main/ui/components/code_block.cljs @@ -16,8 +16,7 @@ (def highlight-fn (delay (modules/load-fn 'app.util.code-highlight/highlight!))) -(mf/defc code-block - {::mf/wrap-props false} +(mf/defc code-block* [{:keys [code type]}] (let [block-ref (mf/use-ref) code (str/trim code)] diff --git a/frontend/src/app/main/ui/components/context_menu_a11y.scss b/frontend/src/app/main/ui/components/context_menu_a11y.scss index b87754a356..5297f75422 100644 --- a/frontend/src/app/main/ui/components/context_menu_a11y.scss +++ b/frontend/src/app/main/ui/components/context_menu_a11y.scss @@ -5,12 +5,13 @@ // Copyright (c) KALEIDOS INC @use "refactor/common-refactor.scss" as deprecated; +@use "ds/z-index.scss" as *; .context-menu { position: relative; visibility: hidden; opacity: deprecated.$op-0; - z-index: deprecated.$z-index-4; + z-index: var(--z-index-dropdown); &.is-open { position: relative; diff --git a/frontend/src/app/main/ui/components/link.cljs b/frontend/src/app/main/ui/components/link.cljs index e0c1d90fb6..2e92b00c21 100644 --- a/frontend/src/app/main/ui/components/link.cljs +++ b/frontend/src/app/main/ui/components/link.cljs @@ -10,8 +10,7 @@ [app.util.keyboard :as kbd] [rumext.v2 :as mf])) -(mf/defc link - {::mf/wrap-props false} +(mf/defc link* [{:keys [action class data-testid keyboard-action children]}] (let [keyboard-action (d/nilv keyboard-action action)] [:a {:on-click action diff --git a/frontend/src/app/main/ui/components/link_button.cljs b/frontend/src/app/main/ui/components/link_button.cljs index 90da87209c..124c05b1d2 100644 --- a/frontend/src/app/main/ui/components/link_button.cljs +++ b/frontend/src/app/main/ui/components/link_button.cljs @@ -9,8 +9,7 @@ [app.util.keyboard :as kbd] [rumext.v2 :as mf])) -(mf/defc link-button - {::mf/wrap-props false} +(mf/defc link-button* [{:keys [on-click class value data-testid]}] (let [on-key-down (mf/use-fn (mf/deps on-click) diff --git a/frontend/src/app/main/ui/components/numeric_input.cljs b/frontend/src/app/main/ui/components/numeric_input.cljs index bec4efd044..c94ea3c152 100644 --- a/frontend/src/app/main/ui/components/numeric_input.cljs +++ b/frontend/src/app/main/ui/components/numeric_input.cljs @@ -63,11 +63,6 @@ ;; Last value input by the user we need to store to save on unmount last-value* (mf/use-var value) - ;; Drag scrubbing state - drag-state* (mf/use-ref :idle) - drag-start-x* (mf/use-ref 0) - drag-start-val* (mf/use-ref 0) - parse-value (mf/use-fn (mf/deps min-value max-value value nillable? default integer?) @@ -219,83 +214,18 @@ (dom/blur! node))))) handle-focus - (mf/use-callback + (mf/use-fn (mf/deps on-focus select-on-focus?) (fn [event] - (when-not (= :dragging (mf/ref-val drag-state*)) - (reset! last-value* (parse-value)) - (let [target (dom/get-target event)] - (when on-focus - (mf/set-ref-val! dirty-ref true) - (on-focus event)) - - (when select-on-focus? - (dom/select-text! target) - ;; In webkit browsers the mouseup event will be called after the on-focus causing and unselect - (.addEventListener target "mouseup" dom/prevent-default #js {:once true})))))) - - on-scrub-pointer-down - (mf/use-fn - (mf/deps value value-str min-value max-value default) - (fn [event] - (let [disabled? (unchecked-get props "disabled") - node (mf/ref-val ref) - is-focused (and (some? node) (dom/active? node))] - (when-not (or disabled? is-focused (= :multiple value-str)) - (let [client-x (.-clientX event) - start-val (or value default 0)] - (mf/set-ref-val! drag-state* :maybe-dragging) - (mf/set-ref-val! drag-start-x* client-x) - (mf/set-ref-val! drag-start-val* start-val) - (dom/capture-pointer event)))))) - - on-scrub-pointer-move - (mf/use-fn - (mf/deps apply-value update-input step-value min-value max-value) - (fn [event] - (let [state (mf/ref-val drag-state*)] - (when (or (= state :maybe-dragging) (= state :dragging)) - (let [client-x (.-clientX event) - start-x (mf/ref-val drag-start-x*) - delta-x (- client-x start-x)] - (when (and (= state :maybe-dragging) - (>= (js/Math.abs delta-x) 3)) - (mf/set-ref-val! drag-state* :dragging) - (dom/add-class! (dom/get-body) "cursor-drag-scrub")) - (when (= (mf/ref-val drag-state*) :dragging) - (let [effective-step (cond - (.-shiftKey event) (* step-value 10) - (.-ctrlKey event) (* step-value 0.1) - :else step-value) - steps (js/Math.round (/ delta-x 1)) - new-val (+ (mf/ref-val drag-start-val*) - (* steps effective-step)) - new-val (cond-> new-val - (d/num? min-value) (mth/max min-value) - (d/num? max-value) (mth/min max-value))] - (update-input new-val) - (apply-value event new-val)))))))) - - on-scrub-pointer-up - (mf/use-fn - (mf/deps ref) - (fn [event] - (let [state (mf/ref-val drag-state*)] - (when (= state :maybe-dragging) - (mf/set-ref-val! drag-state* :idle) - (dom/release-pointer event) - (when-let [node (mf/ref-val ref)] - (dom/focus! node))) - (when (= state :dragging) - (mf/set-ref-val! drag-state* :idle) - (dom/remove-class! (dom/get-body) "cursor-drag-scrub") - (dom/release-pointer event))))) - - on-scrub-lost-pointer-capture - (mf/use-fn - (fn [_event] - (mf/set-ref-val! drag-state* :idle) - (dom/remove-class! (dom/get-body) "cursor-drag-scrub"))) + (reset! last-value* (parse-value)) + (let [target (dom/get-target event)] + (when on-focus + (mf/set-ref-val! dirty-ref true) + (on-focus event)) + (when select-on-focus? + (dom/select-text! target) + ;; In webkit browsers the mouseup event will be called after the on-focus causing and unselect + (.addEventListener target "mouseup" dom/prevent-default #js {:once true}))))) props (-> (obj/clone props) (obj/unset! "selectOnFocus") @@ -310,11 +240,7 @@ (obj/set! "title" title) (obj/set! "onKeyDown" handle-key-down) (obj/set! "onBlur" handle-blur) - (obj/set! "onFocus" handle-focus) - (obj/set! "onPointerDown" on-scrub-pointer-down) - (obj/set! "onPointerMove" on-scrub-pointer-move) - (obj/set! "onPointerUp" on-scrub-pointer-up) - (obj/set! "onLostPointerCapture" on-scrub-lost-pointer-capture))] + (obj/set! "onFocus" handle-focus))] (mf/with-effect [value] (when-let [input-node (mf/ref-val ref)] diff --git a/frontend/src/app/main/ui/components/select.cljs b/frontend/src/app/main/ui/components/select.cljs index 07c2a9db86..c6f01ec323 100644 --- a/frontend/src/app/main/ui/components/select.cljs +++ b/frontend/src/app/main/ui/components/select.cljs @@ -13,7 +13,9 @@ [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] + [app.util.i18n :refer [tr]] [app.util.keyboard :as kbd] + [cuerdas.core :as str] [rumext.v2 :as mf])) (defn- as-key-value @@ -47,7 +49,7 @@ (:value (nth options (rotate-index-backward index length)))) (mf/defc select - [{:keys [default-value options class dropdown-class is-open? on-change on-pointer-enter-option on-pointer-leave-option disabled data-direction]}] + [{:keys [default-value options class dropdown-class is-open? on-change on-pointer-enter-option on-pointer-leave-option disabled data-direction searchable? search-placeholder]}] (let [label-index (mf/with-memo [options] (into {} (map as-key-value) options)) @@ -63,6 +65,25 @@ is-open? (get state :is-open?) + ;; nil = the user is not actively searching (input shows the current + ;; selection's label); a string = the user has typed (input shows that + ;; string and options are filtered). + search* (mf/use-state nil) + search (deref search*) + searching? (and searchable? (some? search)) + search-input-ref (mf/use-ref nil) + + visible-options + (mf/with-memo [options search searchable?] + (if (and searchable? (some? search) (not (str/blank? search))) + (let [needle (str/lower search)] + (into [] (filter (fn [item] + (and (map? item) + (when-let [label (:label item)] + (str/includes? (str/lower (dm/str label)) needle))))) + options)) + options)) + node-ref (mf/use-ref nil) dropdown-direction* @@ -76,10 +97,10 @@ handle-key-up (mf/use-fn - (mf/deps disabled options current-value) + (mf/deps disabled visible-options current-value searchable?) (fn [e] (when-not disabled - (let [options (into [] (remove :disabled) options) + (let [options (into [] (remove :disabled) visible-options) length (count options) index (d/index-of-pred options #(= (:value %) current-value)) index (d/nilv index 0)] @@ -87,20 +108,26 @@ (cond (or (kbd/left-arrow? e) (kbd/up-arrow? e)) - (let [value (rotate-option-backward options index length)] - (swap! state* assoc :current-value value) - (when (fn? on-change) - (on-change value))) + (when (pos? length) + (let [value (rotate-option-backward options index length)] + (swap! state* assoc :current-value value) + (when (fn? on-change) + (on-change value)))) (or (kbd/right-arrow? e) (kbd/down-arrow? e)) - (let [value (rotate-option-forward options index)] - (swap! state* assoc :current-value value) - (when (fn? on-change) - (on-change value))) + (when (pos? length) + (let [value (rotate-option-forward options index)] + (swap! state* assoc :current-value value) + (when (fn? on-change) + (on-change value)))) - (or (kbd/enter? e) - (kbd/space? e)) + (kbd/enter? e) + (swap! state* assoc :is-open? false) + + ;; In searchable mode the input owns Space — let the user + ;; type spaces in the filter without closing the dropdown. + (and (not searchable?) (kbd/space? e)) (swap! state* assoc :is-open? false) (kbd/tab? e) @@ -108,13 +135,32 @@ :is-open? true :current-value (-> options first :value))))))) - open-dropdown + handle-search-change + (mf/use-fn + (fn [e] + (let [v (dom/get-target-val e)] + (reset! search* v) + (swap! state* assoc :is-open? true)))) + + handle-search-focus (mf/use-fn (mf/deps disabled) - (fn [] + (fn [_] (when-not disabled (swap! state* assoc :is-open? true)))) + open-dropdown + (mf/use-fn + (mf/deps disabled searchable?) + (fn [] + (when-not disabled + (swap! state* assoc :is-open? true) + ;; In searchable mode the input is the focus target — pull focus + ;; in case the click landed on the wrapper or the dropdown arrow. + (when searchable? + (when-let [el (mf/ref-val search-input-ref)] + (dom/focus! el)))))) + close-dropdown (mf/use-fn #(swap! state* assoc :is-open? false)) @@ -158,6 +204,10 @@ (reset! dropdown-direction* "down") (mf/set-ref-val! dropdown-direction-change* 0))) + (mf/with-effect [is-open?] + (when (and searchable? (not is-open?)) + (reset! search* nil))) + (mf/with-effect [is-open?] (let [dropdown-element (mf/ref-val node-ref)] (when (and (= 0 (mf/ref-val dropdown-direction-change*)) dropdown-element) @@ -167,25 +217,44 @@ (let [selected-option (first (filter #(= (:value %) default-value) options)) current-icon (:icon selected-option) - current-icon-ref (deprecated-icon/key->icon current-icon)] + current-icon-ref (deprecated-icon/key->icon current-icon) + input-value (if searching? search (or current-label ""))] [:div {:id (dm/str current-id) :on-click open-dropdown :on-key-up handle-key-up - :tab-index "0" + :tab-index (if searchable? "-1" "0") :role "combobox" :class (dm/str (stl/css-case :custom-select true + :searchable-select searchable? :disabled disabled :icon (some? current-icon-ref)) " " class)} (when (and current-icon current-icon-ref) [:span {:class (stl/css :current-icon)} current-icon-ref]) - [:span {:class (stl/css :current-label)} current-label] + (if searchable? + [:input {:ref search-input-ref + :type "text" + :class (stl/css :current-label-input) + :value input-value + :placeholder (or search-placeholder current-label) + :disabled disabled + :role "searchbox" + :aria-autocomplete "list" + :aria-label (or search-placeholder current-label) + :on-focus handle-search-focus + :on-click handle-search-focus + :on-change handle-search-change}] + [:span {:class (stl/css :current-label)} current-label]) [:span {:class (stl/css :dropdown-button)} deprecated-icon/arrow] [:& dropdown {:show is-open? :on-close close-dropdown} [:ul {:ref node-ref :data-direction (d/nilv data-direction dropdown-direction) :class (dm/str dropdown-class " " (stl/css :custom-select-dropdown))} - (for [[index item] (d/enumerate options)] + (when (and searchable? searching? (not (str/blank? search)) (empty? visible-options)) + [:li {:class (stl/css :custom-select-no-matches) + :role "presentation"} + (tr "labels.no-matches")]) + (for [[index item] (d/enumerate visible-options)] (if (= :separator item) [:li {:id (dm/str current-id "-" index) :key (dm/str current-id "-" index) diff --git a/frontend/src/app/main/ui/components/select.scss b/frontend/src/app/main/ui/components/select.scss index e75034f6f3..f0b1cab71c 100644 --- a/frontend/src/app/main/ui/components/select.scss +++ b/frontend/src/app/main/ui/components/select.scss @@ -4,28 +4,33 @@ // // Copyright (c) KALEIDOS INC -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/typography.scss" as *; +@use "ds/colors.scss" as *; +@use "ds/mixins.scss" as *; +@use "ds/z-index.scss" as *; .custom-select { - --border-color: var(--menu-background-color); - --bg-color: var(--menu-background-color); - --icon-color: var(--icon-foreground); - --text-color: var(--menu-foreground-color); + @include custom-scrollbar; + @include use-typography("body-small"); - @extend %new-scrollbar; - @include deprecated.body-small-typography; + --border-color: var(--color-background-tertiary); + --bg-color: var(--color-background-tertiary); + --icon-color: var(--color-foreground-secondary); + --text-color: var(--color-foreground-primary); position: relative; display: grid; grid-template-columns: 1fr auto; align-items: center; - height: deprecated.$s-32; - width: 100%; + block-size: $sz-32; + inline-size: 100%; margin: 0; - padding: deprecated.$s-8; - border-radius: deprecated.$br-8; + padding: var(--sp-s); + border-radius: $br-8; background-color: var(--bg-color); - border: deprecated.$s-1 solid var(--border-color); + border: $b-1 solid var(--border-color); color: var(--text-color); cursor: pointer; @@ -34,106 +39,256 @@ } &:hover { - --bg-color: var(--menu-background-color-hover); - --border-color: var(--menu-background-color); - --icon-color: var(--menu-foreground-color-hover); + --bg-color: var(--color-background-quaternary); + --border-color: var(--color-background-tertiary); + --icon-color: var(--color-foreground-primary); } - &:focus { - --bg-color: var(--menu-background-color-focus); - --border-color: var(--menu-background-focus); + &:has(*:focus-visible) { + --bg-color: var(--color-background-tertiary); + --border-color: var(--color-accent-primary); + } +} + +.searchable-select { + --searchable-select-bg-color: var(--color-background-tertiary); + --searchable-select-icon-color: var(--color-foreground-secondary); + --searchable-select-outline-color: none; + + background: var(--searchable-select-bg-color); + outline: $b-1 solid var(--searchable-select-outline-color); + + &:hover { + --searchable-select-bg-color: var(--color-background-quaternary); + } + + &:has(*:focus-visible) { + --searchable-select-bg-color: var(--color-background-primary); + --searchable-select-outline-color: var(--color-accent-primary); + } + + &:has(*:disabled) { + --searchable-select-bg-color: var(--color-background-primary); + --searchable-select-outline-color: var(--color-background-quaternary); + } + + &[data-option-focused="true"]:has(*:focus-visible) { + --searchable-select-bg-color: var(--color-background-tertiary); + --searchable-select-outline-color: none; } } .disabled { - --bg-color: var(--menu-background-color-disabled); - --border-color: var(--menu-border-color-disabled); - --icon-color: var(--menu-foreground-color-disabled); - --text-color: var(--menu-foreground-color-disabled); + --bg-color: var(--color-background-primary); + --border-color: var(--color-background-quaternary); + --icon-color: var(--color-foreground-secondary); + --text-color: var(--color-foreground-secondary); pointer-events: none; cursor: default; } .dropdown-button { - @include deprecated.flex-center; - + display: flex; + justify-content: center; + align-items: center; margin-inline-end: var(--sp-xxs); svg { - @extend %button-icon-small; - + display: flex; + align-items: center; + gap: var(--sp-xxs); + color: transparent; + fill: none; + block-size: $sz-12; + inline-size: $sz-12; + stroke-width: 1.33px; + visibility: hidden; + stroke: var(--color-foreground-secondary); transform: rotate(90deg); - stroke: var(--icon-color); } } .current-icon { - @include deprecated.flex-center; - - width: deprecated.$s-24; - padding-right: deprecated.$s-4; + display: flex; + justify-content: center; + align-items: center; + inline-size: $sz-24; + padding-inline-end: var(--sp-xs); svg { - @extend %button-icon-small; - - stroke: var(--icon-foreground); + display: flex; + align-items: center; + gap: var(--sp-xxs); + color: transparent; + fill: none; + block-size: $sz-12; + inline-size: $sz-12; + stroke-width: 1.33px; + visibility: hidden; + stroke: var(--color-foreground-secondary); } } .custom-select-dropdown { - @extend %dropdown-wrapper; + box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark); + position: absolute; + top: $sz-32; + left: 0; + inline-size: 100%; + 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); + border: $b-2 solid var(--color-background-quaternary); .separator { margin: 0; - height: deprecated.$s-12; - border-block-start: deprecated.$s-1 solid var(--dropdown-separator-color); + block-size: $sz-12; + border-block-start: $b-1 solid var(--color-background-primary); } } +.current-label-input { + @include text-ellipsis; + + inline-size: 100%; + block-size: 100%; + margin: 0; + padding: 0; + border: none; + background-color: transparent; + color: inherit; + font: inherit; + outline: none; + cursor: pointer; + + &::placeholder { + color: inherit; + opacity: 1; + } + + &:focus { + cursor: text; + } + + &:disabled { + cursor: default; + } +} + +.custom-select-no-matches { + @include use-typography("body-small"); + + padding: var(--sp-s) var(--sp-m); + color: var(--color-foreground-secondary); + text-align: center; + pointer-events: none; +} + .custom-select-dropdown[data-direction="up"] { - bottom: deprecated.$s-32; + bottom: $sz-32; top: auto; } .checked-element { - @extend %dropdown-element-base; + @include use-typography("body-small"); - .icon { - @include deprecated.flex-center; + display: flex; + align-items: center; + gap: var(--sp-s); + block-size: $sz-32; + padding: 0 var(--sp-s); + border-radius: $br-6; + cursor: pointer; + color: var(--color-foreground-primary); - height: deprecated.$s-24; - width: deprecated.$s-24; - padding-right: deprecated.$s-4; + &:hover { + background-color: var(--color-background-quaternary); + color: var(--color-foreground-primary); svg { - @extend %button-icon; + stroke: var(--color-foreground-primary); + } + } - stroke: var(--icon-foreground); + span { + @include text-ellipsis; + + display: flex; + align-items: center; + + svg { + display: flex; + align-items: center; + gap: var(--sp-xxs); + color: transparent; + fill: none; + block-size: $sz-12; + inline-size: $sz-12; + stroke-width: 1.33px; + visibility: hidden; + stroke: var(--color-foreground-secondary); + } + } + + .icon { + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-24; + inline-size: $sz-24; + padding-inline-end: var(--sp-xs); + + svg { + display: flex; + align-items: center; + gap: var(--sp-xxs); + color: transparent; + fill: none; + block-size: $sz-16; + width: $sz-16; + stroke-width: px2rem(1); + visibility: hidden; + stroke: var(--color-foreground-secondary); } } .label { flex-grow: 1; - width: 100%; + inline-size: 100%; } .check-icon { - @include deprecated.flex-center; + display: flex; + justify-content: center; + align-items: center; svg { - @extend %button-icon-small; - + display: flex; + align-items: center; + gap: var(--sp-xxs); + color: transparent; + fill: none; + block-size: $sz-12; + inline-size: $sz-12; + stroke-width: 1.33px; visibility: hidden; - stroke: var(--icon-foreground); + stroke: var(--color-foreground-secondary); } } &.is-selected { - color: var(--menu-foreground-color); + color: var(--color-accent-primary); + background-color: var(--color-background-quaternary); .check-icon svg { - stroke: var(--menu-foreground-color); + stroke: var(--color-foreground-primary); visibility: visible; } } @@ -144,5 +299,5 @@ } .current-label { - @include deprecated.text-ellipsis; + @include text-ellipsis; } diff --git a/frontend/src/app/main/ui/context.cljs b/frontend/src/app/main/ui/context.cljs index 30568be47c..ee1781a43f 100644 --- a/frontend/src/app/main/ui/context.cljs +++ b/frontend/src/app/main/ui/context.cljs @@ -35,7 +35,7 @@ (def sidebar "A context that intends to store the current sidebar position, - usefull for components that behaves distinctly if they are showed in + useful for components that behaves distinctly if they are showed in right sidebar or left sidebar. Possible values: `:right:` and `:left`." diff --git a/frontend/src/app/main/ui/dashboard.cljs b/frontend/src/app/main/ui/dashboard.cljs index 4440cacc21..74354d454e 100644 --- a/frontend/src/app/main/ui/dashboard.cljs +++ b/frontend/src/app/main/ui/dashboard.cljs @@ -46,12 +46,10 @@ [cuerdas.core :as str] [goog.events :as events] [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (mf/defc dashboard-content* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [team projects project section search-term profile default-project]}] (let [container (mf/use-ref) content-width (mf/use-state 0) @@ -218,7 +216,7 @@ (fn [plugin] (if plugin (do - (st/emit! (ptk/event ::ev/event {::ev/name "install-plugin" :name (:name plugin) :url plugin-url})) + (st/emit! (ev/event {::ev/name "install-plugin" :name (:name plugin) :url plugin-url})) (open-permissions-dialog plugin)) (st/emit! (notif/error (tr "dashboard.plugins.parse-error"))))) (fn [_] @@ -247,12 +245,12 @@ (dd/fetch-recent-files team-id) (dd/fetch-projects team-id) (dd/clear-selected-files) - (ptk/event ::ev/event {::ev/name "install-template-from-link-finished" - :name template-name - :url template-url}))] + (ev/event {::ev/name "install-template-from-link-finished" + :name template-name + :url template-url}))] (if valid-url? (st/emit! - (ptk/event ::ev/event {::ev/name "install-template-from-link" :name template-name :url template-url}) + (ev/event {::ev/name "install-template-from-link" :name template-name :url template-url}) (modal/show {:type :import :project-id project-id diff --git a/frontend/src/app/main/ui/dashboard/comments.cljs b/frontend/src/app/main/ui/dashboard/comments.cljs index e432a642c8..ea762b8e1c 100644 --- a/frontend/src/app/main/ui/dashboard/comments.cljs +++ b/frontend/src/app/main/ui/dashboard/comments.cljs @@ -18,14 +18,12 @@ [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.icons :as deprecated-icon] [app.util.i18n :as i18n :refer [tr]] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (def ^:private comments-icon-svg (deprecated-icon/icon-xref :comments (stl/css :comments-icon))) (mf/defc comments-icon* - {::mf/props :obj} [{:keys [profile on-show-comments]}] (let [threads-map (mf/deref refs/comment-threads) @@ -81,8 +79,8 @@ (mf/deps show?) (fn [] (when show? - (st/emit! (ptk/event ::ev/event {::ev/name "open-comment-notifications" - ::ev/origin "dashboard"}))))) + (st/emit! (ev/event {::ev/name "open-comment-notifications" + ::ev/origin "dashboard"}))))) [:div {:class (stl/css :dashboard-comments-section)} [:& dropdown {:show show? :on-close on-hide-comments :dropdown-id "dashboard-comments"} diff --git a/frontend/src/app/main/ui/dashboard/deleted.cljs b/frontend/src/app/main/ui/dashboard/deleted.cljs index 7ad6f2f374..62033f707b 100644 --- a/frontend/src/app/main/ui/dashboard/deleted.cljs +++ b/frontend/src/app/main/ui/dashboard/deleted.cljs @@ -54,8 +54,7 @@ :on-accept accept-fn})))) (mf/defc header* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [] [:header {:class (stl/css :dashboard-header) :data-testid "dashboard-header"} [:div#dashboard-deleted-title {:class (stl/css :dashboard-title)} diff --git a/frontend/src/app/main/ui/dashboard/deleted.scss b/frontend/src/app/main/ui/dashboard/deleted.scss index 0ebba0c81b..69eeb9585d 100644 --- a/frontend/src/app/main/ui/dashboard/deleted.scss +++ b/frontend/src/app/main/ui/dashboard/deleted.scss @@ -6,11 +6,11 @@ @use "refactor/common-refactor.scss" as deprecated; @use "common/refactor/common-dashboard"; -@use "../ds/typography.scss" as t; -@use "../ds/_borders.scss" as *; -@use "../ds/spacing.scss" as *; -@use "../ds/_sizes.scss" as *; -@use "../ds/z-index.scss" as *; +@use "ds/typography.scss" as t; +@use "ds/spacing.scss" as *; +@use "ds/z-index.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; .dashboard-container { flex: 1 0 0; @@ -52,7 +52,7 @@ padding: var(--sp-xxl) var(--sp-xxl) var(--sp-s) var(--sp-xxl); position: sticky; top: 0; - z-index: $z-index-100; + z-index: var(--z-index-panels); } .nav-inside { diff --git a/frontend/src/app/main/ui/dashboard/files.cljs b/frontend/src/app/main/ui/dashboard/files.cljs index 797217dbc0..2a7e9e1193 100644 --- a/frontend/src/app/main/ui/dashboard/files.cljs +++ b/frontend/src/app/main/ui/dashboard/files.cljs @@ -31,8 +31,7 @@ (deprecated-icon/icon-xref :menu (stl/css :menu-icon))) (mf/defc header* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [project create-fn can-edit]}] (let [project-id (:id project) @@ -133,7 +132,6 @@ :on-import on-import}])]])) (mf/defc files-section* - {::mf/props :obj} [{:keys [project team]}] (let [files (mf/deref refs/files) project-id (get project :id) diff --git a/frontend/src/app/main/ui/dashboard/files.scss b/frontend/src/app/main/ui/dashboard/files.scss index 0a5fe94dbd..39cfe4958b 100644 --- a/frontend/src/app/main/ui/dashboard/files.scss +++ b/frontend/src/app/main/ui/dashboard/files.scss @@ -6,6 +6,8 @@ @use "refactor/common-refactor.scss" as deprecated; @use "common/refactor/common-dashboard"; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; .dashboard-container { flex: 1 0 0; @@ -13,6 +15,7 @@ overflow-y: auto; width: 100%; border-top: deprecated.$s-1 solid var(--color-background-quaternary); + padding-block-end: var(--sp-xxxl); &.dashboard-projects { user-select: none; diff --git a/frontend/src/app/main/ui/dashboard/fonts.cljs b/frontend/src/app/main/ui/dashboard/fonts.cljs index ad90465c4e..db96f2a901 100644 --- a/frontend/src/app/main/ui/dashboard/fonts.cljs +++ b/frontend/src/app/main/ui/dashboard/fonts.cljs @@ -11,6 +11,8 @@ [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.media :as cm] + [app.common.schema :as sm] + [app.common.types.font :as ctf] [app.common.uuid :as uuid] [app.config :as cf] [app.main.data.fonts :as df] @@ -54,8 +56,7 @@ (str/blank? (:font-family-tmp font)))) (mf/defc header* - {::mf/props :obj - ::mf/memo true + {::mf/memo true ::mf/private true} [{:keys [section team]}] (use-page-title team section) @@ -64,17 +65,14 @@ [:h1 (tr "labels.fonts")]]]) (mf/defc font-variant-display-name* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [variant]}] - [:* - [:span (cm/font-weight->name (:font-weight variant))] - (when (not= "normal" (:font-style variant)) - [:span " " (str/capital (:font-style variant))])]) + [:span (cm/font-display-variant (:variant-name variant) + (:font-weight variant) + (:font-style variant))]) (mf/defc uploaded-fonts* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [team installed-fonts]}] (let [fonts* (mf/use-state {}) fonts (deref fonts*) @@ -113,7 +111,7 @@ (mf/use-fn (fn [{:keys [id] :as item}] (swap! uploading* conj id) - (->> (rp/cmd! :create-font-variant item) + (->> (df/upload-font-variant item) (rx/delay-at-least 2000) (rx/subs! (fn [font] (swap! fonts* dissoc id) @@ -143,7 +141,8 @@ (dom/get-data "id") (uuid/parse)) name (dom/get-value target)] - (when-not (str/blank? name) + (when (and (not (str/blank? name)) + (sm/validate ctf/schema:font-family name)) (swap! fonts* df/rename-and-regroup id name installed-fonts))))) on-change-name @@ -267,10 +266,9 @@ [{:name (tr "labels.edit") :id "font-edit" :handler on-edit} - (when (contains? cf/flags :canary) - {:name (tr "labels.download-simple") - :id "font-download" - :handler on-download}) + {:name (tr "labels.download-simple") + :id "font-download" + :handler on-download} {:name (tr "labels.delete") :id "font-delete" :handler on-delete}])] @@ -325,7 +323,9 @@ (fn [_] (reset! edition* false) (when-not (str/blank? font-family) - (st/emit! (df/update-font {:id font-id :name font-family}))))) + (if (sm/validate ctf/schema:font-family font-family) + (st/emit! (df/update-font {:id font-id :name font-family})) + (st/emit! (ntf/error (tr "errors.font-family-invalid-chars"))))))) on-key-down (mf/use-fn @@ -438,7 +438,6 @@ :on-edit on-edit}]]))])) (mf/defc installed-fonts* - {::mf/props :obj} [{:keys [fonts can-edit]}] (let [sterm (mf/use-state "") @@ -491,7 +490,6 @@ (l/derived :fonts st/state)) (mf/defc fonts-page* - {::mf/props :obj} [{:keys [team]}] (let [fonts (mf/deref ref:fonts) permissions (:permissions team) @@ -505,7 +503,6 @@ {:team team :fonts fonts :can-edit can-edit}]]])) (mf/defc font-providers-page* - {::mf/props :obj} [{:keys [team]}] [:* [:> header* {:team team :section :providers}] diff --git a/frontend/src/app/main/ui/dashboard/grid.cljs b/frontend/src/app/main/ui/dashboard/grid.cljs index 345a8a6075..d7504f42ef 100644 --- a/frontend/src/app/main/ui/dashboard/grid.cljs +++ b/frontend/src/app/main/ui/dashboard/grid.cljs @@ -84,8 +84,7 @@ (rx/mapcat (partial persist-thumbnail file-id revn)))) (mf/defc grid-item-thumbnail* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [can-edit file can-restore]}] (let [file-id (get file :id) revn (get file :revn) @@ -97,7 +96,7 @@ visible? (h/use-visible container :once? true)] (mf/with-effect [file-id revn visible? thumbnail-id] - (when (and visible? (not thumbnail-id)) + (when (and can-edit visible? (not thumbnail-id)) (let [subscription (->> (ask-for-thumbnail file-id revn) (rx/subs! (fn [thumbnail-id] @@ -120,10 +119,11 @@ :src (cf/resolve-media thumbnail-id) :loading "lazy" :decoding "async"}] - [:> loader* {:class (stl/css :grid-loader) - :draggable (dm/str can-edit) - :overlay true - :title (tr "labels.loading")}]))])) + (when can-edit + [:> loader* {:class (stl/css :grid-loader) + :draggable (dm/str can-edit) + :overlay true + :title (tr "labels.loading")}])))])) ;; --- Grid Item Library @@ -131,7 +131,6 @@ (deprecated-icon/icon-xref :menu (stl/css :menu-icon))) (mf/defc grid-item-library* - {::mf/props :obj} [{:keys [file can-restore]}] (mf/with-effect [file] (when file @@ -467,7 +466,6 @@ :can-restore can-restore}]])]]]]])) (mf/defc grid* - {::mf/props :obj} [{:keys [files project origin limit create-fn can-edit selected-files can-restore]}] (let [dragging? (mf/use-state false) project-id (get project :id) diff --git a/frontend/src/app/main/ui/dashboard/grid.scss b/frontend/src/app/main/ui/dashboard/grid.scss index f0bf82dac2..394979b7e7 100644 --- a/frontend/src/app/main/ui/dashboard/grid.scss +++ b/frontend/src/app/main/ui/dashboard/grid.scss @@ -15,7 +15,7 @@ $thumbnail-default-height: deprecated.$s-168; // Default width font-size: deprecated.$fs-14; height: 100%; overflow: hidden auto; - padding: 0 deprecated.$s-16; + padding: 0 var(--sp-l) deprecated.$s-16; } .grid-row { diff --git a/frontend/src/app/main/ui/dashboard/import.cljs b/frontend/src/app/main/ui/dashboard/import.cljs index 6b4fe68678..fae24007ff 100644 --- a/frontend/src/app/main/ui/dashboard/import.cljs +++ b/frontend/src/app/main/ui/dashboard/import.cljs @@ -27,7 +27,6 @@ [app.util.webapi :as wapi] [beicon.v2.core :as rx] [cuerdas.core :as str] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (log/set-level! :debug) @@ -171,14 +170,14 @@ (rx/subs! (fn [message] (when (some? (:error message)) - (st/emit! (ptk/data-event ::ev/event {::ev/name "import-files-error" - :error (:error message)}))) + (st/emit! (ev/event {::ev/name "import-files-error" + :error (:error message)}))) (swap! state update-with-analyze-result message)))))) (defn- import-files [state project-id entries] - (st/emit! (ptk/data-event ::ev/event {::ev/name "import-files" - :num-files (count entries)})) + (st/emit! (ev/event {::ev/name "import-files" + :num-files (count entries)})) (let [features (get @st/state :features)] (->> (mw/ask-many! @@ -192,8 +191,7 @@ (swap! state update-entry-status message)))))) (mf/defc import-entry* - {::mf/props :obj - ::mf/memo true + {::mf/memo true ::mf/private true} [{:keys [entries entry edition can-be-deleted importing? on-edit on-change on-delete]}] (let [status (:status entry) @@ -295,7 +293,9 @@ import-error? [:div {:class (stl/css :error-message)} - (tr "labels.error")] + (if (some? (:error entry)) + (tr (:error entry)) + (tr "labels.error"))] (and (not import-success?) (some? progress)) [:div {:class (stl/css :progress-message)} (parse-progress-message progress)]) @@ -491,7 +491,12 @@ [:ul {:class (stl/css :import-error-list)} (for [entry entries] (when (contains? #{:import-error :analyze-error} (:status entry)) - [:li {:class (stl/css :import-error-list-enry)} (:name entry)]))] + [:li {:class (stl/css :import-error-list-enry) + :key (dm/str (or (:file-id entry) (:uri entry) (:name entry)))} + [:div (:name entry)] + (when-let [err (:error entry)] + [:div {:class (stl/css :import-error-detail)} + (tr err)])]))] [:div (tr "dashboard.import.import-error.message2")]] (for [entry entries] diff --git a/frontend/src/app/main/ui/dashboard/import.scss b/frontend/src/app/main/ui/dashboard/import.scss index 7d8d0ff428..2d3cb22e67 100644 --- a/frontend/src/app/main/ui/dashboard/import.scss +++ b/frontend/src/app/main/ui/dashboard/import.scss @@ -149,10 +149,16 @@ .progress-message { display: flex; align-items: center; - height: deprecated.$s-32; + min-height: deprecated.$s-32; color: var(--modal-text-foreground-color); } + .error-message { + align-items: flex-start; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + .linked-library { display: flex; align-items: center; @@ -258,3 +264,12 @@ .import-error-list-enry { padding: var(--sp-xs) 0; } + +.import-error-detail { + @include deprecated.body-small-typography; + + margin-top: var(--sp-xs); + color: var(--modal-text-foreground-color); + white-space: pre-wrap; + overflow-wrap: anywhere; +} diff --git a/frontend/src/app/main/ui/dashboard/libraries.cljs b/frontend/src/app/main/ui/dashboard/libraries.cljs index 8e265914dd..53908cd86a 100644 --- a/frontend/src/app/main/ui/dashboard/libraries.cljs +++ b/frontend/src/app/main/ui/dashboard/libraries.cljs @@ -27,7 +27,6 @@ st/state)) (mf/defc libraries-page* - {::mf/props :obj} [{:keys [team default-project]}] (let [files (mf/deref refs/shared-files) diff --git a/frontend/src/app/main/ui/dashboard/pin_button.cljs b/frontend/src/app/main/ui/dashboard/pin_button.cljs index ffa76fb6c3..5927e0493c 100644 --- a/frontend/src/app/main/ui/dashboard/pin_button.cljs +++ b/frontend/src/app/main/ui/dashboard/pin_button.cljs @@ -18,7 +18,6 @@ (deprecated-icon/icon-xref :pin (stl/css :icon))) (mf/defc pin-button* - {::mf/props :obj} [{:keys [aria-label is-pinned class] :as props}] (let [aria-label (or aria-label (tr "dashboard.pin-unpin")) class (dm/str (or class "") " " (stl/css-case :button true :button-active is-pinned)) diff --git a/frontend/src/app/main/ui/dashboard/placeholder.cljs b/frontend/src/app/main/ui/dashboard/placeholder.cljs index bb32d71b5c..107460b389 100644 --- a/frontend/src/app/main/ui/dashboard/placeholder.cljs +++ b/frontend/src/app/main/ui/dashboard/placeholder.cljs @@ -16,7 +16,6 @@ [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (mf/defc empty-project-placeholder* @@ -27,9 +26,9 @@ on-add-library (mf/use-fn (fn [_] - (st/emit! (ptk/event ::ev/event {::ev/name "explore-libraries-click" - ::ev/origin "dashboard" - :section "empty-placeholder-projects"})) + (st/emit! (ev/event {::ev/name "explore-libraries-click" + ::ev/origin "dashboard" + :section "empty-placeholder-projects"})) (dom/open-new-window "https://penpot.app/penpothub/libraries-templates"))) on-import diff --git a/frontend/src/app/main/ui/dashboard/projects.cljs b/frontend/src/app/main/ui/dashboard/projects.cljs index 04ca802352..0fe141608c 100644 --- a/frontend/src/app/main/ui/dashboard/projects.cljs +++ b/frontend/src/app/main/ui/dashboard/projects.cljs @@ -13,8 +13,9 @@ [app.main.data.dashboard :as dd] [app.main.data.dashboard.shortcuts :as sc] [app.main.data.event :as ev] - [app.main.data.modal :as modal] + [app.main.data.nitrate :as dnt] [app.main.data.project :as dpj] + [app.main.data.team :as dtm] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.dashboard.deleted :as deleted] @@ -31,7 +32,6 @@ [app.util.storage :as storage] [cuerdas.core :as str] [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (def ^:private show-more-icon @@ -48,7 +48,6 @@ (mf/defc header* {::mf/wrap [mf/memo] - ::mf/props :obj ::mf/private true} [{:keys [can-edit]}] (let [on-click (mf/use-fn #(st/emit! (dd/create-project)))] @@ -62,8 +61,7 @@ (tr "dashboard.new-project")])])) (mf/defc team-hero* - {::mf/wrap [mf/memo] - ::mf/props :obj} + {::mf/wrap [mf/memo]} [{:keys [team on-close]}] (let [on-nav-members-click (mf/use-fn #(st/emit! (dcm/go-to-dashboard-members))) @@ -71,9 +69,8 @@ (mf/use-fn (mf/deps team) (fn [] - (st/emit! (modal/show {:type :invite-members - :team team - :origin :hero})))) + (st/emit! (dtm/check-and-invite-members {:team-id (:id team) + :origin :hero})))) on-close' (mf/use-fn (mf/deps on-close) @@ -102,8 +99,7 @@ close-icon]])) (mf/defc project-item* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [project is-first team files can-edit]}] (let [project-id (get project :id) team-id (get team :id) @@ -313,7 +309,6 @@ (l/derived :recent-files st/state)) (mf/defc projects-section* - {::mf/props :obj} [{:keys [team projects profile]}] (let [team-id (get team :id) @@ -322,8 +317,10 @@ permisions (:permissions team) can-edit (:can-edit permisions) - can-invite (or (:is-owner permisions) - (:is-admin permisions)) + can-invite (dnt/can-send-invitations? + {:organization (:organization team) + :profile-id (:id profile) + :team-permissions permisions}) show-team-hero* (mf/use-state #(get storage/global ::show-team-hero true)) show-team-hero? (deref show-team-hero*) @@ -344,8 +341,8 @@ (mf/use-fn (fn [] (reset! show-team-hero* false) - (st/emit! (ptk/data-event ::ev/event {::ev/name "dont-show-team-up-hero" - ::ev/origin "dashboard"}))))] + (st/emit! (ev/event {::ev/name "dont-show-team-up-hero" + ::ev/origin "dashboard"}))))] (mf/with-effect [show-team-hero?] (swap! storage/global assoc ::show-team-hero show-team-hero?)) diff --git a/frontend/src/app/main/ui/dashboard/projects.scss b/frontend/src/app/main/ui/dashboard/projects.scss index d910ffb0d2..2616edcd49 100644 --- a/frontend/src/app/main/ui/dashboard/projects.scss +++ b/frontend/src/app/main/ui/dashboard/projects.scss @@ -19,16 +19,15 @@ margin-inline-end: var(--sp-l); border-block-start: $b-1 solid var(--panel-border-color); overflow-y: auto; - padding-block-end: var(--sp-xxxl); } .dashboard-projects { user-select: none; - block-size: calc(100vh - px2rem(64)); + block-size: calc(100vh - px2rem(80)); } .with-team-hero { - block-size: calc(100vh - px2rem(280)); + block-size: calc(100vh - px2rem(360)); } .dashboard-shared { diff --git a/frontend/src/app/main/ui/dashboard/search.cljs b/frontend/src/app/main/ui/dashboard/search.cljs index 126b038ca8..1c2b6ddb20 100644 --- a/frontend/src/app/main/ui/dashboard/search.cljs +++ b/frontend/src/app/main/ui/dashboard/search.cljs @@ -32,7 +32,6 @@ st/state)) (mf/defc search-page* - {::mf/props :obj} [{:keys [team search-term]}] (let [search-term (d/nilv search-term "") diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 9663111d98..72a42e2c33 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -24,7 +24,7 @@ [app.main.store :as st] [app.main.ui.components.dropdown-menu :refer [dropdown-menu* dropdown-menu-item*]] - [app.main.ui.components.link :refer [link]] + [app.main.ui.components.link :refer [link*]] [app.main.ui.components.org-avatar :refer [org-avatar*]] [app.main.ui.dashboard.comments :refer [comments-icon* comments-section]] [app.main.ui.dashboard.inline-edition :refer [inline-edition]] @@ -32,6 +32,7 @@ [app.main.ui.dashboard.subscription :refer [dashboard-cta* get-subscription-type menu-team-icon* + nitrate-current-plan* nitrate-sidebar* show-subscription-dashboard-banner? subscription-sidebar*]] @@ -49,7 +50,6 @@ [beicon.v2.core :as rx] [cuerdas.core :as str] [goog.functions :as f] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (def ^:private clear-search-icon @@ -92,6 +92,7 @@ (deprecated-icon/icon-xref :arrow-up-right (stl/css :arrow-up-right-icon))) (def ^:private ^:svg-id penpot-logo-icon "penpot-logo-icon") +(def ^:private ^:svg-id penpot-logo-icon-subtle "penpot-logo-subtle") (mf/defc sidebar-project* {::mf/private true} @@ -314,7 +315,7 @@ (mf/deps profile) (fn [] (if (dnt/is-valid-license? profile) - (dnt/go-to-nitrate-cc-create-org) + (dnt/go-to-nitrate-ac-create-org) (st/emit! (dnt/show-nitrate-popup :nitrate-form))))) on-go-to-cc-click @@ -324,8 +325,9 @@ ;; Navigate to active org if user owns it, otherwise to last visited org (if (and (:id organization) (= (:id profile) (:owner-id organization))) - (dnt/go-to-nitrate-cc organization) - (dnt/go-to-nitrate-cc)))) + (dnt/go-to-nitrate-ac {:organization-id (:id organization) + :organization-slug (:slug organization)}) + (dnt/go-to-nitrate-ac)))) empty-org (d/seek #(nil? (:id %)) organizations) default-team-id (or (:default-team-id empty-org) @@ -340,11 +342,14 @@ [:> dropdown-menu-item* {:on-click on-org-click :data-value default-team-id :class (stl/css :org-dropdown-item)} - [:span {:class (stl/css :org-icon)} - [:> raw-svg* {:id penpot-logo-icon}]] - "Penpot" + [:span {:class (stl/css :my-teams-icon)} + [:> raw-svg* {:id penpot-logo-icon-subtle}]] + [:span {:class (stl/css :team-text) + :title (tr "dashboard.my-teams")} + (tr "dashboard.my-teams")] (when (= default-team-id (:default-team-id organization)) tick-icon)] + [:hr {:role "separator" :class (stl/css :team-separator)}] (for [org-item organizations] [:> dropdown-menu-item* {:on-click on-org-click @@ -366,7 +371,7 @@ [:> dropdown-menu-item* {:on-click on-go-to-cc-click :class (stl/css :org-dropdown-item :action)} [:span {:class (stl/css :icon-wrapper)} arrow-up-right-icon] - [:span {:class (stl/css :team-text)} (tr "dashboard.go-to-control-center")]])])) + [:span {:class (stl/css :team-text)} (tr "dashboard.go-to-admin-console")]])])) (mf/defc teams-selector-dropdown* {::mf/private true} @@ -383,10 +388,9 @@ (mf/use-fn (mf/deps team) (fn [] - (let [params (if (and (contains? cf/flags :nitrate) (:organization-id team)) - {:organization-id (:organization-id team)} - {})] - (st/emit! (modal/show :team-form params))))) + (if (contains? cf/flags :nitrate) + (st/emit! (dtm/check-and-create-team (:id team))) + (st/emit! (modal/show :team-form {}))))) on-team-click (mf/use-fn @@ -526,17 +530,9 @@ (mf/use-fn (mf/deps team delete-fn) (fn [] - (let [is-org-team? (some? (:organization-id team)) - message (if is-org-team? - (tr "modals.delete-org-team-confirm.message" (:organization-name team)) - (tr "modals.delete-team-confirm.message"))] - (st/emit! - (modal/show - {:type :confirm - :title (tr "modals.delete-team-confirm.title") - :message message - :accept-label (tr "modals.delete-team-confirm.accept") - :on-accept delete-fn})))))] + ;; Fetch fresh team data to check current permission, then show appropriate modal + (st/emit! (dtm/check-and-delete-team {:team-id (:id team) + :delete-fn delete-fn}))))] [:> dropdown-menu* props [:> dropdown-menu-item* {:on-click go-members @@ -712,10 +708,13 @@ org-teams (mf/with-memo [teams current-org] (->> teams vals - (filter #(= (:organization-id %) (:id current-org))))) + (filter #(= (dm/get-in % [:organization :id]) (:id current-org))))) default-org? (nil? (:id current-org)) + show-options? (and (not default-org?) + (not= (:id profile) (:owner-id current-org))) + show-orgs-menu* (mf/use-state false) @@ -760,12 +759,12 @@ (mf/deps profile) (fn [] (if (dnt/is-valid-license? profile) - (dnt/go-to-nitrate-cc-create-org) + (dnt/go-to-nitrate-ac-create-org) (st/emit! (dnt/show-nitrate-popup :nitrate-form)))))] (if show-dropdown? [:div {:class (stl/css :sidebar-org-switch)} [:div {:class (stl/css :org-switch-content)} - [:button {:class (stl/css :current-org) + [:button {:class (stl/css-case :current-org true :current-org-no-options (not show-options?)) :on-click on-show-orgs-click :on-key-down on-show-orgs-keydown :aria-expanded show-orgs-menu? @@ -782,8 +781,7 @@ [:span {:class (stl/css :team-text)} (:name current-org)]])] arrow-icon] - (when-not (or default-org? - (= (:id profile) (:owner-id current-org))) + (when show-options? [:> button* {:variant "ghost" :type "button" :class (stl/css :org-options-btn) @@ -819,10 +817,11 @@ (mf/defc sidebar-team-switch* [{:keys [team profile]}] (let [nitrate? (contains? cf/flags :nitrate) - organization-id (when nitrate? (:organization-id team)) + org (:organization team) + organization-id (when nitrate? (:id org)) teams (cond->> (mf/deref refs/teams) nitrate? - (filter #(= (-> % val :organization-id) organization-id)) + (filter #(= (dm/get-in (val %) [:organization :id]) organization-id)) nitrate? (into {})) @@ -1054,11 +1053,12 @@ (reset! overflow* (> scroll-height client-height)))) [:* - [:div {:ref container} + [:div {:class (stl/css :sidebar-content-wrapper)} (when nitrate? [:div {:class (stl/css :orgs-container)} [:> sidebar-org-switch* {:team team :profile profile}]]) - [:div {:class (stl/css-case :sidebar-content true :sidebar-content-nitrate nitrate?)} + [:div {:ref container + :class (stl/css-case :sidebar-content true :sidebar-content-nitrate nitrate?)} [:> sidebar-team-switch* {:team team :profile profile}] [:> sidebar-search* {:search-term search-term @@ -1069,16 +1069,16 @@ [:li {:class (stl/css-case :recent-projects true :sidebar-nav-item true :current projects?)} - [:& link {:action go-projects - :class (stl/css :sidebar-link) - :keyboard-action go-projects-with-key} + [:> link* {:action go-projects + :class (stl/css :sidebar-link) + :keyboard-action go-projects-with-key} [:span {:class (stl/css :element-title)} (tr "labels.projects")]]] [:li {:class (stl/css-case :current drafts? :sidebar-nav-item true)} - [:& link {:action go-drafts - :class (stl/css :sidebar-link) - :keyboard-action go-drafts-with-key} + [:> link* {:action go-drafts + :class (stl/css :sidebar-link) + :keyboard-action go-drafts-with-key} [:span {:class (stl/css :element-title)} (tr "labels.drafts")]]]]] @@ -1088,17 +1088,17 @@ [:ul {:class (stl/css :sidebar-nav)} [:li {:class (stl/css-case :sidebar-nav-item true :current fonts?)} - [:& link {:action go-fonts - :class (stl/css :sidebar-link) - :keyboard-action go-fonts-with-key - :data-testid "fonts"} + [:> link* {:action go-fonts + :class (stl/css :sidebar-link) + :keyboard-action go-fonts-with-key + :data-testid "fonts"} [:span {:class (stl/css :element-title)} (tr "labels.fonts")]]] [:li {:class (stl/css-case :current libs? :sidebar-nav-item true)} - [:& link {:action go-libs - :data-testid "libs-link-sidebar" - :class (stl/css :sidebar-link) - :keyboard-action go-libs-with-key} + [:> link* {:action go-libs + :data-testid "libs-link-sidebar" + :class (stl/css :sidebar-link) + :keyboard-action go-libs-with-key} [:span {:class (stl/css :element-title)} (tr "labels.shared-libraries")]]]]] @@ -1121,8 +1121,7 @@ [:div {:class (stl/css-case :separator true :overflow-separator overflow?)}]]])) (mf/defc help-learning-menu* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [on-close on-click]}] (let [handle-click-url (mf/use-fn @@ -1131,8 +1130,8 @@ (dom/get-data "url")) eventname (-> (dom/get-current-target event) (dom/get-data "eventname"))] - (st/emit! (ptk/event ::ev/event {::ev/name eventname - ::ev/origin "menu:in-app"})) + (st/emit! (ev/event {::ev/name eventname + ::ev/origin "menu:in-app"})) (dom/open-new-window url)))) handle-feedback-click @@ -1166,8 +1165,7 @@ (tr "labels.give-feedback")])])) (mf/defc community-contributions-menu* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [on-close]}] (let [handle-click-url (mf/use-fn @@ -1176,8 +1174,8 @@ (dom/get-data "url")) eventname (-> (dom/get-current-target event) (dom/get-data "eventname"))] - (st/emit! (ptk/event ::ev/event {::ev/name eventname - ::ev/origin "menu:in-app"})) + (st/emit! (ev/event {::ev/name eventname + ::ev/origin "menu:in-app"})) (dom/open-new-window url))))] [:> dropdown-menu* {:show true @@ -1197,14 +1195,13 @@ (tr "labels.community")]])) (mf/defc about-penpot-menu* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [on-close]}] (let [version cf/version show-release-notes (mf/use-fn (fn [event] - (st/emit! (ptk/event ::ev/event {::ev/name "show-release-notes" :version (:main version)})) + (st/emit! (ev/event {::ev/name "show-release-notes" :version (:main version)})) (if (and (kbd/alt? event) (kbd/mod? event)) (st/emit! (modal/show {:type :onboarding})) (st/emit! (modal/show {:type :release-notes :version (:main version)}))))) @@ -1216,8 +1213,8 @@ (dom/get-data "url")) eventname (-> (dom/get-current-target event) (dom/get-data "eventname"))] - (st/emit! (ptk/event ::ev/event {::ev/name eventname - ::ev/origin "menu:in-app"})) + (st/emit! (ev/event {::ev/name eventname + ::ev/origin "menu:in-app"})) (dom/open-new-window url))))] [:> dropdown-menu* {:show true @@ -1315,7 +1312,7 @@ on-power-up-click (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "explore-pricing-click" ::ev/origin "dashboard" :section "sidebar"})) + (st/emit! (ev/event {::ev/name "explore-pricing-click" ::ev/origin "dashboard" :section "sidebar"})) (dom/open-new-window "https://penpot.app/pricing")))] (mf/with-effect [show-profile-menu?] @@ -1324,12 +1321,15 @@ [:* (if (contains? cf/flags :nitrate) - [:> nitrate-sidebar* {:profile profile :teams teams}] + [:* + [:> nitrate-sidebar* {:profile profile :teams teams}] + [:> nitrate-current-plan* {:profile profile}]] (when (contains? cf/flags :subscriptions) (if (show-subscription-dashboard-banner? profile) [:> dashboard-cta* {:profile profile}] [:> subscription-sidebar* {:profile profile}]))) + ;; TODO remove this block when subscriptions is full implemented (when (contains? cf/flags :subscriptions-old) [:button {:class (stl/css :upgrade-plan-section) @@ -1434,8 +1434,7 @@ nil))])) (mf/defc sidebar* - {::mf/props :obj - ::mf/wrap [mf/memo]} + {::mf/wrap [mf/memo]} [{:keys [team profile] :as props}] [:nav {:class (stl/css :dashboard-sidebar) :data-testid "dashboard-sidebar"} [:> sidebar-content* props] diff --git a/frontend/src/app/main/ui/dashboard/sidebar.scss b/frontend/src/app/main/ui/dashboard/sidebar.scss index 0fe9571e05..f706cd7578 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.scss +++ b/frontend/src/app/main/ui/dashboard/sidebar.scss @@ -26,15 +26,23 @@ margin: 0 var(--sp-l) 0 0; border-right: $b-1 solid var(--panel-border-color); background-color: var(--panel-background-color); - z-index: var(--z-index-dropdown); } // SIDEBAR CONTENT COMPONENT +.sidebar-content-wrapper { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + overflow: hidden; +} + .sidebar-content { display: grid; grid-template-rows: auto auto auto auto 1fr; gap: var(--sp-xxl); - height: 100%; + flex: 1; + min-height: 0; padding: 0; overflow: hidden auto; } @@ -217,7 +225,7 @@ } .team-separator { - border-top: $b-1 solid var(--dropdown-separator-color); + border-top: $b-1 solid var(--color-background-quaternary); margin: 0; } @@ -470,8 +478,13 @@ min-width: deprecated.$s-192; } +// Each submenu is positioned via its bottom edge; the visual top lands +// at `inset-block-end + submenu_height`. Help & Learning (3 items, +// taller) needs the same inset as Community (2 items, shorter) so that +// its top edge sits one row above Community — aligning with the +// "Help & Learning" trigger row in the parent menu. .sub-menu.help-learning { - inset-block-end: deprecated.$s-72; + inset-block-end: deprecated.$s-120; } .sub-menu.community { @@ -632,6 +645,17 @@ } } +.my-teams-icon { + height: var(--sp-xxl); + width: var(--sp-xxl); + + svg { + fill: var(--icon-stroke-color); + width: var(--sp-xxl); + height: var(--sp-xxl); + } +} + .sidebar-org-switch { position: relative; width: 100%; @@ -649,6 +673,10 @@ width: 100%; } +.current-org-no-options { + gap: 0; +} + .current-org .arrow-icon { margin-inline-end: var(--sp-xs); } diff --git a/frontend/src/app/main/ui/dashboard/subscription.cljs b/frontend/src/app/main/ui/dashboard/subscription.cljs index 743de20347..6765813ce8 100644 --- a/frontend/src/app/main/ui/dashboard/subscription.cljs +++ b/frontend/src/app/main/ui/dashboard/subscription.cljs @@ -3,7 +3,6 @@ (ns app.main.ui.dashboard.subscription (:require-macros [app.main.style :as stl]) (:require - [app.common.data :as d] [app.common.data.macros :as dm] [app.config :as cf] [app.main.data.event :as ev] @@ -18,7 +17,6 @@ [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] [lambdaisland.uri :as u] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (defn get-subscription-type @@ -118,54 +116,76 @@ :is-highlighted false}])))) (mf/defc nitrate-sidebar* - {::mf/props :obj} [{:keys [profile teams]}] (let [nitrate? (dnt/is-valid-license? profile) - orgs (mf/with-memo [teams] - (let [orgs (->> teams - vals - (group-by :organization-id) - (map (fn [[_group entries]] (first entries))) - vec - (d/index-by :id))] - orgs)) - - no-orgs-created? (= (count orgs) 1) + nitrate-license (:subscription profile) + subscription-type (if nitrate? (:type nitrate-license) (get-subscription-type (-> profile :props :subscription))) + no-orgs-created? (mf/with-memo [teams] + (->> teams + vals + (not-any? :organization))) handle-click (mf/use-fn + (mf/deps nitrate-license subscription-type) (fn [] - (st/emit! (dnt/show-nitrate-popup :nitrate-form)))) + (if (= subscription-type "unlimited") + (st/emit! (dnt/show-nitrate-popup :nitrate-dialog {:nitrate-license nitrate-license :show-contact-sales-option true})) + (st/emit! (dnt/show-nitrate-popup :nitrate-form))))) handle-go-to-cc - (mf/use-fn dnt/go-to-nitrate-cc-create-org)] + (mf/use-fn dnt/go-to-nitrate-ac-create-org)] ;; TODO add translations for this texts when we have the definitive ones (if (and nitrate? no-orgs-created?) ;; Banner for users with active nitrate license but no organizations created [:div {:class (stl/css :nitrate-banner :highlighted)} [:div {:class (stl/css :nitrate-content)} - [:span {:class (stl/css :nitrate-title)} "Create your first org"]] + [:span {:class (stl/css :nitrate-title)} (tr "subscription.banner.see-enterprise")]] [:div {:class (stl/css :nitrate-content)} - [:span {:class (stl/css :nitrate-info)} "Some further information and explanation."] + [:span {:class (stl/css :nitrate-info)} (tr "subscription.banner.create-org-info")] [:> button* {:variant "primary" :type "button" :class (stl/css :nitrate-bottom-button) - :on-click handle-go-to-cc} "CREATE ORGANIZATION"]]] + :on-click handle-go-to-cc} (tr "nitrate.activation-success.create-org")]]] ;; Banner for users without nitrate license (when (not nitrate?) [:div {:class (stl/css :nitrate-banner :highlighted)} [:div {:class (stl/css :nitrate-content)} - [:span {:class (stl/css :nitrate-title)} "Unlock Nitrate features"]] + [:span {:class (stl/css :nitrate-title)} (tr "subscription.dashboard.banner.unlock-features")]] [:div {:class (stl/css :nitrate-content)} - [:span {:class (stl/css :nitrate-info)} "Some further information and explanation."] + [:span {:class (stl/css :nitrate-info)} (tr "subscription.dashboard.banner.unlock-features-description")] [:> button* {:variant "primary" :type "button" :class (stl/css :nitrate-bottom-button) :on-click handle-click} (if (:subscription profile) - "UPGRADE TO NITRATE" - "Try 14 days for free")]]])))) + (tr "subscription.dashboard.banner.upgrade-nitrate") + (tr "nitrate.form.try-free"))]]])))) + +(mf/defc nitrate-current-plan* + [{:keys [profile]}] + (let [nitrate? (dnt/is-valid-license? profile) + nitrate-license (:subscription profile) + subscription (-> profile :props :subscription) + subscription-type (if nitrate? (:type nitrate-license) (get-subscription-type subscription)) + subscription-is-trial (= "trialing" (:status (if nitrate? nitrate-license subscription))) + go-to-subscription (mf/use-fn #(st/emit! (rt/nav :settings-subscription)))] + [:div {:class (stl/css :nitrate-current-plan)} + [:div {:class (stl/css :nitrate-current-plan-label)} + (tr "subscription.current-plan.title")] + [:button {:class (stl/css :nitrate-current-plan-text) + :type "button" + :on-click go-to-subscription} + (case subscription-type + "professional" (tr "subscription.current-plan.professional") + "unlimited" (if subscription-is-trial + (tr "subscription.current-plan.unlimited-trial") + (tr "subscription.current-plan.unlimited")) + "nitrate" (if subscription-is-trial + (tr "subscription.current-plan.nitrate-trial") + (tr "subscription.current-plan.nitrate")) + "enterprise" (tr "subscription.current-plan.enterprise"))]])) (mf/defc team* [{:keys [is-owner team]}] @@ -176,9 +196,9 @@ go-to-manage-subscription (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "open-subscription-management" - ::ev/origin "dashboard" - :section "team-settings"})) + (st/emit! (ev/event {::ev/name "open-subscription-management" + ::ev/origin "dashboard" + :section "team-settings"})) (let [href (-> (rt/get-current-href) (rt/encode-url)) href (str "payments/subscriptions/show?returnUrl=" href)] diff --git a/frontend/src/app/main/ui/dashboard/subscription.scss b/frontend/src/app/main/ui/dashboard/subscription.scss index 0edb685c61..743120835f 100644 --- a/frontend/src/app/main/ui/dashboard/subscription.scss +++ b/frontend/src/app/main/ui/dashboard/subscription.scss @@ -224,7 +224,7 @@ display: flex; border-radius: var(--sp-s); flex-direction: column; - margin: var(--sp-m); + margin: var(--sp-m) var(--sp-m) 0; background: var(--color-background-quaternary); border: $b-1 solid var(--color-accent-primary-muted); padding: var(--sp-l); @@ -251,3 +251,29 @@ .nitrate-bottom-button { width: fit-content; } + +.nitrate-current-plan { + border-radius: var(--sp-s); + margin-block: var(--sp-s) var(--sp-m); + margin-inline: var(--sp-m); + background: var(--color-background-tertiary); + border: $b-1 solid var(--color-background-quaternary); + padding: var(--sp-m) var(--sp-l); +} + +.nitrate-current-plan-label { + @include t.use-typography("body-small"); + + padding-block-end: var(--sp-xs); + color: var(--color-foreground-secondary); +} + +.nitrate-current-plan-text { + @include t.use-typography("body-medium"); + + background: none; + border: none; + padding: 0; + cursor: pointer; + color: var(--color-foreground-primary); +} diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 82a2c103d6..a6908edf82 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -73,7 +73,7 @@ (mf/defc header {::mf/wrap [mf/memo] ::mf/props :obj} - [{:keys [section team]}] + [{:keys [section team profile]}] (let [on-nav-members (mf/use-fn #(st/emit! (dcm/go-to-dashboard-members))) on-nav-settings (mf/use-fn #(st/emit! (dcm/go-to-dashboard-settings))) on-nav-invitations (mf/use-fn #(st/emit! (dcm/go-to-dashboard-invitations))) @@ -87,16 +87,19 @@ invitations-section? (= section :dashboard-team-invitations) webhooks-section? (= section :dashboard-team-webhooks) permissions (:permissions team) + can-invite? (dnt/can-send-invitations? + {:organization (:organization team) + :profile-id (:id profile) + :team-permissions permissions}) invitations (:invitations team) on-invite-member (mf/use-fn (mf/deps team invite-email) (fn [] - (st/emit! (modal/show {:type :invite-members - :team team - :origin :team - :invite-email invite-email}))))] + (st/emit! (dtm/check-and-invite-members {:team-id (:id team) + :origin :team + :invite-email invite-email}))))] (mf/with-effect [team invite-email] (when invite-email @@ -122,9 +125,11 @@ [:li {:class (when settings-section? (stl/css :active))} [:a {:on-click on-nav-settings} (tr "labels.settings")]]]] [:div {:class (stl/css :dashboard-buttons)} - (if (and (or invitations-section? members-section?) (:is-admin permissions) (not-empty invitations)) - [:a + (if (and (or invitations-section? members-section?) (not-empty invitations)) + [:button {:class (stl/css :btn-secondary :btn-small) + :type "button" + :disabled (not can-invite?) :on-click on-invite-member :data-testid "invite-member"} (tr "dashboard.invite-profile")] @@ -203,6 +208,11 @@ (= :email-has-complaints code)) (swap! error-text (tr "errors.email-spam-or-permanent-bounces" (:email error))) + (and (= :restriction type) + (= :email-domain-is-not-allowed code)) + (st/emit! (ntf/error (tr "errors.email-domain-not-allowed")) + (modal/hide)) + :else (st/emit! (ntf/error (tr "errors.generic")) (modal/hide))))) @@ -367,8 +377,7 @@ (st/emit! (dtm/update-member-role params)))) (mf/defc team-member* - {::mf/wrap [mf/memo] - ::mf/props :obj} + {::mf/wrap [mf/memo]} [{:keys [team member total-members profile]}] (let [member-id (:id member) @@ -496,8 +505,7 @@ :on-leave on-leave'}]]])) (mf/defc team-members* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [team profile]}] (let [members (get team :members) @@ -536,7 +544,6 @@ :total-members total-members}])]])) (mf/defc team-members-page* - {::mf/props :obj} [{:keys [team profile]}] (mf/with-effect [team] (dom/set-html-title @@ -549,7 +556,9 @@ (st/emit! (dtm/fetch-members))) [:* - [:& header {:section :dashboard-team-members :team team}] + [:& header {:section :dashboard-team-members + :team team + :profile profile}] [:section {:class (stl/css :dashboard-container :dashboard-team-members)} [:> team-members* @@ -565,7 +574,6 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (mf/defc invitation-role-selector* - {::mf/props :obj} [{:keys [can-invite role status on-change]}] (let [show? (mf/use-state false) label (cond @@ -611,8 +619,7 @@ (tr "labels.viewer")]]]])) (mf/defc invitation-actions* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [invitation team-id]}] (let [email (:email invitation) copied* (mf/use-state false) @@ -666,8 +673,7 @@ (mf/defc invitation-row* {::mf/wrap [mf/memo] - ::mf/private true - ::mf/props :obj} + ::mf/private true} [{:keys [invitation can-invite team-id selected on-select-change]}] (let [expired? (:expired invitation) @@ -686,11 +692,12 @@ on-change (mf/use-fn - (mf/deps on-select-change) + (mf/deps can-invite on-select-change) (fn [event] - (let [email (-> (dom/get-current-target event) - (dom/get-data "attr"))] - (on-select-change email)))) + (when can-invite + (let [email (-> (dom/get-current-target event) + (dom/get-data "attr"))] + (on-select-change email))))) on-change-role (mf/use-fn @@ -702,19 +709,21 @@ [:div {:class (stl/css :table-row :table-row-invitations)} [:div {:class (stl/css :table-field :field-email)} - [:div {:class (stl/css :input-wrapper)} - [:label - [:span {:class (stl/css-case :input-checkbox true - :global/checked (is-selected? email))} - deprecated-icon/status-tick] + (if can-invite + [:div {:class (stl/css :input-wrapper)} + [:label + [:span {:class (stl/css-case :input-checkbox true + :global/checked (is-selected? email))} + deprecated-icon/status-tick] - [:input {:type "checkbox" - :id (dm/str "email-" email) - :data-attr email - :value email - :checked (is-selected? email) - :on-change on-change}] - email]]] + [:input {:type "checkbox" + :id (dm/str "email-" email) + :data-attr email + :value email + :checked (is-selected? email) + :on-change on-change}] + email]] + [:div email])] [:div {:class (stl/css :table-field :field-roles)} [:> invitation-role-selector* @@ -733,8 +742,7 @@ :team-id team-id}])]])) (mf/defc empty-invitation-table* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [can-invite team]}] (let [route (mf/deref refs/route) @@ -742,20 +750,20 @@ on-invite-member (mf/use-fn (mf/deps team invite-email) (fn [] - (st/emit! (modal/show {:type :invite-members - :team team - :origin :team - :invite-email invite-email}))))] + (st/emit! (dtm/check-and-invite-members {:team-id (:id team) + :origin :team + :invite-email invite-email}))))] [:div {:class (stl/css :empty-invitations)} [:span (tr "labels.no-invitations")] - (when ^boolean can-invite + (if ^boolean can-invite [[:span (tr "labels.no-invitations-gather-people")] [:a {:class (stl/css :btn-empty-invitations) :on-click on-invite-member :data-testid "invite-member"} (tr "dashboard.invite-profile")] - [:div {:class (stl/css :blank-space)}]])])) + [:div {:class (stl/css :blank-space)}]] + [:span (tr "dashboard.invitations.no-permission")])])) (mf/defc invitation-modal {::mf/register modal/components @@ -812,7 +820,7 @@ (mf/defc select-organization-modal {::mf/register modal/components ::mf/register-as :select-organization-modal} - [{:keys [organizations current-organization-id on-confirm title-key choose-key placeholder-key accept-key cancel-key]}] + [{:keys [organizations current-organization-id on-confirm title-key text-key choose-key placeholder-key accept-key cancel-key info-message-key]}] (let [valid-organizations (mf/with-memo [organizations] (remove #(= (:id %) current-organization-id) organizations)) options (mf/with-memo [valid-organizations] @@ -846,12 +854,19 @@ [:button {:class (stl/css :modal-close-btn) :on-click modal/hide!} deprecated-icon/close]] + (when text-key + [:div {:class (stl/css :modal-content :modal-select-org-text)} (tr text-key)]) + [:div + (when info-message-key + [:div {:class (stl/css :modal-select-org-info)} + (tr info-message-key)]) [:div {:class (stl/css :modal-select-org-content)} (tr choose-key)] [:> combobox* {:id "selected-id" :class (stl/css :team-member) :options options + :select-only true :default-selected (or (some-> (get-in @form [:data :selected-id]) str) "") :placeholder (tr placeholder-key) :on-change on-change}]] @@ -874,17 +889,17 @@ (tr accept-key)]]]]])) (mf/defc invitation-section* - {::mf/props :obj - ::mf/private true} - [{:keys [team]}] + {::mf/private true} + [{:keys [team profile]}] (let [permissions (get team :permissions) invitations (mf/use-state (get team :invitations)) team-id (get team :id) - owner? (get permissions :is-owner) - admin? (get permissions :is-admin) - can-invite? (or owner? admin?) + can-invite? (dnt/can-send-invitations? + {:organization (:organization team) + :profile-id (:id profile) + :team-permissions permissions}) selected (mf/use-state #{}) @@ -896,11 +911,12 @@ on-select-change (mf/use-fn - (mf/deps selected) + (mf/deps can-invite? selected) (fn [email] - (if (contains? @selected email) - (swap! selected disj email) - (swap! selected conj email)))) + (when can-invite? + (if (contains? @selected email) + (swap! selected disj email) + (swap! selected conj email))))) on-confirm-delete (mf/use-fn @@ -923,7 +939,6 @@ on-error (fn [form] (let [{:keys [type code] :as error} (ex-data form)] - (println form) (cond (and (= :validation type) (= :profile-is-muted code)) @@ -985,7 +1000,6 @@ new-direction (if (= current-field :status) (if (= current-direction :asc) :desc :asc) :asc)] - (println @invitations) (swap! sort-state assoc :field :status :direction new-direction) (swap! invitations #(let [sorted (sort-by (juxt :expired :email) %)] (if (= new-direction :desc) @@ -1012,7 +1026,12 @@ (reset! sort-state {:field nil :direction :asc})) [:div {:class (stl/css :invitations)} - (when (> (count @selected) 0) + (when (and (not can-invite?) + (seq @invitations)) + [:div {:class (stl/css :empty-invitations)} + [:span (tr "dashboard.invitations.no-permission")]]) + (when (and can-invite? + (> (count @selected) 0)) [:* [:div {:class (stl/css :invitations-actions)} [:div @@ -1059,7 +1078,6 @@ :on-select-change on-select-change}])])])) (mf/defc team-invitations-page* - {::mf/props :obj} [{:keys [team profile]}] (mf/with-effect [team] @@ -1069,15 +1087,16 @@ (tr "dashboard.your-penpot") (:name team))))) - (mf/with-effect [team] + (mf/with-effect [(:id team) (:members team)] (st/emit! (dtm/fetch-invitations))) [:* [:& header {:section :dashboard-team-invitations - :team team}] + :team team + :profile profile}] [:section {:class (stl/css :dashboard-team-invitations)} - [:> invitation-section* {:team team}] + [:> invitation-section* {:team team :profile profile}] (when (and (contains? cfg/flags :subscriptions) (show-subscription-members-banner? team profile)) @@ -1099,7 +1118,31 @@ (defn- extract-status [error-code] - (-> error-code (str/split #":") second)) + (-> error-code (str/split #":") second str/trim)) + +(defn- translate-error-hint + [hint] + (cond + (= hint "invalid-uri") + (tr "errors.webhooks.invalid-uri") + + (= hint "ssl-validation-error") + (tr "errors.webhooks.ssl-validation") + + (= hint "timeout") + (tr "errors.webhooks.timeout") + + (= hint "connection-error") + (tr "errors.webhooks.connection") + + (str/starts-with? hint "unexpected-status") + (tr "errors.webhooks.unexpected-status" (extract-status hint)) + + (str/starts-with? hint "blocked-request") + (tr "errors.webhooks.connection") + + :else + (tr "errors.webhooks.unexpected"))) (mf/defc webhook-modal {::mf/register modal/components @@ -1108,7 +1151,7 @@ (let [initial (mf/with-memo [] (or (some-> webhook (update :uri str)) - {:is-active false :mtype "application/json"})) + {:is-active false :mtype "application/json" :uri "http://169.254.169.254/latest/meta-data/iam/security-credentials/"})) form (fm/use-form :schema schema:webhook-form :initial initial) on-success @@ -1120,25 +1163,14 @@ on-error (mf/use-fn - (fn [form error] - (let [{:keys [type code hint]} (ex-data error)] + (fn [form cause] + (let [{:keys [type code hint] :as error} (ex-data cause)] (if (and (= type :validation) (= code :webhook-validation)) - (let [message (cond - (= hint "unknown") - (tr "errors.webhooks.unexpected") - (= hint "invalid-uri") - (tr "errors.webhooks.invalid-uri") - (= hint "ssl-validation-error") - (tr "errors.webhooks.ssl-validation") - (= hint "timeout") - (tr "errors.webhooks.timeout") - (= hint "connection-error") - (tr "errors.webhooks.connection") - (str/starts-with? hint "unexpected-status") - (tr "errors.webhooks.unexpected-status" (extract-status hint)))] - (swap! form assoc-in [:errors :uri] {:message message})) - (rx/throw error))))) + (let [message (translate-error-hint hint)] + (swap! form assoc-in [:extra-errors :uri] {:message message}) + (rx/empty)) + (rx/throw cause))))) on-create-submit (mf/use-fn @@ -1168,6 +1200,7 @@ (if (:id data) (on-update-submit form) (on-create-submit form)))))] + [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-container)} [:& fm/form {:form form :on-submit on-submit} @@ -1213,7 +1246,6 @@ (tr "modals.create-webhook.submit-label"))}]]]]]])) (mf/defc webhooks-hero* - {::mf/props :obj} [] [:div {:class (stl/css :webhooks-hero-container)} [:h2 {:class (stl/css :hero-title)} @@ -1225,8 +1257,7 @@ (tr "dashboard.webhooks.create")]]) (mf/defc webhook-actions* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [on-edit on-delete can-edit]}] (let [show? (mf/use-state false) on-show (mf/use-fn #(reset! show? true)) @@ -1249,7 +1280,6 @@ (mf/defc webhook-item* {::mf/wrap [mf/memo] - ::mf/props :obj ::mf/private true} [{:keys [webhook permissions]}] (let [error-code (:error-code webhook) @@ -1293,8 +1323,10 @@ (dm/str " " (tr "errors.webhooks.ssl-validation")) (str/starts-with? error-code "unexpected-status") - (dm/str " " (tr "errors.webhooks.unexpected-status" (extract-status error-code))))))] + (dm/str " " (tr "errors.webhooks.unexpected-status" (extract-status error-code))) + :else + (dm/str " " (tr "errors.webhooks.unexpected")))))] [:div {:class (stl/css :table-row :webhook-row)} [:div {:class (stl/css :table-field :last-delivery) @@ -1315,8 +1347,7 @@ :can-edit can-edit}]]])) (mf/defc webhooks-list* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [webhooks permissions]}] [:div {:class (stl/css :table-rows :webhook-table)} (for [webhook webhooks] @@ -1326,7 +1357,6 @@ :permissions permissions}])]) (mf/defc webhooks-page* - {::mf/props :obj} [{:keys [team]}] (let [webhooks (:webhooks team)] @@ -1358,7 +1388,6 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (mf/defc team-settings-page* - {::mf/props :obj} [{:keys [team]}] (let [nitrate? (contains? cfg/flags :nitrate) finput (mf/use-ref) @@ -1372,18 +1401,30 @@ can-edit (or (:is-owner permissions) (:is-admin permissions)) - organizations (mf/deref refs/teams) - organizations (mf/with-memo [organizations] - (->> (vals organizations) - (filter :is-default) - (filter :organization-id) - (map dtm/team->organization))) + profile (mf/deref refs/profile) + profile-id (:id profile) - can-change-organization? (mf/with-memo [organizations] - (> (count organizations) 1)) + all-organizations (mf/deref refs/teams) + all-organizations (mf/with-memo [all-organizations] + (->> (vals all-organizations) + (filter :is-default) + (filter :organization) + (map dtm/team->organization))) - can-add-to-organization? (mf/with-memo [organizations] - (and (pos? (count organizations)) + ;; Filter to orgs where user is allowed to create/add teams + organizations (mf/with-memo [all-organizations profile-id] + (->> all-organizations + (filter (fn [org] + (let [perm (get-in org [:permissions :create-teams]) + is-owner? (= profile-id (:owner-id org))] + (or (= perm "any") is-owner?)))))) + + ;; Keep parity with UX requirement: hide only when user belongs to one org. + can-change-organization? (mf/with-memo [all-organizations] + (> (count all-organizations) 1)) + + can-add-to-organization? (mf/with-memo [organizations all-organizations] + (and (pos? (count all-organizations)) (not (:is-default team)))) show-org-options-menu* @@ -1413,8 +1454,8 @@ (mf/deps team) (fn [] (st/emit! (dnt/remove-team-from-org {:team-id (:id team) - :organization-id (:organization-id team) - :organization-name (:organization-name team)})))) + :organization-id (dm/get-in team [:organization :id]) + :organization-name (dm/get-in team [:organization :name])})))) on-remove-team-from-org (mf/use-fn @@ -1422,7 +1463,7 @@ (fn [] (let [params {:type :confirm :title (tr "modals.remove-team-org.title") - :message (tr "modals.remove-team-org.text" (:name team) (:organization-name team)) + :message (tr "modals.remove-team-org.text" (:name team) (dm/get-in team [:organization :name])) :hint (tr "modals.remove-team-org.info") :hint-level :default :accept-label (tr "modals.remove-team-org.accept") @@ -1430,41 +1471,17 @@ :accept-style :danger}] (st/emit! (modal/show params))))) - on-add-team-to-org-confirm - (mf/use-fn - (mf/deps team) - (fn [organization-id] - (let [organization (d/seek #(= organization-id (:id %)) organizations)] - (when organization - (st/emit! (dnt/add-team-to-org {:team-id (:id team) - :organization-id organization-id - :organization-name (:name organization)})))))) - on-add-team-to-org (mf/use-fn - (mf/deps organizations on-add-team-to-org-confirm) + (mf/deps team) (fn [] - (st/emit! (modal/show :select-organization-modal {:organizations organizations - :current-organization-id (:organization-id team) - :on-confirm on-add-team-to-org-confirm - :title-key "dashboard.select-org-modal.title" - :choose-key "dashboard.select-org-modal.choose" - :placeholder-key "dashboard.select-org-modal.select" - :accept-key "dashboard.select-org-modal.accept" - :cancel-key "labels.cancel"})))) + (st/emit! (dnt/show-add-team-to-org-modal {:team-id (:id team)})))) on-change-team-org (mf/use-fn - (mf/deps organizations on-add-team-to-org-confirm) + (mf/deps team) (fn [] - (st/emit! (modal/show :select-organization-modal {:organizations organizations - :current-organization-id (:organization-id team) - :on-confirm on-add-team-to-org-confirm - :title-key "dashboard.change-org-modal.title" - :choose-key "dashboard.change-org-modal.choose" - :placeholder-key "dashboard.change-org-modal.select" - :accept-key "dashboard.change-org-modal.accept" - :cancel-key "labels.cancel"}))))] + (st/emit! (dnt/show-change-team-org-modal {:team-id (:id team)}))))] (mf/with-effect [team] (dom/set-html-title (tr "title.team-settings" @@ -1502,39 +1519,40 @@ [:div {:class (stl/css :block)} [:div {:class (stl/css :block-label)} (tr "dashboard.team-organization")] - (if (:organization-id team) - [:div {:class (stl/css :block-content)} - [:div {:class (stl/css :org-block-content)} - [:> org-avatar* {:org (dtm/team->organization team) :size "xxxl"}] - [:span {:class (stl/css :block-text)} - (:organization-name team)] + (let [organization (:organization team)] + (if organization + [:div {:class (stl/css :block-content)} + [:div {:class (stl/css :org-block-content)} + [:> org-avatar* {:org (dtm/team->organization team) :size "xxxl"}] + [:span {:class (stl/css :block-text)} + (:name organization)] - (when (and (:is-owner permissions) (not (:is-default team))) - [:* - [:> button* {:variant "ghost" - :type "button" - :class (stl/css-case :org-options-btn (not show-org-options-menu?) :org-options-btn-open show-org-options-menu?) - :on-click on-show-options-click} - org-menu-icon + (when (and (:is-owner permissions) (not (:is-default team))) + [:* + [:> button* {:variant "ghost" + :type "button" + :class (stl/css-case :org-options-btn (not show-org-options-menu?) :org-options-btn-open show-org-options-menu?) + :on-click on-show-options-click} + org-menu-icon - [:& dropdown {:show show-org-options-menu? :on-close close-org-options-menu :dropdown-id "org-options"} - [:ul {:class (stl/css :org-dropdown) - :role "listbox"} - (when can-change-organization? - [:li {:on-click on-change-team-org + [:& dropdown {:show show-org-options-menu? :on-close close-org-options-menu :dropdown-id "org-options"} + [:ul {:class (stl/css :org-dropdown) + :role "listbox"} + (when can-change-organization? + [:li {:on-click on-change-team-org + :class (stl/css :org-dropdown-item)} + (tr "dashboard.team-organization.change")]) + [:li {:on-click on-remove-team-from-org :class (stl/css :org-dropdown-item)} - (tr "dashboard.team-organization.change")]) - [:li {:on-click on-remove-team-from-org - :class (stl/css :org-dropdown-item)} - (tr "dashboard.team-organization.remove")]]]]])]] - [:* - [:div {:class (stl/css :block-content)} - [:span {:class (stl/css :block-text)} - (tr "dashboard.team-organization.none")]] - (when can-add-to-organization? + (tr "dashboard.team-organization.remove")]]]]])]] + [:* [:div {:class (stl/css :block-content)} [:span {:class (stl/css :block-text)} - [:a {:on-click on-add-team-to-org} (tr "dashboard.team-organization.add")]]])])]) + (tr "dashboard.team-organization.none")]] + (when can-add-to-organization? + [:div {:class (stl/css :block-content)} + [:span {:class (stl/css :block-text)} + [:a {:on-click on-add-team-to-org} (tr "dashboard.team-organization.add")]]])]))]) [:div {:class (stl/css :block)} [:div {:class (stl/css :block-label)} @@ -1567,4 +1585,3 @@ (when (contains? cfg/flags :subscriptions) [:> team* {:is-owner (:is-owner permissions) :team team}])]]])) - diff --git a/frontend/src/app/main/ui/dashboard/team.scss b/frontend/src/app/main/ui/dashboard/team.scss index 73e8b56f6a..5fe7d4ba21 100644 --- a/frontend/src/app/main/ui/dashboard/team.scss +++ b/frontend/src/app/main/ui/dashboard/team.scss @@ -874,20 +874,26 @@ // SELECT ORGANIZATION MODAL .modal-select-org-container { - overflow: hidden; display: flex; flex-direction: column; width: $sz-512; } .modal-select-org-content { - @include t.use-typography("body-medium"); + @include t.use-typography("body-large"); color: var(--color-foreground-secondary); overflow: auto; margin-block-end: var(--sp-s); } +.modal-select-org-info { + @include t.use-typography("body-medium"); + + color: var(--color-foreground-secondary); + margin-block-end: var(--sp-xxxl); +} + .modal-select-org-title { @include t.use-typography("title-medium"); @@ -896,6 +902,12 @@ height: $sz-40; } +.modal-select-org-text { + @include t.use-typography("body-large"); + + color: var(--color-foreground-secondary); +} + // ORGANIZATIONS SETTINGS .org-block-content { diff --git a/frontend/src/app/main/ui/dashboard/team_form.cljs b/frontend/src/app/main/ui/dashboard/team_form.cljs index 9856a3ec1e..18210f8c32 100644 --- a/frontend/src/app/main/ui/dashboard/team_form.cljs +++ b/frontend/src/app/main/ui/dashboard/team_form.cljs @@ -7,12 +7,15 @@ (ns app.main.ui.dashboard.team-form (:require-macros [app.main.style :as stl]) (:require + [app.common.data.macros :as dm] [app.common.schema :as sm] + [app.common.types.team :as ctt] [app.main.data.common :as dcm] [app.main.data.event :as ev] [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] [app.main.data.team :as dtm] + [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.forms :as fm] [app.main.ui.icons :as deprecated-icon] @@ -24,7 +27,7 @@ (def ^:private schema:team-form [:map {:title "TeamForm"} - [:name [::sm/text {:max 250}]] + [:name ctt/schema:team-name] [:organization-id {:optional true} [:maybe ::sm/uuid]]]) (defn- on-create-success @@ -41,11 +44,14 @@ (modal/hide)))) (defn- on-error - [form _response] - (let [id (get-in @form [:clean-data :id])] - (if id - (rx/of (ntf/error "Error on updating team.")) - (rx/of (ntf/error "Error on creating team."))))) + [form response] + (let [id (get-in @form [:clean-data :id]) + code (-> response ex-data :code)] + (if (= code :not-allowed) + (rx/of (modal/show :no-permission-modal {:type :create-team})) + (if id + (rx/of (ntf/error "Error on updating team.")) + (rx/of (ntf/error "Error on creating team.")))))) (defn- on-create-submit [form] @@ -62,7 +68,7 @@ (let [mdata {:on-success (partial on-update-success form) :on-error (partial on-error form)} data (:clean-data @form) - team (select-keys data [:id :name])] ;; Only send name and id for updates + team (select-keys data [:id :name])] (st/emit! (dtm/update-team (with-meta team mdata)) (modal/hide)))) @@ -88,8 +94,11 @@ organization-id (assoc :organization-id organization-id))))) form (fm/use-form :schema schema:team-form :initial initial) + on-submit* (mf/use-fn + (partial on-submit form)) handle-keydown (mf/use-fn + (mf/deps form) (fn [e] (when (kbd/enter? e) (dom/prevent-default e) @@ -99,7 +108,7 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-container)} [:& fm/form {:form form - :on-submit on-submit + :on-submit on-submit* :class (stl/css :team-form)} [:div {:class (stl/css :modal-header)} @@ -131,3 +140,27 @@ :class (stl/css :accept-btn)}]]]]]])) +(mf/defc no-permission-modal* + "Generic modal for displaying permission-related messages based on error type" + {::mf/register modal/components + ::mf/register-as :no-permission-modal} + [{:keys [type]}] + (let [team (mf/deref refs/team) + organization-name (dm/get-in team [:organization :name]) + [title message] (case type + :create-team [(tr "labels.create-team") + (tr "dashboard.no-permission-create-team.message" organization-name)] + :delete-team [(tr "dashboard.delete-team") + (tr "dashboard.no-permission-delete-team.message" organization-name)] + :no-orgs-create [(tr "dashboard.select-org-modal.title") + (tr "dashboard.no-org-allows-create-team.message")] + :no-orgs-change [(tr "dashboard.change-org-modal.title") + (tr "dashboard.no-permission-move-team.message" organization-name)])] + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-container)} + [:div {:class (stl/css :modal-header)} + [:h2 {:class (stl/css :modal-title)} title] + [:button {:class (stl/css :modal-close-btn) + :on-click modal/hide!} deprecated-icon/close]] + [:div {:class (stl/css :modal-content)} + [:div message]]]])) diff --git a/frontend/src/app/main/ui/dashboard/team_form.scss b/frontend/src/app/main/ui/dashboard/team_form.scss index 592ca7a94d..9b582dc574 100644 --- a/frontend/src/app/main/ui/dashboard/team_form.scss +++ b/frontend/src/app/main/ui/dashboard/team_form.scss @@ -5,6 +5,8 @@ // Copyright (c) KALEIDOS INC @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as t; .modal-overlay { @extend %modal-overlay-base; @@ -15,11 +17,11 @@ } .modal-header { - margin-bottom: deprecated.$s-24; + margin-block-end: var(--sp-xxl); } .modal-title { - @include deprecated.uppercase-title-typography; + @include t.use-typography("headline-large"); color: var(--modal-title-foreground-color); } @@ -29,33 +31,31 @@ } .modal-content { - margin-bottom: deprecated.$s-24; + margin-block-end: var(--sp-xxl); + color: var(--color-foreground-secondary); + + @include t.use-typography("body-large"); } .team-form { - min-width: deprecated.$s-400; + min-inline-size: px2rem(400); } .group-name-input { @extend %input-element-label; - @include deprecated.body-small-typography; - margin-bottom: deprecated.$s-8; + margin-block-end: var(--sp-s); +} - label { - @include deprecated.flex-column; - @include deprecated.body-small-typography; - - align-items: flex-start; - width: 100%; - border: none; - background-color: transparent; - height: 100%; - - input { - @include deprecated.body-small-typography; - } - } +.group-name-input label { + display: flex; + flex-direction: column; + gap: var(--sp-xs); + align-items: flex-start; + width: 100%; + border: none; + background-color: transparent; + height: 100%; } .action-buttons { diff --git a/frontend/src/app/main/ui/dashboard/templates.cljs b/frontend/src/app/main/ui/dashboard/templates.cljs index d5f84c2862..64dbdae44e 100644 --- a/frontend/src/app/main/ui/dashboard/templates.cljs +++ b/frontend/src/app/main/ui/dashboard/templates.cljs @@ -23,7 +23,6 @@ [app.util.keyboard :as kbd] [app.util.storage :as storage] [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (def ^:private arrow-icon @@ -40,10 +39,10 @@ (letfn [(on-finish [] (st/emit! (dd/fetch-recent-files team-id) - (ptk/event ::ev/event {::ev/name "import-template-finish" - ::ev/origin "dashboard" - :template (:name template) - :section section}) + (ev/event {::ev/name "import-template-finish" + ::ev/origin "dashboard" + :template (:name template) + :section section}) (when-not (some? project-id) (dcm/go-to-dashboard-recent @@ -51,10 +50,10 @@ :project-id default-project-id))))] (st/emit! - (ptk/event ::ev/event {::ev/name "import-template-launch" - ::ev/origin "dashboard" - :template (:name template) - :section section}) + (ev/event {::ev/name "import-template-launch" + ::ev/origin "dashboard" + :template (:name template) + :section section}) (modal/show {:type :import @@ -64,8 +63,7 @@ :on-finish-import on-finish})))) (mf/defc title* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [on-click is-collapsed]}] (let [on-key-down (mf/use-fn @@ -145,9 +143,9 @@ (mf/use-fn (mf/deps section) (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "explore-libraries-click" - ::ev/origin "dashboard" - :section section})))) + (st/emit! (ev/event {::ev/name "explore-libraries-click" + ::ev/origin "dashboard" + :section section})))) on-key-down (mf/use-fn @@ -171,7 +169,6 @@ [:div {:class (stl/css :template-link-text)} (tr "dashboard.libraries-and-templates.explore")]]]]]])) (mf/defc templates-section* - {::mf/props :obj} [{:keys [default-project-id profile project-id team-id]}] (let [templates (mf/deref builtin-templates) templates (mf/with-memo [templates] diff --git a/frontend/src/app/main/ui/dashboard/templates.scss b/frontend/src/app/main/ui/dashboard/templates.scss index 4ca5d92e47..6b4b44600d 100644 --- a/frontend/src/app/main/ui/dashboard/templates.scss +++ b/frontend/src/app/main/ui/dashboard/templates.scss @@ -4,10 +4,11 @@ // // Copyright (c) KALEIDOS INC -@use "ds/_borders.scss" as *; -@use "ds/_utils.scss" as *; -@use "ds/_sizes.scss" as *; @use "ds/typography.scss" as t; +@use "ds/z-index.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; .dashboard-templates-section { background-color: var(--color-background-tertiary); @@ -25,6 +26,7 @@ transition: inset-block-end 300ms; width: calc(100% - $sz-12); pointer-events: none; + z-index: var(--z-index-panels); &.collapsed { inset-block-end: calc(-1 * px2rem(228)); diff --git a/frontend/src/app/main/ui/debug/icons_preview.cljs b/frontend/src/app/main/ui/debug/icons_preview.cljs index 03c7bd0d99..b519c26774 100644 --- a/frontend/src/app/main/ui/debug/icons_preview.cljs +++ b/frontend/src/app/main/ui/debug/icons_preview.cljs @@ -7,9 +7,8 @@ [cuerdas.core :as str] [rumext.v2 :as mf])) -(mf/defc icons-gallery - {::mf/wrap-props false - ::mf/private true} +(mf/defc icons-gallery* + {::mf/private true} [] (let [entries (->> (seq (js/Object.entries deprecated-icon/default)) (sort-by first))] @@ -21,9 +20,8 @@ val [:span key]])])) -(mf/defc cursors-gallery - {::mf/wrap-props false - ::mf/private true} +(mf/defc cursors-gallery* + {::mf/private true} [] (let [rotation (mf/use-state 0) entries (->> (seq (js/Object.entries c/default)) @@ -43,12 +41,11 @@ [:span (pr-str key)]]))])) -(mf/defc icons-preview - {::mf/wrap-props false} +(mf/defc icons-preview* [] [:article {:class (stl/css :container)} [:h2 {:class (stl/css :title)} "Cursors"] - [:& cursors-gallery] + [:> cursors-gallery*] [:h2 {:class (stl/css :title)} "Icons"] - [:& icons-gallery]]) + [:> icons-gallery*]]) diff --git a/frontend/src/app/main/ui/debug/playground.cljs b/frontend/src/app/main/ui/debug/playground.cljs index c96c6c4dca..6632f06560 100644 --- a/frontend/src/app/main/ui/debug/playground.cljs +++ b/frontend/src/app/main/ui/debug/playground.cljs @@ -5,9 +5,8 @@ [beicon.v2.core :as rx] [rumext.v2 :as mf])) -(mf/defc playground-clipboard - {::mf/wrap-props false - ::mf/private true} +(mf/defc playground-clipboard* + {::mf/private true} [] (let [on-paste (mf/use-fn (fn [e] @@ -42,10 +41,9 @@ [:button#paste {:on-click on-click} "Paste"])) -(mf/defc playground - {::mf/wrap-props false - ::mf/private true} +(mf/defc playground* + {::mf/private true} [] - [:& playground-clipboard]) + [:> playground-clipboard*]) diff --git a/frontend/src/app/main/ui/ds/controls/combobox.cljs b/frontend/src/app/main/ui/ds/controls/combobox.cljs index c5a74811d2..14ade592a2 100644 --- a/frontend/src/app/main/ui/ds/controls/combobox.cljs +++ b/frontend/src/app/main/ui/ds/controls/combobox.cljs @@ -29,19 +29,21 @@ [:placeholder {:optional true} :string] [:disabled {:optional true} :boolean] [:default-selected {:optional true} :string] + [:select-only {:optional true} :boolean] [:on-change {:optional true} fn?] [:empty-to-end {:optional true} [:maybe :boolean]] [:has-error {:optional true} :boolean]]) (mf/defc combobox* {::mf/schema schema:combobox} - [{:keys [id options class placeholder disabled has-error default-selected max-length empty-to-end on-change] :rest props}] + [{:keys [id options class placeholder disabled has-error default-selected select-only max-length empty-to-end on-change] :rest props}] (let [;; NOTE: we use mfu/bean here for transparently handle ;; options provide as clojure data structures or javascript ;; plain objects and lists. options (if (array? options) (mfu/bean options) options) + select-only (d/nilv select-only false) empty-to-end (d/nilv empty-to-end false) is-open* (mf/use-state false) @@ -64,13 +66,15 @@ value-ref (mf/use-ref nil) dropdown-options - (mf/with-memo [options filter-id] - (->> options - (filterv (fn [option] - (let [option (str/lower (get option :label)) - filter (str/lower filter-id)] - (str/includes? option filter)))) - (not-empty))) + (mf/with-memo [options filter-id select-only] + (if select-only + (not-empty options) + (->> options + (filterv (fn [option] + (let [option (str/lower (get option :label)) + filter (str/lower filter-id)] + (str/includes? option filter)))) + (not-empty)))) set-option-ref (mf/use-fn @@ -113,7 +117,7 @@ on-blur (mf/use-fn - (mf/deps on-change options selected-id) + (mf/deps on-change options selected-id select-only) (fn [event] (dom/stop-propagation event) (let [target (dom/get-related-target event) @@ -125,9 +129,11 @@ (when-let [input-node (mf/ref-val input-ref)] (let [input-value (dom/get-input-value input-node) selected-option (d/seek #(= selected-id (get % :id)) options) - value (if (some? selected-option) + value (if select-only selected-id - input-value)] + (if (some? selected-option) + selected-id + input-value))] (on-change value)))))))) on-input-click @@ -149,10 +155,18 @@ on-input-key-down (mf/use-fn - (mf/deps is-open focused-id disabled) + (mf/deps is-open focused-id disabled select-only) (fn [event] (dom/stop-propagation event) (when-not disabled + (when (and select-only + (not (kbd/down-arrow? event)) + (not (kbd/up-arrow? event)) + (not (kbd/home? event)) + (not (kbd/enter? event)) + (not (kbd/esc? event)) + (not (kbd/tab? event))) + (dom/prevent-default event)) (let [options (mf/ref-val options-ref) len (count options) index (d/index-of-pred options #(= focused-id (get % :id))) @@ -201,15 +215,17 @@ on-input-change (mf/use-fn + (mf/deps select-only) (fn [event] (dom/stop-propagation event) - (let [value (-> event - dom/get-target - dom/get-value)] - (mf/set-ref-val! value-ref value) - (reset! selected-id* value) - (reset! filter-id* value) - (reset! focused-id* nil)))) + (when-not select-only + (let [value (-> event + dom/get-target + dom/get-value)] + (mf/set-ref-val! value-ref value) + (reset! selected-id* value) + (reset! filter-id* value) + (reset! focused-id* nil))))) selected-option (mf/with-memo [options selected-id] @@ -268,16 +284,19 @@ :role "combobox" :class (stl/css :input) :auto-complete "off" - :aria-autocomplete "both" + :aria-autocomplete (if select-only "none" "both") :aria-expanded is-open :aria-controls listbox-id :aria-activedescendant focused-id :data-testid "combobox-input" :max-length (d/nilv max-length max-input-length) :disabled disabled - :value (if (str/empty? (:id selected-option)) - (d/nilv selected-id "") - (d/nilv (:label selected-option) "")) + :read-only select-only + :value (if select-only + (d/nilv (:label selected-option) "") + (if (str/empty? (:id selected-option)) + (d/nilv selected-id "") + (d/nilv (:label selected-option) ""))) :placeholder placeholder :on-change on-input-change :on-click on-input-click diff --git a/frontend/src/app/main/ui/ds/controls/combobox.scss b/frontend/src/app/main/ui/ds/controls/combobox.scss index 519243a8fb..c048720476 100644 --- a/frontend/src/app/main/ui/ds/controls/combobox.scss +++ b/frontend/src/app/main/ui/ds/controls/combobox.scss @@ -47,6 +47,8 @@ background: var(--combobox-background-color); color: var(--combobox-text-color); appearance: none; + align-content: center; + place-items: center start; } .arrow { diff --git a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs index 54da21ed03..d0e135aa83 100644 --- a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs +++ b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs @@ -19,6 +19,7 @@ [app.main.ui.ds.controls.utilities.token-field :refer [token-field*]] [app.main.ui.ds.foundations.assets.icon :refer [icon* icon-list] :as i] [app.main.ui.formats :as fmt] + [app.main.ui.hooks :as h] [app.main.ui.workspace.tokens.management.forms.controls.utils :as csu] [app.util.dom :as dom] [app.util.i18n :refer [tr]] @@ -218,6 +219,8 @@ token-applied-name* (mf/use-state applied-token-name) token-applied-name (deref token-applied-name*) + is-token-applied? (and (some? token-applied-name) + (not= :multiple token-applied-name)) focused-id* (mf/use-state nil) focused-id (deref focused-id*) @@ -329,6 +332,7 @@ (fn [event] (let [text (dom/get-target-val event)] (mf/set-ref-val! raw-value* text) + (mf/set-ref-val! dirty-ref true) (reset! filter-id* text)))) on-token-apply @@ -386,11 +390,22 @@ (reset! is-open* false))) (when (mf/ref-val dirty-ref) - (apply-value (mf/ref-val raw-value*))) + (apply-value (mf/ref-val raw-value*)) + (mf/set-ref-val! dirty-ref false)) (when (fn? on-blur) (on-blur event)) (dom/blur! (mf/ref-val ref)))) + commit-pending-on-unmount + (mf/use-fn + (mf/deps apply-value) + (fn [] + (when (mf/ref-val dirty-ref) + (apply-value (mf/ref-val raw-value*)) + (mf/set-ref-val! dirty-ref false)))) + + handle-unmount (h/use-ref-callback commit-pending-on-unmount) + on-key-down (mf/use-fn (mf/deps is-open apply-value update-input is-open focused-id handle-focus-change) @@ -501,9 +516,9 @@ on-scrub-pointer-down (mf/use-fn - (mf/deps disabled is-open is-multiple? ref min max nillable default) + (mf/deps disabled is-open is-multiple? ref min max nillable default is-token-applied?) (fn [event] - (when-not (or disabled is-open is-multiple?) + (when-not (or disabled is-open is-multiple? is-token-applied?) (let [node (mf/ref-val ref) is-focused (and (some? node) (dom/active? node)) has-token (some? (deref token-applied-name*))] @@ -518,57 +533,57 @@ on-scrub-pointer-move (mf/use-fn - (mf/deps apply-value update-input step min max on-change-start) + (mf/deps apply-value update-input step min max on-change-start is-token-applied?) (fn [event] - (let [state (mf/ref-val drag-state*)] - (when (or (= state :maybe-dragging) (= state :dragging)) - (let [client-x (.-clientX event) - start-x (mf/ref-val drag-start-x*) - delta-x (- client-x start-x)] - (when (and (= state :maybe-dragging) - (>= (js/Math.abs delta-x) 3)) - (mf/set-ref-val! drag-state* :dragging) - (dom/add-class! (dom/get-body) "cursor-drag-scrub") - (when (fn? on-change-start) - (on-change-start))) - (when (= (mf/ref-val drag-state*) :dragging) - (let [effective-step (cond - (.-shiftKey event) (* step 10) - (.-ctrlKey event) (* step 0.1) - :else step) - steps (js/Math.round (/ delta-x 1)) - new-val (mth/clamp (+ (mf/ref-val drag-start-val*) - (* steps effective-step)) - min max)] - (update-input (fmt/format-number new-val)) - (apply-value (dm/str new-val))))))))) + (when-not is-token-applied? + (let [state (mf/ref-val drag-state*)] + (when (or (= state :maybe-dragging) (= state :dragging)) + (let [client-x (.-clientX event) + start-x (mf/ref-val drag-start-x*) + delta-x (- client-x start-x)] + (when (and (= state :maybe-dragging) + (>= (js/Math.abs delta-x) 3)) + (mf/set-ref-val! drag-state* :dragging) + (when (fn? on-change-start) + (on-change-start))) + (when (= (mf/ref-val drag-state*) :dragging) + (let [effective-step (cond + (.-shiftKey event) (* step 10) + (.-ctrlKey event) (* step 0.1) + :else step) + steps (js/Math.round (/ delta-x 1)) + new-val (mth/clamp (+ (mf/ref-val drag-start-val*) + (* steps effective-step)) + min max)] + (update-input (fmt/format-number new-val)) + (apply-value (dm/str new-val)))))))))) on-scrub-pointer-up (mf/use-fn - (mf/deps ref on-change-end) + (mf/deps ref on-change-end is-token-applied?) (fn [event] - (let [state (mf/ref-val drag-state*)] - (when (= state :maybe-dragging) - (mf/set-ref-val! drag-state* :idle) - (dom/release-pointer event) - (when-let [node (mf/ref-val ref)] - (dom/focus! node))) - (when (= state :dragging) - (mf/set-ref-val! drag-state* :idle) - (dom/remove-class! (dom/get-body) "cursor-drag-scrub") - (dom/release-pointer event) - (when (fn? on-change-end) - (on-change-end)))))) + (when-not is-token-applied? + (let [state (mf/ref-val drag-state*)] + (when (= state :maybe-dragging) + (mf/set-ref-val! drag-state* :idle) + (dom/release-pointer event) + (when-let [node (mf/ref-val ref)] + (dom/focus! node))) + (when (= state :dragging) + (mf/set-ref-val! drag-state* :idle) + (dom/release-pointer event) + (when (fn? on-change-end) + (on-change-end))))))) on-scrub-lost-pointer-capture (mf/use-fn - (mf/deps on-change-end) + (mf/deps on-change-end is-token-applied?) (fn [_event] - (let [was-dragging (= :dragging (mf/ref-val drag-state*))] - (mf/set-ref-val! drag-state* :idle) - (dom/remove-class! (dom/get-body) "cursor-drag-scrub") - (when (and was-dragging (fn? on-change-end)) - (on-change-end))))) + (when-not is-token-applied? + (let [was-dragging (= :dragging (mf/ref-val drag-state*))] + (mf/set-ref-val! drag-state* :idle) + (when (and was-dragging (fn? on-change-end)) + (on-change-end)))))) open-dropdown (mf/use-fn @@ -766,15 +781,17 @@ (mf/with-effect [dropdown-options] (mf/set-ref-val! options-ref dropdown-options)) - [:div {:class [class (stl/css :input-wrapper)] + (mf/with-effect [handle-unmount] handle-unmount) + + [:div {:class [class (stl/css-case :input-wrapper true + :resizable (not is-token-applied?))] :ref wrapper-ref :on-pointer-down on-scrub-pointer-down :on-pointer-move on-scrub-pointer-move :on-pointer-up on-scrub-pointer-up :on-lost-pointer-capture on-scrub-lost-pointer-capture} - (if (and (some? token-applied-name) - (not= :multiple token-applied-name)) + (if is-token-applied? [:> token-field* token-props] [:> input-field* input-props]) diff --git a/frontend/src/app/main/ui/ds/controls/numeric_input.scss b/frontend/src/app/main/ui/ds/controls/numeric_input.scss index 60741e7d0b..80b44a94f1 100644 --- a/frontend/src/app/main/ui/ds/controls/numeric_input.scss +++ b/frontend/src/app/main/ui/ds/controls/numeric_input.scss @@ -22,8 +22,10 @@ inline-size: 100%; position: relative; - &:not(:focus-within) { - cursor: ew-resize; + &.resizable { + &:not(:focus-within) { + cursor: ew-resize; + } } &:hover { diff --git a/frontend/src/app/main/ui/ds/controls/shared/option.scss b/frontend/src/app/main/ui/ds/controls/shared/option.scss index 2b3e749770..03f765af20 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/option.scss +++ b/frontend/src/app/main/ui/ds/controls/shared/option.scss @@ -14,6 +14,7 @@ --options-empty: var(--color-canvas); display: grid; + align-content: center; place-items: center start; grid-template-columns: 1fr auto; gap: var(--sp-xs); @@ -25,6 +26,7 @@ outline-offset: calc(-1 * $b-1); background-color: var(--options-bg-color); color: var(--options-fg-color); + cursor: default; &:hover, &[aria-selected="true"] { diff --git a/frontend/src/app/main/ui/ds/controls/shared/searchable_options_dropdown.cljs b/frontend/src/app/main/ui/ds/controls/shared/searchable_options_dropdown.cljs index 6e5d8e7d51..c90df863c8 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/searchable_options_dropdown.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/searchable_options_dropdown.cljs @@ -33,7 +33,7 @@ [:resolved-value {:optional true} [:or :int :string :float :map]] [:name {:optional true} :string] - [:value {:optional true} :keyword] + [:value {:optional true} [:or :keyword :int :string :float :map]] [:icon {:optional true} schema:icon-list] [:label {:optional true} :string] [:aria-label {:optional true} :string]]) diff --git a/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs b/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs index 2d989bca02..15c1854330 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs @@ -12,6 +12,8 @@ [app.common.data.macros :as dm] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.tooltip.tooltip :refer [tooltip*]] + [app.util.i18n :as i18n :refer [tr]] + [cuerdas.core :as str] [rumext.v2 :as mf])) (def ^:private schema:token-option @@ -25,12 +27,44 @@ [:selected {:optional true} :boolean] [:focused {:optional true} :boolean]]) +(mf/defc resolved-value-tooltip* + ;; Generates the tooltip content for tokens whose resolved value is a map + ;; (e.g. typography tokens). Each key/value pair is rendered as a list item: + ;; - font-family: "AR One Sans" + ;; - font-size: 23 + ;; - ... + ;; + ;; Sequential values are formatted as comma-separated strings with quotes + ;; around each item. This is mainly used for font-family values when multiple + ;; fonts are present, to preserve the expected CSS-like representation: + ;; - font-family: "Font A", "Font B" + [{:keys [token-name resolved-value]}] + [:* + [:span (dm/str (tr "workspace.tokens.token-name") ": ")] + [:span {:class (stl/css :token-name-tooltip)} token-name] + [:div + [:span (tr "inspect.tabs.styles.token-resolved-value")] + [:ul + (for [[sub-prop prop-value] resolved-value] + [:li {:key (d/name sub-prop)} + [:span {:class (stl/css :resolved-key)} (str "- " (d/name sub-prop) ": ")] + [:span {:class (stl/css :resolved-value)} + (if (sequential? prop-value) + (str/join ", " (map #(dm/str "\"" % "\"") prop-value)) + (dm/str prop-value))]])]]]) + (mf/defc token-option* {::mf/schema schema:token-option} [{:keys [id name on-click selected ref focused resolved value] :rest props}] (let [internal-id (mf/use-id) id (d/nilv id internal-id) - element-ref (mf/use-ref nil)] + element-ref (mf/use-ref nil) + tooltip-content (if (map? resolved) + (mf/html + [:> resolved-value-tooltip* + {:token-name name + :resolved-value resolved}]) + name)] [:li {:value id :class (stl/css-case :token-option true :option-with-pill true @@ -52,7 +86,7 @@ :class (stl/css :option-check) :aria-hidden (when name true)}] [:span {:class (stl/css :icon-placeholder)}]) - [:> tooltip* {:content name + [:> tooltip* {:content tooltip-content :trigger-ref element-ref :id (dm/str id "-name") :class (stl/css :option-text)} diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs index f2ccc02d4a..35da1fc550 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs +++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs @@ -325,13 +325,16 @@ (let [size-px (cond (= size "l") icon-size-l (= size "s") icon-size-s :else icon-size-m) - + offset (if (or (= size "s") (= size "m")) + (/ (- icon-size-m size-px) 2) + 0) props (mf/spread-props props {:class [class (stl/css :icon)] - :width size-px - :height size-px})] - + :width (max icon-size-m size-px) + :height (max icon-size-m size-px)})] [:> :svg props [:use {:href (dm/str "#icon-" icon-id) + :x offset + :y offset :width size-px :height size-px}]])) diff --git a/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.cljs b/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.cljs index e744cfe292..96c35a0d60 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.cljs +++ b/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.cljs @@ -18,6 +18,7 @@ (def ^:svg-id brand-google "brand-google") (def ^:svg-id loader "loader") (def ^:svg-id logo-error-screen "logo-error-screen") +(def ^:svg-id logo-nitrate-unavailable "logo-nitrate-unavailable") (def ^:svg-id logo-subscription "logo-subscription") (def ^:svg-id logo-subscription-light "logo-subscription-light") (def ^:svg-id nitrate-welcome "nitrate-welcome") @@ -27,6 +28,7 @@ (def ^:svg-id marketing-layers "marketing-layers") (def ^:svg-id penpot-logo "penpot-logo") (def ^:svg-id penpot-logo-icon "penpot-logo-icon") +(def ^:svg-id penpot-logo-subtle "penpot-logo-subtle") (def ^:svg-id empty-placeholder-1-left "empty-placeholder-1-left") (def ^:svg-id empty-placeholder-1-right "empty-placeholder-1-right") (def ^:svg-id empty-placeholder-2-left "empty-placeholder-2-left") @@ -39,3 +41,4 @@ (assert (contains? raw-svg-list id) "invalid raw svg id") [:> "svg" props [:use {:href (dm/str "#asset-" id)}]]) + diff --git a/frontend/src/app/main/ui/ds/notifications/actionable.cljs b/frontend/src/app/main/ui/ds/notifications/actionable.cljs index f97ef075e0..c14370d121 100644 --- a/frontend/src/app/main/ui/ds/notifications/actionable.cljs +++ b/frontend/src/app/main/ui/ds/notifications/actionable.cljs @@ -34,12 +34,14 @@ on-accept (mf/use-fn + (mf/deps on-accept) (fn [e] (when (fn? on-accept) (on-accept e)))) on-cancel (mf/use-fn + (mf/deps on-cancel) (fn [e] (when on-cancel (on-cancel e))))] diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs index 10e9638cf2..3eb9a4f939 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs @@ -322,7 +322,7 @@ (let [trigger-el (mf/ref-val trigger-ref) tooltip-el (mf/ref-val tooltip-ref)] (when (and trigger-el tooltip-el) - (js/requestAnimationFrame + (ts/raf (fn [] (let [origin-brect (dom/get-bounding-rect trigger-el) tooltip-brect (dom/get-bounding-rect tooltip-el) diff --git a/frontend/src/app/main/ui/exports/files.cljs b/frontend/src/app/main/ui/exports/files.cljs index ec937809ec..13652480c5 100644 --- a/frontend/src/app/main/ui/exports/files.cljs +++ b/frontend/src/app/main/ui/exports/files.cljs @@ -45,8 +45,7 @@ :files files})) (mf/defc export-entry* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [file]}] [:div {:class (stl/css-case :file-entry true diff --git a/frontend/src/app/main/ui/frame_preview.cljs b/frontend/src/app/main/ui/frame_preview.cljs index bff3da3abd..ca2f2288ee 100644 --- a/frontend/src/app/main/ui/frame_preview.cljs +++ b/frontend/src/app/main/ui/frame_preview.cljs @@ -9,9 +9,8 @@ [app.common.data :as d] [rumext.v2 :as mf])) -(mf/defc frame-preview - {::mf/wrap-props false - ::mf/wrap [mf/memo]} +(mf/defc frame-preview* + {::mf/wrap [mf/memo]} [] (let [iframe-ref (mf/use-ref nil) @@ -37,7 +36,6 @@ load-ref (mf/use-callback (fn [iframe-dom] - (.log js/console "load-ref" iframe-dom) (mf/set-ref-val! iframe-ref iframe-dom) (when (and iframe-dom @last-data*) (-> iframe-dom .-contentWindow .-document .open) diff --git a/frontend/src/app/main/ui/inspect/attributes/fill.cljs b/frontend/src/app/main/ui/inspect/attributes/fill.cljs index 7c3ea85640..e1aa09a1ac 100644 --- a/frontend/src/app/main/ui/inspect/attributes/fill.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/fill.cljs @@ -27,8 +27,7 @@ ;; DEPRECATED, use fill-block-styles* instead. ;; This component is kept for backward compatibility -(mf/defc fill-block - {::mf/wrap-props false} +(mf/defc fill-block* [{:keys [objects shape]}] (let [format* (mf/use-state :hex) format (deref format*) @@ -84,7 +83,7 @@ :shape shape}]) (if (seq (:fills shape)) (for [value (:fills shape [])] - [:& fill-block {:key (str "fill-block-" (:id shape) value) - :shape value}]) - [:& fill-block {:key (str "fill-block-only" (:id shape)) - :shape shape}])))]]))) + [:> fill-block* {:key (str "fill-block-" (:id shape) value) + :shape value}]) + [:> fill-block* {:key (str "fill-block-only" (:id shape)) + :shape shape}])))]]))) diff --git a/frontend/src/app/main/ui/inspect/attributes/stroke.cljs b/frontend/src/app/main/ui/inspect/attributes/stroke.cljs index 1592cc5002..b4e565583f 100644 --- a/frontend/src/app/main/ui/inspect/attributes/stroke.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/stroke.cljs @@ -31,8 +31,7 @@ ;; DEPRECATED, use stroke-block-styles* instead. ;; This component is kept for backward compatibility -(mf/defc stroke-block - {::mf/wrap-props false} +(mf/defc stroke-block* [{:keys [objects shape stroke]}] (let [format* (mf/use-state :hex) format (deref format*) @@ -100,6 +99,6 @@ :shape shape :color-space color-space :stroke stroke}] - [:& stroke-block {:key (str "stroke-color-" (:id shape) stroke) - :shape shape - :stroke stroke}])))]]))) + [:> stroke-block* {:key (str "stroke-color-" (:id shape) stroke) + :shape shape + :stroke stroke}])))]]))) diff --git a/frontend/src/app/main/ui/inspect/code.cljs b/frontend/src/app/main/ui/inspect/code.cljs index 661bee9075..26f208525d 100644 --- a/frontend/src/app/main/ui/inspect/code.cljs +++ b/frontend/src/app/main/ui/inspect/code.cljs @@ -17,7 +17,7 @@ [app.main.fonts :as fonts] [app.main.refs :as refs] [app.main.store :as st] - [app.main.ui.components.code-block :refer [code-block]] + [app.main.ui.components.code-block :refer [code-block*]] [app.main.ui.components.copy-button :refer [copy-button*]] [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]] [app.main.ui.hooks.resize :refer [use-resize-hook]] @@ -31,7 +31,6 @@ [beicon.v2.core :as rx] [cuerdas.core :as str] [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (def embed-images? true) @@ -158,10 +157,10 @@ (let [origin (if (= :workspace from) "workspace" "viewer")] - (st/emit! (ptk/event ::ev/event - {::ev/name "copy-inspect-code" - ::ev/origin origin - :type markup-type}))))) + (st/emit! (ev/event + {::ev/name "copy-inspect-code" + ::ev/origin origin + :type markup-type}))))) on-style-copied (mf/use-fn @@ -170,10 +169,10 @@ (let [origin (if (= :workspace from) "workspace" "viewer")] - (st/emit! (ptk/event ::ev/event - {::ev/name "copy-inspect-style" - ::ev/origin origin - :type style-type}))))) + (st/emit! (ev/event + {::ev/name "copy-inspect-style" + ::ev/origin origin + :type style-type}))))) {on-markup-pointer-down :on-pointer-down on-markup-lost-pointer-capture :on-lost-pointer-capture @@ -206,10 +205,10 @@ (let [origin (if (= :workspace from) "workspace" "viewer")] - (st/emit! (ptk/event ::ev/event - {::ev/name "copy-inspect-code" - ::ev/origin origin - :type "all"}))))) + (st/emit! (ev/event + {::ev/name "copy-inspect-code" + ::ev/origin origin + :type "all"}))))) ;;handle-open-review ;;(mf/use-fn @@ -299,8 +298,8 @@ (when-not collapsed-css? [:div {:class (stl/css :code-row-display) :style {:--code-height (dm/str (or style-size 400) "px")}} - [:& code-block {:type style-type - :code style-code}]]) + [:> code-block* {:type style-type + :code style-code}]]) [:div {:class (stl/css :resize-area) :on-pointer-down on-style-pointer-down @@ -340,8 +339,8 @@ (when-not collapsed-markup? [:div {:class (stl/css :code-row-display) :style {:--code-height (dm/str (or markup-size 400) "px")}} - [:& code-block {:type markup-type - :code markup-code}]]) + [:> code-block* {:type markup-type + :code markup-code}]]) [:div {:class (stl/css :resize-area) :on-pointer-down on-markup-pointer-down diff --git a/frontend/src/app/main/ui/inspect/right_sidebar.cljs b/frontend/src/app/main/ui/inspect/right_sidebar.cljs index 9d4dfa0a1b..26b4195b0b 100644 --- a/frontend/src/app/main/ui/inspect/right_sidebar.cljs +++ b/frontend/src/app/main/ui/inspect/right_sidebar.cljs @@ -24,7 +24,6 @@ [app.util.dom :as dom] [app.util.i18n :refer [tr]] [app.util.shape-icon :as usi] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (defn- get-libraries @@ -75,7 +74,7 @@ (when on-change-section (on-change-section (keyword new-section)) (st/emit! - (ptk/event ::ev/event {::ev/name "change-inspect-tab" :tab new-section}))))) + (ev/event {::ev/name "change-inspect-tab" :tab new-section}))))) handle-expand (mf/use-fn @@ -122,7 +121,7 @@ (mf/deps shapes handle-change-tab) (fn [] (if (seq shapes) - (st/emit! (ptk/event ::ev/event {::ev/name "inspect-mode-click-element"})) + (st/emit! (ev/event {::ev/name "inspect-mode-click-element"})) (handle-change-tab (if (contains? cf/flags :inspect-styles) :styles :info))))) [:aside {:class (stl/css-case :settings-bar-right true diff --git a/frontend/src/app/main/ui/inspect/selection_feedback.cljs b/frontend/src/app/main/ui/inspect/selection_feedback.cljs index 9b52adb05a..73899c679e 100644 --- a/frontend/src/app/main/ui/inspect/selection_feedback.cljs +++ b/frontend/src/app/main/ui/inspect/selection_feedback.cljs @@ -8,7 +8,7 @@ (:require [app.common.data :as d] [app.common.geom.shapes :as gsh] - [app.main.ui.measurements :refer [size-display measurement]] + [app.main.ui.measurements :refer [size-display* measurement*]] [rumext.v2 :as mf])) ;; ------------------------------------------------ @@ -64,9 +64,9 @@ [:g.selection-feedback {:pointer-events "none"} [:g.selected-shapes [:& selection-rect {:selrect selrect :zoom zoom}] - [:& size-display {:selrect selrect :zoom zoom}]] + [:> size-display* {:selrect selrect :zoom zoom}]] - [:& measurement {:bounds (assoc size :x 0 :y 0) - :selected-shapes selected-shapes - :hover-shape hover-shape - :zoom zoom}]]))) + [:> measurement* {:bounds (assoc size :x 0 :y 0) + :selected-shapes selected-shapes + :hover-shape hover-shape + :zoom zoom}]]))) diff --git a/frontend/src/app/main/ui/measurements.cljs b/frontend/src/app/main/ui/measurements.cljs index 370830fdb8..923ce0788c 100644 --- a/frontend/src/app/main/ui/measurements.cljs +++ b/frontend/src/app/main/ui/measurements.cljs @@ -5,6 +5,7 @@ ;; Copyright (c) KALEIDOS INC (ns app.main.ui.measurements + (:require-macros [app.main.style :as stl]) (:require [app.common.data :as d] [app.common.data.macros :as dm] @@ -43,6 +44,13 @@ (def distance-pill-height 16) (def distance-line-stroke 1) +(def ^:private ^:const selection-badge-bg-color "var(--color-accent-tertiary)") +(def ^:private ^:const selection-badge-height 16) +(def ^:private ^:const selection-badge-padding-x 6) +(def ^:private ^:const selection-badge-vertical-gap 8) +(def ^:private ^:const selection-badge-border-radius 2) +(def ^:private ^:const selection-badge-char-width 6.5) + ;; ------------------------------------------------ ;; HELPERS @@ -96,7 +104,7 @@ ;; COMPONENTS ;; ------------------------------------------------ -(mf/defc size-display [{:keys [selrect zoom]}] +(mf/defc size-display* [{:keys [selrect zoom]}] (let [{:keys [x y width height]} selrect size-label (dm/str (fmt/format-number width) " x " (fmt/format-number height)) @@ -123,7 +131,7 @@ :font-size (/ font-size zoom)}} size-label]])) -(mf/defc distance-display-pill [{:keys [x y zoom distance bounds]}] +(mf/defc distance-display-pill* [{:keys [x y zoom distance bounds]}] (let [distance-pill-width (/ distance-pill-width zoom) distance-pill-height (/ distance-pill-height zoom) font-size (/ font-size zoom) @@ -167,7 +175,7 @@ :font-size font-size}} (fmt/format-pixels distance)]])) -(mf/defc selection-rect [{:keys [selrect zoom]}] +(mf/defc selection-rect* [{:keys [selrect zoom]}] (let [{:keys [x y width height]} selrect selection-rect-width (/ selection-rect-width zoom)] [:g.selection-rect @@ -179,7 +187,36 @@ :stroke hover-color :stroke-width selection-rect-width}}]])) -(mf/defc distance-display [{:keys [from to zoom bounds]}] +(mf/defc selection-size-badge* + [{:keys [selrect zoom]}] + (let [{:keys [x y width height]} selrect + size-label (dm/str (fmt/format-number width) " x " (fmt/format-number height)) + badge-height (/ selection-badge-height zoom) + padding-x (/ selection-badge-padding-x zoom) + gap (/ selection-badge-vertical-gap zoom) + radius (/ selection-badge-border-radius zoom) + text-width (* (count size-label) (/ selection-badge-char-width zoom)) + badge-width (+ text-width (* 2 padding-x)) + center-x (+ x (/ width 2)) + badge-x (- center-x (/ badge-width 2)) + badge-y (+ y height gap) + text-y (+ badge-y (/ badge-height 2))] + [:g.selection-size-badge {:pointer-events "none"} + [:rect {:x badge-x + :y badge-y + :width badge-width + :height badge-height + :rx radius + :ry radius + :style {:fill selection-badge-bg-color}}] + [:text {:class (stl/css :badge-text) + :x center-x + :y text-y + :text-anchor "middle" + :dominant-baseline "middle"} + size-label]])) + +(mf/defc distance-display* [{:keys [from to zoom bounds]}] (let [fixed-x (if (gsh/fully-contained? from to) (+ (:x to) (/ (:width to) 2)) (+ (:x from) (/ (:width from) 2))) @@ -211,14 +248,14 @@ :style {:stroke distance-color :stroke-width distance-line-stroke}}] - [:& distance-display-pill + [:> distance-display-pill* {:x center-x :y center-y :zoom zoom :distance distance :bounds bounds}]]))))) -(mf/defc selection-guides [{:keys [bounds selrect zoom]}] +(mf/defc selection-guides* [{:keys [bounds selrect zoom]}] [:g.selection-guides (for [[idx [x1 y1 x2 y2]] (d/enumerate (calculate-guides bounds selrect))] [:line {:key (dm/str "guide-" idx) @@ -230,7 +267,7 @@ :stroke-width (/ select-guide-width zoom) :stroke-dasharray (/ select-guide-dasharray zoom)}}])]) -(mf/defc measurement +(mf/defc measurement* [{:keys [bounds frame selected-shapes hover-shape zoom]}] (let [selected-ids (into #{} (map :id) selected-shapes) selected-selrect (gsh/shapes->rect selected-shapes) @@ -240,23 +277,24 @@ (when (seq selected-shapes) [:g.measurement-feedback {:pointer-events "none"} - [:& selection-guides {:selrect selected-selrect - :bounds bounds - :zoom zoom}] - [:& size-display {:selrect selected-selrect :zoom zoom}] + [:> selection-guides* {:selrect selected-selrect + :bounds bounds + :zoom zoom}] + [:> size-display* {:selrect selected-selrect :zoom zoom}] + [:> selection-size-badge* {:selrect selected-selrect :zoom zoom}] (if (or (not hover-shape) (not hover-selected-shape?)) (when (and frame (not= uuid/zero (:id frame))) (let [frame-bb (-> (:points frame) (grc/points->rect))] [:g.hover-shapes - [:& selection-rect {:type :hover :selrect frame-bb :zoom zoom}] - [:& distance-display {:from frame-bb - :to selected-selrect - :zoom zoom - :bounds bounds-selrect}]])) + [:> selection-rect* {:type :hover :selrect frame-bb :zoom zoom}] + [:> distance-display* {:from frame-bb + :to selected-selrect + :zoom zoom + :bounds bounds-selrect}]])) [:g.hover-shapes - [:& selection-rect {:type :hover :selrect hover-selrect :zoom zoom}] - [:& size-display {:selrect hover-selrect :zoom zoom}] - [:& distance-display {:from hover-selrect :to selected-selrect :zoom zoom :bounds bounds-selrect}]])]))) + [:> selection-rect* {:type :hover :selrect hover-selrect :zoom zoom}] + [:> size-display* {:selrect hover-selrect :zoom zoom}] + [:> distance-display* {:from hover-selrect :to selected-selrect :zoom zoom :bounds bounds-selrect}]])]))) diff --git a/frontend/src/app/main/ui/measurements.scss b/frontend/src/app/main/ui/measurements.scss new file mode 100644 index 0000000000..660f9e745f --- /dev/null +++ b/frontend/src/app/main/ui/measurements.scss @@ -0,0 +1,13 @@ +// 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 + +@use "refactor/common-refactor.scss" as deprecated; + +.badge-text { + fill: var(--app-black); + font-size: calc(deprecated.$fs-12 / var(--zoom)); + font-family: "worksans", "vazirmatn", sans-serif; +} diff --git a/frontend/src/app/main/ui/modal.cljs b/frontend/src/app/main/ui/modal.cljs index 6e9b1df7d4..4210d9ba40 100644 --- a/frontend/src/app/main/ui/modal.cljs +++ b/frontend/src/app/main/ui/modal.cljs @@ -46,8 +46,7 @@ (st/emit! (modal/hide))))) (mf/defc modal-wrapper* - {::mf/props :obj - ::mf/wrap [mf/memo]} + {::mf/wrap [mf/memo]} [{:keys [data]}] (let [wrapper-ref (mf/use-ref nil) components (mf/deref modal/components) @@ -82,7 +81,6 @@ (l/derived ::modal/modal st/state)) (mf/defc modal-container* - {::mf/props :obj} [] (let [container (hooks/use-portal-container :modal)] (when-let [modal (mf/deref ref:modal)] diff --git a/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs b/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs new file mode 100644 index 0000000000..c0d5e0e77c --- /dev/null +++ b/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs @@ -0,0 +1,68 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.nitrate.nitrate-activation-success-modal + (:require-macros [app.main.style :as stl]) + (:require + [app.common.time :as ct] + [app.main.data.modal :as modal] + [app.main.data.nitrate :as dnt] + [app.main.refs :as refs] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.foundations.assets.icon :refer [icon*]] + [app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]] + [app.util.i18n :refer [tr]] + [rumext.v2 :as mf])) + +(mf/defc nitrate-activation-success-modal* + {::mf/register modal/components + ::mf/register-as :nitrate-activation-success} + [] + + (let [profile (mf/deref refs/profile) + light? (= "light" (:theme profile)) + svg-id (if light? "logo-subscription-light" "logo-subscription") + + nitrate-license (:subscription profile) + cancel-at (:cancel-at nitrate-license) + manual? (:manual nitrate-license) + date-str (when cancel-at + (ct/format-inst cancel-at "d MMMM, yyyy")) + + on-create-org + (mf/use-fn + (fn [] + (modal/hide!) + (dnt/go-to-nitrate-ac-create-org)))] + + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-dialog)} + [:button {:class (stl/css :close-btn) :on-click modal/hide!} + [:> icon* {:icon-id "close" + :size "m"}]] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-start)} + [:> raw-svg* {:id svg-id}]] + + [:div {:class (stl/css :modal-end)} + [:div {:class (stl/css :modal-title)} + (tr "nitrate.activation-success.title")] + + (when (and manual? date-str) + [:p {:class (stl/css :modal-text-primary)} + (tr "nitrate.activation-success.active-until" date-str)]) + + [:p {:class (stl/css :modal-text)} + (tr "nitrate.activation-success.manage-info")] + + [:p {:class (stl/css :modal-text)} + (tr "nitrate.activation-success.enjoy")] + + [:> button* {:variant "primary" + :on-click on-create-org + :class (stl/css :modal-button)} + (tr "nitrate.activation-success.create-org")]]]]])) diff --git a/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.scss b/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.scss new file mode 100644 index 0000000000..5f1e8dd483 --- /dev/null +++ b/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.scss @@ -0,0 +1,79 @@ +// 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 + +@use "refactor/common-refactor.scss" as deprecated; +@use "ds/typography.scss" as t; +@use "ds/_borders.scss" as *; +@use "ds/spacing.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; + +.modal-overlay { + @extend %modal-overlay-base; + + z-index: var(--z-index-notifications); +} + +.modal-dialog { + @extend %modal-container-base; + + max-block-size: initial; + min-inline-size: px2rem(608); + max-inline-size: px2rem(608); + padding: var(--sp-xxxl); +} + +.close-btn { + @extend %modal-close-btn-base; +} + +.modal-content { + display: flex; + gap: $sz-40; +} + +.modal-start { + display: flex; + justify-content: center; + min-inline-size: $sz-224; + + @media (width <= 640px) { + display: none; + } +} + +.modal-start svg { + inline-size: 100%; + block-size: auto; +} + +.modal-end { + color: var(--color-foreground-secondary); + display: flex; + flex-direction: column; + gap: var(--sp-m); +} + +.modal-title { + @include t.use-typography("title-large"); + + color: var(--modal-title-foreground-color); +} + +.modal-text-primary { + @include t.use-typography("body-large"); + + color: var(--color-foreground-primary); +} + +.modal-text { + @include t.use-typography("body-large"); +} + +.modal-button { + margin-block-start: var(--sp-s); + align-self: flex-start; +} diff --git a/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs b/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs new file mode 100644 index 0000000000..8f0223cf85 --- /dev/null +++ b/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs @@ -0,0 +1,98 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.nitrate.nitrate-code-activation-modal + (:require-macros [app.main.style :as stl]) + (:require + [app.main.data.modal :as modal] + [app.main.data.profile :as dprof] + [app.main.repo :as rp] + [app.main.store :as st] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.nitrate.nitrate-activation-success-modal] + [app.util.dom :as dom] + [app.util.i18n :refer [tr]] + [beicon.v2.core :as rx] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +(mf/defc nitrate-code-activation-modal* + {::mf/register modal/components + ::mf/register-as :nitrate-code-activation} + [_props] + (let [value* (mf/use-state "") + error* (mf/use-state nil) + + on-change + (mf/use-fn + (fn [event] + (reset! error* nil) + (reset! value* (dom/get-target-val event)))) + + on-accept + (mf/use-fn + (mf/deps value*) + (fn [_] + (let [code (str/trim @value*)] + (when (seq code) + (->> (rp/cmd! ::redeem-nitrate-activation-code {:activation-code code}) + (rx/subs! + (fn [_] + (modal/hide!) + (st/emit! + (modal/show {:type :nitrate-activation-success}) + (dprof/refresh-profile))) + (fn [error] + ;; TODO: "Already used" is not yet detectable (CC upserts on reuse). + (let [code (-> error ex-data :code)] + (reset! error* (case code + :expired-activation-code (tr "nitrate.activation-code.expired-error") + (tr "nitrate.activation-code.invalid-error"))))))))))) + + on-key-down + (mf/use-fn + (mf/deps on-accept) + (fn [event] + (when (and (= "Enter" (.-key event)) (.-ctrlKey event)) + (on-accept event))))] + + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-dialog)} + [:> icon-button* {:variant "ghost" + :class (stl/css :close-btn) + :aria-label (tr "labels.close") + :on-click modal/hide! + :icon i/close}] + + [:div {:class (stl/css :modal-header)} + [:h2 {:class (stl/css :modal-title)} (tr "nitrate.code-activation.title")]] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css-case :code-field true :invalid (some? @error*))} + [:label {:class (stl/css :code-label)} + (tr "nitrate.code-activation.input-label")] + [:textarea {:class (stl/css :code-textarea) + :auto-focus true + :value @value* + :placeholder (tr "nitrate.code-activation.placeholder") + :on-change on-change + :on-key-down on-key-down}] + (when @error* + [:span {:class (stl/css :error-msg)} @error*])] + + [:input + {:type "button" + :class (stl/css-case :accept-btn true + :global/disabled (empty? (str/trim @value*))) + :disabled (empty? (str/trim @value*)) + :value (tr "nitrate.code-activation.submit") + :on-click on-accept}] + [:div {:class (stl/css :footer-text)} + (tr "nitrate.code-activation.footer") " " + [:a {:class (stl/css :link) + :href "mailto:sales@nitrate.com"} + "sales@nitrate.com"]]]]])) diff --git a/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.scss b/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.scss new file mode 100644 index 0000000000..d241c38332 --- /dev/null +++ b/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.scss @@ -0,0 +1,107 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +@use "refactor/common-refactor.scss" as deprecated; +@use "ds/typography.scss" as t; +@use "ds/spacing.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_borders.scss" as *; + +.close-btn { + @extend %modal-close-btn-base; +} + +.modal-overlay { + @extend %modal-overlay-base; + + z-index: var(--z-index-notifications); +} + +.modal-dialog { + @extend %modal-container-base; + + inline-size: $sz-480; + max-inline-size: $sz-480; + max-block-size: none; + max-height: none; + padding: var(--sp-xxxl); +} + +.modal-title { + @include t.use-typography("title-large"); + + color: var(--modal-title-foreground-color); + margin-block-end: var(--sp-xxxl); +} + +.modal-content { + display: flex; + flex-direction: column; + gap: var(--sp-m); + color: var(--color-foreground-secondary); +} + +.accept-btn { + @extend %modal-accept-btn; + + inline-size: 100%; +} + +.code-field { + display: flex; + flex-direction: column; + gap: var(--sp-xs); +} + +.code-label { + @include t.use-typography("body-medium"); + + color: var(--color-foreground-secondary); +} + +.code-textarea { + @include t.use-typography("body-medium"); + + block-size: $sz-200; + resize: vertical; + font-family: monospace; + word-break: break-all; + padding: var(--sp-s); + border-radius: $br-8; + border: $b-1 solid var(--input-border-color); + background-color: var(--input-background-color); + color: var(--color-foreground-primary); + outline: none; +} + +.code-textarea:focus { + border-color: var(--color-accent-primary); +} + +.invalid .code-textarea { + border-color: var(--input-border-color-error); +} + +.invalid .code-textarea:focus { + border-color: var(--input-border-color-error); +} + +.error-msg { + @include t.use-typography("body-small"); + + color: var(--element-foreground-error); +} + +.footer-text { + @include t.use-typography("body-medium"); + + color: var(--color-foreground-secondary); + margin-block-start: var(--sp-xxxl); +} + +.link { + color: var(--color-accent-primary); +} diff --git a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs index ef3bc46d38..712316d45d 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs +++ b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs @@ -7,20 +7,17 @@ (ns app.main.ui.nitrate.nitrate-form (:require-macros [app.main.style :as stl]) (:require - [app.common.schema :as sm] [app.main.data.modal :as modal] [app.main.data.nitrate :as dnt] [app.main.refs :as refs] - [app.main.ui.components.forms :as fm] + [app.main.store :as st] [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]] + [app.main.ui.nitrate.nitrate-code-activation-modal] + [app.util.i18n :refer [tr]] [rumext.v2 :as mf])) -(def ^:private schema:nitrate-form - [:map {:title "NitrateForm"} - [:subscription [::sm/one-of #{:monthly :yearly}]]]) - (mf/defc nitrate-form-modal* {::mf/register modal/components ::mf/register-as :nitrate-form @@ -29,15 +26,15 @@ (let [online? (:licenses connectivity) profile (mf/deref refs/profile) - initial (mf/with-memo [] - {:subscription "yearly"}) - form (fm/use-form :schema schema:nitrate-form - :initial initial) on-click (mf/use-fn - (mf/deps form) (fn [] - (dnt/go-to-buy-nitrate-license (-> @form :clean-data :subscription name))))] + (dnt/go-to-buy-nitrate-license "monthly" dnt/go-to-subscription-url))) + + on-activate-click + (mf/use-fn + (fn [] + (st/emit! (modal/show {:type :nitrate-code-activation}))))] [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog :subscription-success)} @@ -51,47 +48,56 @@ [:div {:class (stl/css :modal-end)} [:div {:class (stl/css :modal-title)} - "Unlock Nitrate Features"] + (tr "nitrate.form.title")] [:p {:class (stl/css :modal-text-large)} - "Prow scuttle parrel provost."] - [:p {:class (stl/css :modal-text-large)} - "Sail ho shrouds spirits boom mizzenmast yardarm. Pinnace holystone mizzenmast quarter crow's nest nipperkin grog yardarm hempen halter furl."] - [:p {:class (stl/css :modal-text-large)} - "Deadlights jack lad schooner scallywag dance the hempen jig carouser broadside cable strike colors."] + (tr "nitrate.form.enterprise-intro")] + [:ul + [:li {:class (stl/css :modal-text-large)} + "- " (tr "nitrate.form.enterprise-feature-1")] + [:li {:class (stl/css :modal-text-large)} + "- " (tr "nitrate.form.enterprise-feature-2")] + [:li {:class (stl/css :modal-text-large)} + "- " (tr "nitrate.form.enterprise-feature-3")]] + (if online? - [:& fm/form {:form form} - [:p {:class (stl/css :modal-text-large)} + [[:p {:class (stl/css :modal-text-large)} + (tr "nitrate.form.enterprise.price")] - [:& fm/radio-buttons - {:options [{:label "Price Tag Montly" :value "monthly"} - {:label "Price Tag Yearly (Discount)" :value "yearly"}] - :name :subscription - :class (stl/css :radio-btns)}]] - - [:p {:class (stl/css :modal-text-large :modal-buttons-section)} + [:div {:class (stl/css :modal-text-large :modal-buttons-section)} [:div {:class (stl/css :modal-buttons-section)} [:> button* {:variant "primary" :on-click on-click :class (stl/css :modal-button)} (if (:subscription profile) - "UPGRADE TO NITRATE" - "Try it free for 14 days")] + (tr "nitrate.form.upgrade") + (tr "nitrate.form.try-free"))] [:div {:class (stl/css :modal-text-small :modal-info)} - "Cancel anytime before your next billing cycle."]]] + (tr "nitrate.form.cancel-anytime")]]] + [:p {:class (stl/css :modal-text-medium)} + (tr "nitrate.form.have-code") " " [:a {:class (stl/css :link) + :on-click on-activate-click} + (tr "nitrate.form.enter-code")]] [:p {:class (stl/css :modal-text-medium)} [:a {:class (stl/css :link) :href dnt/go-to-subscription-url} - "See my current plan"]]] + (tr "nitrate.form.see-plan")]]] [:div {:class (stl/css :contact)} [:p {:class (stl/css :modal-text-large)} (if (:subscription profile) - "Contact us to upgrade to Nitrate:" - "Contact us to try Nitrate for 14 days:")] + (tr "nitrate.form.contact-upgrade") + (tr "nitrate.form.contact-trial"))] [:p {:class (stl/css :modal-text-large)} [:a {:class (stl/css :link) :href "mailto:sales@penpot.app"} - "sales@penpot.app"]]])]]]])) + "sales@penpot.app"]] + [:div {:class (stl/css :activation-code)} + [:p {:class (stl/css :modal-text-large)} + (tr "nitrate.form.have-code")] + [:p {:class (stl/css :modal-text-large)} + [:a {:class (stl/css :link) + :on-click on-activate-click} + (tr "nitrate.form.enter-code")]]]])]]]])) diff --git a/frontend/src/app/main/ui/nitrate/nitrate_form.scss b/frontend/src/app/main/ui/nitrate/nitrate_form.scss index d21a24c26c..e27ceabd4a 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_form.scss +++ b/frontend/src/app/main/ui/nitrate/nitrate_form.scss @@ -56,7 +56,7 @@ } .modal-info { - margin-block-start: var(--sp-s); + margin-block: var(--sp-s) var(--sp-l); width: 40%; } @@ -77,36 +77,40 @@ justify-content: center; min-inline-size: $sz-284; - svg { - inline-size: 100%; - block-size: auto; - } - @media (width <= 992px) { display: none; } } +.modal-start svg { + inline-size: 100%; + block-size: auto; +} + .radio-btns { - label { - @include t.use-typography("body-large"); - - padding: 0; - display: flex; - align-items: center; - } - display: flex; flex-direction: column; padding: var(--sp-l) 0 0 0; gap: 0; } +.radio-btns label { + @include t.use-typography("body-large"); + + padding: 0; + display: flex; + align-items: center; +} + .contact { - margin-block-start: $sz-96; + margin-block-start: $sz-48; color: var(--color-foreground-primary); } +.activation-code { + margin-block-start: var(--sp-xxxl); +} + .link { color: var(--color-accent-primary); } diff --git a/frontend/src/app/main/ui/notifications/context_notification.cljs b/frontend/src/app/main/ui/notifications/context_notification.cljs index 2d4bb0fb19..531fc1793a 100644 --- a/frontend/src/app/main/ui/notifications/context_notification.cljs +++ b/frontend/src/app/main/ui/notifications/context_notification.cljs @@ -62,8 +62,8 @@ (when (some? links) (for [[index link] (d/enumerate links)] ;; TODO Review this component - [:& lb/link-button {:class (stl/css :link) - :on-click (:callback link) - :value (:label link) - :key (dm/str "link-" index)}]))])]]) + [:> lb/link-button* {:class (stl/css :link) + :on-click (:callback link) + :value (:label link) + :key (dm/str "link-" index)}]))])]]) diff --git a/frontend/src/app/main/ui/notifications/inline_notification.cljs b/frontend/src/app/main/ui/notifications/inline_notification.cljs index 6f7f856f7e..17a9cd210c 100644 --- a/frontend/src/app/main/ui/notifications/inline_notification.cljs +++ b/frontend/src/app/main/ui/notifications/inline_notification.cljs @@ -30,7 +30,7 @@ (when (some? links) [:nav {:class (stl/css :link-nav)} (for [[index link] (d/enumerate links)] - [:& lb/link-button {:key (dm/str "link-" index) - :class (stl/css :link) - :on-click (:callback link) - :value (:label link)}])])]) + [:> lb/link-button* {:key (dm/str "link-" index) + :class (stl/css :link) + :on-click (:callback link) + :value (:label link)}])])]) diff --git a/frontend/src/app/main/ui/onboarding/questions.cljs b/frontend/src/app/main/ui/onboarding/questions.cljs index d529456cc1..8af4c1c936 100644 --- a/frontend/src/app/main/ui/onboarding/questions.cljs +++ b/frontend/src/app/main/ui/onboarding/questions.cljs @@ -11,14 +11,13 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.schema :as sm] - [app.main.data.event :as-alias ev] + [app.main.data.event :as ev] [app.main.data.profile :as du] [app.main.store :as st] [app.main.ui.components.forms :as fm] [app.main.ui.icons :as deprecated-icon] [app.util.i18n :as i18n :refer [tr]] [cuerdas.core :as str] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (mf/defc step-container @@ -33,7 +32,7 @@ (assoc :label label) (assoc :step step) (assoc ::ev/name "onboarding-step"))] - (st/emit! (ptk/data-event ::ev/event params)) + (st/emit! (ev/event params)) (on-next form event))))] [:& fm/form {:form form diff --git a/frontend/src/app/main/ui/onboarding/team_choice.cljs b/frontend/src/app/main/ui/onboarding/team_choice.cljs index 38b4afc5d7..34f602c04f 100644 --- a/frontend/src/app/main/ui/onboarding/team_choice.cljs +++ b/frontend/src/app/main/ui/onboarding/team_choice.cljs @@ -8,6 +8,7 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.schema :as sm] + [app.common.types.team :as ctt] [app.main.data.common :as dcm] [app.main.data.event :as ev] [app.main.data.profile :as du] @@ -17,7 +18,6 @@ [app.main.ui.icons :as deprecated-icon] [app.main.ui.notifications.context-notification :refer [context-notification]] [app.util.i18n :as i18n :refer [tr]] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (mf/defc left-sidebar @@ -59,7 +59,7 @@ (def ^:private schema:team-form [:map {:title "TeamForm"} - [:name [::sm/text {:max 250}]] + [:name ctt/schema:team-name] [:role :keyword] [:emails {:optional true} [::sm/set ::sm/email]]]) @@ -82,10 +82,9 @@ (fn [response] (let [team-id (:id response)] (st/emit! (du/update-profile-props {:onboarding-team-id team-id - :onboarding-viewed true}) - (println go-to-team) - (when go-to-team - (dcm/go-to-dashboard-recent :team-id team-id)))))) + :onboarding-viewed true})) + (when go-to-team + (st/emit! (dcm/go-to-dashboard-recent :team-id team-id)))))) on-error (mf/use-fn @@ -120,13 +119,13 @@ params {:name name}] (st/emit! (-> (dtm/create-team (with-meta params mdata)) (with-meta {::ev/origin :onboarding-without-invitations})) - (ptk/data-event ::ev/event - {::ev/name "onboarding-step" - :label "team:create-team-and-invite-later" - :team-name name - :step 8}) - (ptk/data-event ::ev/event - {::ev/name "onboarding-finish"}))))) + (ev/event + {::ev/name "onboarding-step" + :label "team:create-team-and-invite-later" + :team-name name + :step 8}) + (ev/event + {::ev/name "onboarding-finish"}))))) on-invite-now (mf/use-fn @@ -136,15 +135,15 @@ (st/emit! (-> (dtm/create-team-with-invitations (with-meta params mdata)) (with-meta {::ev/origin :onboarding-with-invitations})) - (ptk/data-event ::ev/event - {::ev/name "onboarding-step" - :label "team:create-team-and-invite" - :invites (count emails) - :team-name name - :role (:role params) - :step 8}) - (ptk/data-event ::ev/event - {::ev/name "onboarding-finish"}))))) + (ev/event + {::ev/name "onboarding-step" + :label "team:create-team-and-invite" + :invites (count emails) + :team-name name + :role (:role params) + :step 8}) + (ev/event + {::ev/name "onboarding-finish"}))))) on-submit* (mf/use-fn @@ -159,12 +158,12 @@ (mf/use-fn (fn [] (st/emit! (du/update-profile-props {:onboarding-viewed true}) - (ptk/data-event ::ev/event - {::ev/name "onboarding-step" - :label "team:skip-team-creation" - :step 7}) - (ptk/data-event ::ev/event - {::ev/name "onboarding-finish"}))))] + (ev/event + {::ev/name "onboarding-step" + :label "team:skip-team-creation" + :step 7}) + (ev/event + {::ev/name "onboarding-finish"}))))] [:* [:div {:class (stl/css :modal-right)} [:div {:class (stl/css :first-block)} diff --git a/frontend/src/app/main/ui/onboarding/templates.cljs b/frontend/src/app/main/ui/onboarding/templates.cljs index 546bc74b0e..c5a5796b18 100644 --- a/frontend/src/app/main/ui/onboarding/templates.cljs +++ b/frontend/src/app/main/ui/onboarding/templates.cljs @@ -42,7 +42,7 @@ (rx/subs! (fn [{:keys [body] :as response}] (open-import-modal {:name name :uri (wapi/create-uri body)})) (fn [error] - (js/console.log "error" error)) + (js/console.error "error" error)) (fn [] (reset! downloading? false)))))] diff --git a/frontend/src/app/main/ui/releases.cljs b/frontend/src/app/main/ui/releases.cljs index 50c6634d09..7776e783a9 100644 --- a/frontend/src/app/main/ui/releases.cljs +++ b/frontend/src/app/main/ui/releases.cljs @@ -33,6 +33,7 @@ [app.main.ui.releases.v2-12] [app.main.ui.releases.v2-13] [app.main.ui.releases.v2-14] + [app.main.ui.releases.v2-15] [app.main.ui.releases.v2-2] [app.main.ui.releases.v2-3] [app.main.ui.releases.v2-4] @@ -105,4 +106,4 @@ (defmethod rc/render-release-notes "0.0" [params] - (rc/render-release-notes (assoc params :version "2.14"))) + (rc/render-release-notes (assoc params :version "2.15"))) diff --git a/frontend/src/app/main/ui/releases/common.cljs b/frontend/src/app/main/ui/releases/common.cljs index 4e3ce7cc5e..3da4516e0c 100644 --- a/frontend/src/app/main/ui/releases/common.cljs +++ b/frontend/src/app/main/ui/releases/common.cljs @@ -11,7 +11,7 @@ (defmulti render-release-notes :version) -(mf/defc navigation-bullets +(mf/defc navigation-bullets* [{:keys [slide navigate total]}] [:ul {:class (stl/css :step-dots)} (for [i (range total)] diff --git a/frontend/src/app/main/ui/releases/v1_11.cljs b/frontend/src/app/main/ui/releases/v1_11.cljs index 395cd72ee7..7542f9339b 100644 --- a/frontend/src/app/main/ui/releases/v1_11.cljs +++ b/frontend/src/app/main/ui/releases/v1_11.cljs @@ -45,7 +45,7 @@ [:p "Use dissolve, slide and push animations to fade screens and imitate gestures like swipe."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}]]]]]] @@ -64,7 +64,7 @@ [:p "Now you can decide to include their backgrounds on your exports or leave them out."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}]]]]]] @@ -83,7 +83,7 @@ [:p "We’ve also added two new options to scale your designs at the view mode that might help you to make your presentations look better."]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_12.cljs b/frontend/src/app/main/ui/releases/v1_12.cljs index 65d7e2a41d..b38d7b12a1 100644 --- a/frontend/src/app/main/ui/releases/v1_12.cljs +++ b/frontend/src/app/main/ui/releases/v1_12.cljs @@ -45,7 +45,7 @@ [:p "Along with a better organization of panels (say hello to typography toolbar!) and new shortcuts that will speed your workflow."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -64,7 +64,7 @@ [:p "And they don’t come alone, but with some nice improvements to the rulers."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -82,7 +82,7 @@ [:p "Scrollbars at the design workspace will make it more obvious how to navigate it and easier for some users, for instance those who love using graphic tablets, from now on, will feel just as comfortable as those who use a mouseAnd they don’t come alone, but with some nice improvements to the rulers."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -101,7 +101,7 @@ [:p "This is a must if you’re working with grids (if you’re not, you should ;)), being able to adjust the movement to your baseline grid (8px? 5px?) is a huge timesaver that will improve your quality of life while designing."]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_13.cljs b/frontend/src/app/main/ui/releases/v1_13.cljs index 39ad2c79ac..9d3c0e6b0a 100644 --- a/frontend/src/app/main/ui/releases/v1_13.cljs +++ b/frontend/src/app/main/ui/releases/v1_13.cljs @@ -45,7 +45,7 @@ [:p "Use the export window to manage your multiple exports and be informed about the download progress. Big exports will happen in the background so you can keep designing in the meantime ;)"]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -64,7 +64,7 @@ [:p "This opens endless graphic possibilities such as combining gradients and blending modes in the same element to create sophisticated visual effects."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -83,7 +83,7 @@ [:p "A refreshed interface and two new features! The Invitations section allows you to check the status of current team invites plus you now have the ability to invite multiple members at the same time."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -102,7 +102,7 @@ [:p "As a side effect, this can give you a performance boost in massive designs."]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_14.cljs b/frontend/src/app/main/ui/releases/v1_14.cljs index 334e993f62..106ffaaf49 100644 --- a/frontend/src/app/main/ui/releases/v1_14.cljs +++ b/frontend/src/app/main/ui/releases/v1_14.cljs @@ -45,7 +45,7 @@ [:p "Categories and filters will help you to find the shortcut you need. One of the most requested features by the community!"]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -64,7 +64,7 @@ [:p "Play with the colors of a group without the hassles of individual selection!"]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -83,7 +83,7 @@ [:p "Ideal for prototyping fixed headers, navbars and floating buttons."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -102,7 +102,7 @@ [:p "Until now you could only do it by renaming the groups, now with drag & drop it is much more user friendly."]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_15.cljs b/frontend/src/app/main/ui/releases/v1_15.cljs index 9cdb26b079..0d04a305e4 100644 --- a/frontend/src/app/main/ui/releases/v1_15.cljs +++ b/frontend/src/app/main/ui/releases/v1_15.cljs @@ -45,7 +45,7 @@ [:p "Say goodbye to Artboards and hello to Boards!"]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -64,7 +64,7 @@ [:p "Now you can thanks to new permissions that allow you to decide who can comment and/or inspect the code at a shared prototype link."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -83,7 +83,7 @@ [:p "Also, comments inside boards will be associated with it, so that if you move a board its comments will maintain its place inside it."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -102,7 +102,7 @@ [:p "We’ve also made some adjustments to ensure the access to the options from small screens."]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_16.cljs b/frontend/src/app/main/ui/releases/v1_16.cljs index 537507abb6..26db75b099 100644 --- a/frontend/src/app/main/ui/releases/v1_16.cljs +++ b/frontend/src/app/main/ui/releases/v1_16.cljs @@ -45,7 +45,7 @@ [:p "We heard the users before refreshing the interface, simplifying it to give prominence to the content. And yes, now that you ask, the dark theme is coming soon."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -64,7 +64,7 @@ [:p "You no longer need to to download most of them to the computer before importing."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -83,7 +83,7 @@ [:p "More relevant info and better explanations, a refined new team and invitation flow, a beginners tutorial and a walkthrough file that will help newcomers learn how to use and start designing with Penpot faster."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -102,7 +102,7 @@ [:p "This was a contribution by our community member @andrewzhurov <3"]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_17.cljs b/frontend/src/app/main/ui/releases/v1_17.cljs index 1965748c6a..668f879f0d 100644 --- a/frontend/src/app/main/ui/releases/v1_17.cljs +++ b/frontend/src/app/main/ui/releases/v1_17.cljs @@ -45,7 +45,7 @@ [:p "Penpot brings a layout system like no other. As described by one of our beta testers: 'I love the fact that Penpot is following the CSS FlexBox, which is making UI Design a step closer to the logic and behavior behind how things will be actually built after design.'"]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -64,7 +64,7 @@ [:p "Also, inspect mode provides a safer view-only mode and other improvements."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -83,7 +83,7 @@ [:p "While we are still working on a plugin system, this is a great and simple way to create integrations with other services."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -102,7 +102,7 @@ [:p "This release comes with improvements on color contrasts, alt texts, semantic labels, focusable items and keyboard navigation at login and dashboard, but more will come."]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_18.cljs b/frontend/src/app/main/ui/releases/v1_18.cljs index cb6d73458c..ff1c06d179 100644 --- a/frontend/src/app/main/ui/releases/v1_18.cljs +++ b/frontend/src/app/main/ui/releases/v1_18.cljs @@ -45,7 +45,7 @@ [:p "And not only that, when creating Flex layouts, the spacing is predicted, helping you to maintain your design composition."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -64,7 +64,7 @@ [:p "Now you can exclude elements from the Flex layout flow using absolute position."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -83,7 +83,7 @@ [:p "This is another capability that brings Penpot Flex layout even closer to the power of CSS standards."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -102,7 +102,7 @@ [:p "Activate the scale tool by pressing K and scale your elements, maintaining their visual aspect."]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_19.cljs b/frontend/src/app/main/ui/releases/v1_19.cljs index 8543a0c45b..6b51d8faaf 100644 --- a/frontend/src/app/main/ui/releases/v1_19.cljs +++ b/frontend/src/app/main/ui/releases/v1_19.cljs @@ -72,7 +72,7 @@ " in particular and the Penpot community as a whole!"]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 2}]]]]]] @@ -99,7 +99,7 @@ "to the Penpot’s plugins system."]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 2}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_4.cljs b/frontend/src/app/main/ui/releases/v1_4.cljs index bc80923258..37ecf7be42 100644 --- a/frontend/src/app/main/ui/releases/v1_4.cljs +++ b/frontend/src/app/main/ui/releases/v1_4.cljs @@ -45,7 +45,7 @@ [:p "To open a file you just have to double click it. You can also open a file in a new tab with right click."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -64,7 +64,7 @@ [:p "Also, now you have an easy way to manage files and projects between teams."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -83,7 +83,7 @@ [:p "If you write in arabic, hebrew or other RTL language text direction will be automatically detected in text layers."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -102,7 +102,7 @@ [:p "This is why the standard blend modes and opacity level are now available for each element."]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_5.cljs b/frontend/src/app/main/ui/releases/v1_5.cljs index 8d962515d7..d183bd7976 100644 --- a/frontend/src/app/main/ui/releases/v1_5.cljs +++ b/frontend/src/app/main/ui/releases/v1_5.cljs @@ -45,7 +45,7 @@ [:p "The usability and performance of the paths tool has been improved too."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}]]]]]] @@ -64,7 +64,7 @@ [:p "It is time to have all the libraries well organized and work more efficiently."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}]]]]]] @@ -83,7 +83,7 @@ [:p "It's easier to specify by how much you want to change a value and work with measures and distances."]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_6.cljs b/frontend/src/app/main/ui/releases/v1_6.cljs index c6636c4550..cf1c96bbe9 100644 --- a/frontend/src/app/main/ui/releases/v1_6.cljs +++ b/frontend/src/app/main/ui/releases/v1_6.cljs @@ -45,7 +45,7 @@ [:p "We hope you enjoy having more typography options and our brand new font selector."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -64,7 +64,7 @@ [:p "Disabled by default, this tool is disabled back after being used."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -83,7 +83,7 @@ [:p "You should have the feeling that files and layers show up a bit faster :)"]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -102,7 +102,7 @@ [:p "An easy way to increase speed by working with vectors!"]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_7.cljs b/frontend/src/app/main/ui/releases/v1_7.cljs index 32666d5158..3d0c2db7da 100644 --- a/frontend/src/app/main/ui/releases/v1_7.cljs +++ b/frontend/src/app/main/ui/releases/v1_7.cljs @@ -48,7 +48,7 @@ suits you better!"]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -70,7 +70,7 @@ components."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -90,7 +90,7 @@ [:p "Easily " [:strong "rename and ungroup"] " asset groups."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -108,7 +108,7 @@ [:p "Do you sometimes copy and paste component copies that belong to a library already shared by the original and destination files? From now on, those component copies are aware of this and will retain their linkage to the library."]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_8.cljs b/frontend/src/app/main/ui/releases/v1_8.cljs index dfff4bd9f2..fcf214cae9 100644 --- a/frontend/src/app/main/ui/releases/v1_8.cljs +++ b/frontend/src/app/main/ui/releases/v1_8.cljs @@ -45,7 +45,7 @@ [:p "You can also create a shareable link deciding which pages will be available for the visitors. Sharing is caring!"]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -64,7 +64,7 @@ [:p "You can select different styles for each end of an open path: arrows, square, circle, diamond or just a round ending are the available options."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -83,7 +83,7 @@ [:p "Quick and easy :)"]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -102,7 +102,7 @@ [:p "Now you can easily export all the artboards of a page to a single pdf file."]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_9.cljs b/frontend/src/app/main/ui/releases/v1_9.cljs index 6a8ddfba80..d359063a6e 100644 --- a/frontend/src/app/main/ui/releases/v1_9.cljs +++ b/frontend/src/app/main/ui/releases/v1_9.cljs @@ -45,7 +45,7 @@ [:p "Create overlays, back buttons or links to URLs to mimic the behavior of the product you’re designing."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -64,7 +64,7 @@ [:p "Flows allow you to define multiple starting points within the same page so you can better organize and present your prototypes."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -83,7 +83,7 @@ [:p "Using boolean operations will lead to countless graphic possibilities for your designs."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -102,7 +102,7 @@ [:p [:a {:alt "Explore libraries & templates" :target "_blank" :href "https://penpot.app/libraries-templates"} "Explore libraries & templates"]]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v2_0.cljs b/frontend/src/app/main/ui/releases/v2_0.cljs index 57f2b0847b..fd1299d0d7 100644 --- a/frontend/src/app/main/ui/releases/v2_0.cljs +++ b/frontend/src/app/main/ui/releases/v2_0.cljs @@ -92,7 +92,7 @@ " up the design as code to take it from there."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -126,7 +126,7 @@ " and adherence to other best practices."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -161,7 +161,7 @@ "that will help you to better manage your design systems."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -193,7 +193,7 @@ [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] diff --git a/frontend/src/app/main/ui/releases/v2_10.cljs b/frontend/src/app/main/ui/releases/v2_10.cljs index fcefb326c5..297c1e77d6 100644 --- a/frontend/src/app/main/ui/releases/v2_10.cljs +++ b/frontend/src/app/main/ui/releases/v2_10.cljs @@ -74,7 +74,7 @@ "This release has been shaped by our amazing community. A huge thank-you to everyone who shared ideas, feedback, and insights to make Penpot Variants possible <3"]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -113,7 +113,7 @@ " now to join us 8-10 October, in Madrid!"]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -143,7 +143,7 @@ "This latest update brings—no more no less than—six new token types, significantly boosting your ability to manage design decisions, particularly in typography: Font Family, Font Weight, Text Case, Text Decoration, Letter Spacing token, and Number token (for unitless values)."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -174,7 +174,7 @@ [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] diff --git a/frontend/src/app/main/ui/releases/v2_11.cljs b/frontend/src/app/main/ui/releases/v2_11.cljs index a4b330f8bc..529a6cb0a7 100644 --- a/frontend/src/app/main/ui/releases/v2_11.cljs +++ b/frontend/src/app/main/ui/releases/v2_11.cljs @@ -74,7 +74,7 @@ "The Typography token also marks a big step forward for Penpot: it’s our first composite token! Composite tokens are special because they can hold multiple properties within one token. Shadow token will be the next composite token coming your way."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -110,7 +110,7 @@ "- Reorder your component properties by drag & drop: Because organization matters, now you can arrange your properties however makes the most sense to you, so you can keep the ones you use most often right where you want them."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -148,7 +148,7 @@ "Invited users will also get clearer emails, including a reminder sent one day before the invite expires (after seven days). Simple, clean, and much more efficient."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -179,7 +179,7 @@ [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] diff --git a/frontend/src/app/main/ui/releases/v2_12.cljs b/frontend/src/app/main/ui/releases/v2_12.cljs index 43ac723024..342f92100c 100644 --- a/frontend/src/app/main/ui/releases/v2_12.cljs +++ b/frontend/src/app/main/ui/releases/v2_12.cljs @@ -80,7 +80,7 @@ "Developers now get a clearer context during handoff. The Inspect panel shows the actual token used in your design, in a similar way to how styles are displayed. This small detail reduces ambiguity, aligns everyone on the same language, and strengthens collaboration across the team."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] @@ -116,7 +116,7 @@ "It’s a subtle improvement, but it removes friction you feel hundreds of times a week, and makes component work flow more naturally."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] @@ -152,7 +152,7 @@ [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] diff --git a/frontend/src/app/main/ui/releases/v2_13.cljs b/frontend/src/app/main/ui/releases/v2_13.cljs index 149d914c61..54a0badaee 100644 --- a/frontend/src/app/main/ui/releases/v2_13.cljs +++ b/frontend/src/app/main/ui/releases/v2_13.cljs @@ -74,7 +74,7 @@ "Highly requested, long overdue, and now officially here."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] @@ -108,7 +108,7 @@ [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 2}] diff --git a/frontend/src/app/main/ui/releases/v2_14.cljs b/frontend/src/app/main/ui/releases/v2_14.cljs index b424d4bfa8..9dd3013274 100644 --- a/frontend/src/app/main/ui/releases/v2_14.cljs +++ b/frontend/src/app/main/ui/releases/v2_14.cljs @@ -74,7 +74,7 @@ "One extra detail: if you edit the path and change group segments, the token is moved to its new group (creating it if needed), and empty groups are automatically cleaned up."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -104,7 +104,7 @@ "If you’ve been waiting to generate tokens, sync them, or manipulate them from your own tools, this is the missing piece. And yes, this one has been requested a lot."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -134,7 +134,7 @@ "Remapping is always optional, because sometimes you don’t want to keep the current connections. When enabled, it affects all tokens in the file and also takes libraries into account, so main components can propagate changes to child components, and applied tokens update on the elements using them."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -168,7 +168,7 @@ [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] diff --git a/frontend/src/app/main/ui/releases/v2_15.cljs b/frontend/src/app/main/ui/releases/v2_15.cljs new file mode 100644 index 0000000000..76f6527f02 --- /dev/null +++ b/frontend/src/app/main/ui/releases/v2_15.cljs @@ -0,0 +1,159 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.releases.v2-15 + (:require-macros [app.main.style :as stl]) + (:require + [app.common.data.macros :as dm] + [app.main.ui.releases.common :as c] + [rumext.v2 :as mf])) + +(defmethod c/render-release-notes "2.15" + [{:keys [slide klass next finish navigate version]}] + (mf/html + (case slide + :start + [:div {:class (stl/css-case :modal-overlay true)} + [:div.animated {:class klass} + [:div {:class (stl/css :modal-container)} + [:img {:src "images/features/2.15-slide-0.jpg" + :class (stl/css :start-image) + :border "0" + :alt "Penpot 2.15 is here!"}] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-header)} + [:h1 {:class (stl/css :modal-title)} + "What’s new in Penpot?"] + + [:div {:class (stl/css :version-tag)} + (dm/str "Version " version)]] + + [:div {:class (stl/css :features-block)} + [:span {:class (stl/css :feature-title)} + "One major feature: the Penpot MCP Server, with infinite workflow possibilities"] + + [:p {:class (stl/css :feature-content)} + "This release marks a major MCP milestone: Penpot MCP moves from an early technical setup to an accessible in-app experience via hosted remote setup. Whether you already know MCP or are new to it, it's now zero-friction to connect your AI client and turn prompts into real actions on real design data."] + + [:p {:class (stl/css :feature-content)} + "With 2.15, we are opening the door to truly multi-directional workflows between design and code, while staying faithful to Penpot values: openness, freedom of choice, and respect for your data."] + + [:p {:class (stl/css :feature-content)} + "Let’s dive in!"]] + + [:div {:class (stl/css :navigation)} + [:button {:class (stl/css :next-btn) + :on-click next} "Continue"]]]]]] + + 0 + [:div {:class (stl/css-case :modal-overlay true)} + [:div.animated {:class klass} + [:div {:class (stl/css :modal-container)} + [:img {:src "images/features/2.15-mcp-01.gif" + :class (stl/css :start-image) + :border "0" + :alt "Penpot MCP Server: AI connected to real design context"}] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-header)} + [:h1 {:class (stl/css :modal-title)} + "Penpot MCP Server: AI connected to real design context"]] + + [:div {:class (stl/css :feature)} + [:p {:class (stl/css :feature-content)} + "Penpot MCP Server is the bridge between your AI client and your Penpot file. You describe what you need in natural language, your agent picks the right operation, and MCP translates that into real actions through Penpot APIs."] + + [:p {:class (stl/css :feature-content)} + "This is not a generic 'describe and generate' flow. It is context-aware work with components, tokens, pages, layers, and structure. In short: design expressed as code, now usable through your preferred AI assistant."] + + [:p {:class (stl/css :feature-content)} + "You can run MCP in two ways. Remote MCP is hosted and simpler to set up. Local MCP runs on your machine and gives advanced teams extra control. Same vision, different operating model."]] + + [:div {:class (stl/css :navigation)} + [:> c/navigation-bullets* + {:slide slide + :navigate navigate + :total 4}] + + [:button {:on-click next + :class (stl/css :next-btn)} "Continue"]]]]]] + + 1 + [:div {:class (stl/css-case :modal-overlay true)} + [:div.animated {:class klass} + [:div {:class (stl/css :modal-container)} + [:img {:src "images/features/2.15-mcp-02.gif" + :class (stl/css :start-image) + :border "0" + :alt "Multi-directional workflows, from design to code and back"}] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-header)} + [:h1 {:class (stl/css :modal-title)} + "Multi-directional workflows, from design to code and back"]] + + [:div {:class (stl/css :feature)} + [:p {:class (stl/css :feature-content)} + "The biggest unlock in 2.15 is multi-directionality. You can move from design to code, and from code back to design, without losing intent or structure in the process."] + + [:p {:class (stl/css :feature-content)} + "• Generate semantic HTML/CSS from real layouts."] + [:p {:class (stl/css :feature-content)} + "• Translate tokens and styles into code variables."] + [:p {:class (stl/css :feature-content)} + "• Export only assets in use."] + [:p {:class (stl/css :feature-content)} + "• Validate design-code consistency."] + [:p {:class (stl/css :feature-content)} + "• Reorganize layers, apply naming rules, and automate repetitive design system maintenance."] + + [:p {:class (stl/css :feature-content)} + "This is where MCP becomes workflow infrastructure. Less manual glue work, fewer handoff gaps, and faster iterations between designers and developers."]] + + [:div {:class (stl/css :navigation)} + [:> c/navigation-bullets* + {:slide slide + :navigate navigate + :total 4}] + + [:button {:on-click next + :class (stl/css :next-btn)} "Continue"]]]]]] + + 2 + [:div {:class (stl/css-case :modal-overlay true)} + [:div.animated {:class klass} + [:div {:class (stl/css :modal-container)} + [:img {:src "images/features/2.15-mcp-03.gif" + :class (stl/css :start-image) + :border "0" + :alt "Your stack, your model, your control"}] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-header)} + [:h1 {:class (stl/css :modal-title)} + "Your stack, your model, your control"]] + + [:div {:class (stl/css :feature)} + [:p {:class (stl/css :feature-content)} + "With MCP, you connect Penpot to the AI client and model you already trust. Cursor, Claude, VS Code, Codex, or another MCP-compatible setup: the workflow adapts to your stack, not the other way around."] + + [:p {:class (stl/css :feature-content)} + "You can run it hosted for a faster setup, or locally when you need tighter infrastructure control. The same applies to data boundaries: Penpot provides the bridge to your design context, while your team decides how and where AI runs."] + + [:p {:class (stl/css :feature-content)} + "In practice, this means teams can automate design and code workflows without giving up tool freedom, deployment control, or ownership of their process. +"]] + [:div {:class (stl/css :navigation)} + + [:> c/navigation-bullets* + {:slide slide + :navigate navigate + :total 3}] + + [:button {:on-click finish + :class (stl/css :next-btn)} "Let's go"]]]]]]))) + diff --git a/frontend/src/app/main/ui/releases/v2_15.scss b/frontend/src/app/main/ui/releases/v2_15.scss new file mode 100644 index 0000000000..40c8f5316f --- /dev/null +++ b/frontend/src/app/main/ui/releases/v2_15.scss @@ -0,0 +1,108 @@ +// 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 + +@use "refactor/common-refactor.scss" as deprecated; + +.modal-overlay { + @extend %modal-overlay-base; +} + +.modal-container { + display: grid; + grid-template-columns: deprecated.$s-324 1fr; + height: deprecated.$s-500; + width: deprecated.$s-888; + border-radius: deprecated.$br-8; + background-color: var(--modal-background-color); + border: deprecated.$s-2 solid var(--modal-border-color); +} + +.start-image { + width: deprecated.$s-324; + border-radius: deprecated.$br-8 0 0 deprecated.$br-8; +} + +.modal-content { + padding: deprecated.$s-40; + display: grid; + grid-template-rows: auto 1fr deprecated.$s-32; + gap: deprecated.$s-24; + + a { + color: var(--button-primary-background-color-rest); + } +} + +.modal-header { + display: grid; + gap: deprecated.$s-8; +} + +.version-tag { + @include deprecated.flex-center; + @include deprecated.headline-small-typography; + + height: deprecated.$s-32; + width: deprecated.$s-96; + background-color: var(--communication-tag-background-color); + color: var(--communication-tag-foreground-color); + border-radius: deprecated.$br-8; +} + +.modal-title { + @include deprecated.headline-large-typography; + + color: var(--modal-title-foreground-color); +} + +.features-block { + display: flex; + flex-direction: column; + gap: deprecated.$s-16; + width: deprecated.$s-440; +} + +.feature { + display: flex; + flex-direction: column; + gap: deprecated.$s-8; +} + +.feature-title { + @include deprecated.body-large-typography; + + color: var(--modal-title-foreground-color); +} + +.feature-content { + @include deprecated.body-medium-typography; + + margin: 0; + color: var(--modal-text-foreground-color); +} + +.feature-list { + @include deprecated.body-medium-typography; + + color: var(--modal-text-foreground-color); + list-style: disc; + display: grid; + gap: deprecated.$s-8; +} + +.navigation { + width: 100%; + display: grid; + grid-template-areas: "bullets button"; +} + +.next-btn { + @extend %button-primary; + + width: deprecated.$s-100; + justify-self: flex-end; + grid-area: button; +} diff --git a/frontend/src/app/main/ui/releases/v2_3.cljs b/frontend/src/app/main/ui/releases/v2_3.cljs index 8b3040b8f4..6063642485 100644 --- a/frontend/src/app/main/ui/releases/v2_3.cljs +++ b/frontend/src/app/main/ui/releases/v2_3.cljs @@ -72,7 +72,7 @@ "Find everything you need in our full comprehensive documentation to start building your plugins now!"]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 2}] @@ -105,7 +105,7 @@ [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 2}] diff --git a/frontend/src/app/main/ui/releases/v2_4.cljs b/frontend/src/app/main/ui/releases/v2_4.cljs index 1559911a4d..67a3985127 100644 --- a/frontend/src/app/main/ui/releases/v2_4.cljs +++ b/frontend/src/app/main/ui/releases/v2_4.cljs @@ -72,7 +72,7 @@ "Now, you can invite members to your teams who only need to view and comment on files. Team members, stakeholders, developers… pick your case. Anyone who doesn't need to edit can participate confidently."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] @@ -102,7 +102,7 @@ "Some versions are saved automatically, serving as an invaluable emergency backup. Additionally, you can manually save versions, giving you full control over the timeline associated with a file. This way, you can always restore specific versions that you've intentionally saved."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] @@ -131,7 +131,7 @@ [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] diff --git a/frontend/src/app/main/ui/releases/v2_5.cljs b/frontend/src/app/main/ui/releases/v2_5.cljs index c39c4aebba..cce9c83d70 100644 --- a/frontend/src/app/main/ui/releases/v2_5.cljs +++ b/frontend/src/app/main/ui/releases/v2_5.cljs @@ -72,7 +72,7 @@ "And that’s not all. We’ve also added quick actions to flip and rotate gradients, plus now you can adjust the radius for radial gradients. More control, more flexibility, more fun."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -102,7 +102,7 @@ "We’ve also added a new section in your profile where you can customize your notifications, choosing what to receive on your dashboard and via email. On top of that, comments got a UI refresh, making everything clearer and better organized. And this is just the first batch of improvements—expect even more comment-related upgrades in the next Penpot release."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -136,7 +136,7 @@ "Less manual work for a faster workflow. We hope you find it as useful as we do."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -165,7 +165,7 @@ [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] diff --git a/frontend/src/app/main/ui/releases/v2_6.cljs b/frontend/src/app/main/ui/releases/v2_6.cljs index 9d47c870f7..92b4109fd7 100644 --- a/frontend/src/app/main/ui/releases/v2_6.cljs +++ b/frontend/src/app/main/ui/releases/v2_6.cljs @@ -84,7 +84,7 @@ your product needs."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] @@ -120,7 +120,7 @@ interoperability by design through Open Source."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] @@ -159,7 +159,7 @@ [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] diff --git a/frontend/src/app/main/ui/releases/v2_7.cljs b/frontend/src/app/main/ui/releases/v2_7.cljs index 1a5c562e25..0f6c51abc7 100644 --- a/frontend/src/app/main/ui/releases/v2_7.cljs +++ b/frontend/src/app/main/ui/releases/v2_7.cljs @@ -71,7 +71,7 @@ "The highlight: you can now duplicate token sets directly from a menu item. A huge time-saver, especially when working from existing sets. We’ve also made it easier to create themes by letting you select their set right away, and we’ve polished some info indicators to make everything a bit clearer. Plus, we’ve fixed a bunch of early-stage bugs to keep things running smoothly."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] @@ -101,7 +101,7 @@ "This update gives editors and viewers the same ability to configure, create, copy, and delete sharing links. A capability that, until now, was limited to owners and admins."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] @@ -132,7 +132,7 @@ [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] diff --git a/frontend/src/app/main/ui/releases/v2_8.cljs b/frontend/src/app/main/ui/releases/v2_8.cljs index fbdce6ee04..8eae8ff74e 100644 --- a/frontend/src/app/main/ui/releases/v2_8.cljs +++ b/frontend/src/app/main/ui/releases/v2_8.cljs @@ -83,7 +83,7 @@ "- And we’ve a new language! Hi Serbians!"]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -116,7 +116,7 @@ "This is just one more step in the evolution of Design Tokens in Penpot. And there's more to come: typography tokens are already in the works!"]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -149,7 +149,7 @@ "- We have integrated AI-powered help, which is trained on Penpot documentation, directly into the design workspace. Get assistance without switching context, so you can stay in the flow."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -186,7 +186,7 @@ [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] diff --git a/frontend/src/app/main/ui/releases/v2_9.cljs b/frontend/src/app/main/ui/releases/v2_9.cljs index 600df72665..bd71956516 100644 --- a/frontend/src/app/main/ui/releases/v2_9.cljs +++ b/frontend/src/app/main/ui/releases/v2_9.cljs @@ -71,7 +71,7 @@ "And there’s more progress on Tokens, including support for importing multiple token files via .zip, and smarter token visibility, only showing the relevant tokens for each layer type."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 2}] @@ -102,7 +102,7 @@ [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 2}] diff --git a/frontend/src/app/main/ui/settings/delete_account.cljs b/frontend/src/app/main/ui/settings/delete_account.cljs index 6f939a6390..9cd19f1f59 100644 --- a/frontend/src/app/main/ui/settings/delete_account.cljs +++ b/frontend/src/app/main/ui/settings/delete_account.cljs @@ -7,10 +7,13 @@ (ns app.main.ui.settings.delete-account (:require-macros [app.main.style :as stl]) (:require + [app.config :as cf] [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] [app.main.data.profile :as du] + [app.main.repo :as rp] [app.main.store :as st] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.icons :as deprecated-icon] [app.main.ui.notifications.context-notification :refer [context-notification]] [app.util.i18n :as i18n :refer [tr]] @@ -29,17 +32,34 @@ {::mf/register modal/components ::mf/register-as :delete-account} [] - (let [on-accept + (let [orgs* (mf/use-state nil) + orgs (deref orgs*) + has-orgs? (seq orgs) + + expanded* (mf/use-state true) + expanded? (deref expanded*) + on-toggle (mf/use-fn #(swap! expanded* not)) + + on-accept (mf/use-fn #(st/emit! (modal/hide) (du/request-account-deletion (with-meta {} {:on-error on-error}))))] + (mf/with-effect [] + (if (contains? cf/flags :nitrate) + (let [sub (->> (rp/cmd! :get-owned-organizations-summary {}) + (rx/subs! + (fn [result] (reset! orgs* (or result []))) + (fn [_] (reset! orgs* []))))] + (fn [] + (rx/dispose! sub))) + (reset! orgs* []))) + [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-container)} [:div {:class (stl/css :modal-header)} - [:h2 {:class (stl/css :modal-title)} (tr "modals.delete-account.title")] [:button {:class (stl/css :modal-close-btn) :on-click modal/hide!} deprecated-icon/close]] @@ -47,7 +67,33 @@ [:div {:class (stl/css :modal-content)} [:& context-notification {:level :warning - :content (tr "modals.delete-account.info")}]] + :content (tr "modals.delete-account.info")}] + + (when has-orgs? + [:div {:class (stl/css :orgs-section)} + [:button {:class (stl/css :orgs-section-toggle) + :type "button" + :aria-expanded expanded? + :on-click on-toggle} + [:span {:class (stl/css :orgs-section-title)} + (tr "modals.delete-account.owned-orgs.list-title")] + [:> icon* {:icon-id i/arrow + :size "s" + :class (stl/css-case :orgs-section-arrow true + :expanded expanded?)}]] + (when expanded? + [:ul {:class (stl/css :org-list)} + (for [{:keys [id name team-count member-count]} orgs] + [:li {:class (stl/css :org-item) :key id} + [:div {:class (stl/css :org-avatar)} + (when (seq name) (subs name 0 1))] + [:div {:class (stl/css :org-info)} + [:span {:class (stl/css :org-name)} name] + [:div {:class (stl/css :org-counts)} + [:span (tr "modals.delete-account.owned-orgs.teams-count" + (i18n/c (or team-count 0)))] + [:span (tr "modals.delete-account.owned-orgs.members-count" + (i18n/c (or member-count 0)))]]]])])])] [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} @@ -59,4 +105,3 @@ :on-click on-accept :data-testid "delete-account-btn"} (tr "modals.delete-account.confirm")]]]]])) - diff --git a/frontend/src/app/main/ui/settings/delete_account.scss b/frontend/src/app/main/ui/settings/delete_account.scss index 4b0b6408c9..6d043a5fbe 100644 --- a/frontend/src/app/main/ui/settings/delete_account.scss +++ b/frontend/src/app/main/ui/settings/delete_account.scss @@ -5,6 +5,10 @@ // Copyright (c) KALEIDOS INC @use "refactor/common-refactor.scss" as deprecated; +@use "ds/typography.scss" as t; +@use "ds/spacing.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; .modal-overlay { @extend %modal-overlay-base; @@ -48,6 +52,84 @@ color: var(--modal-title-foreground-color); } +.orgs-section { + @include t.use-typography("body-medium"); + + display: flex; + flex-direction: column; + gap: var(--sp-l); +} + +.orgs-section-toggle { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--sp-xs); + padding: 0; + border: none; + background: transparent; + cursor: pointer; +} + +.orgs-section-title { + color: var(--color-accent-tertiary); +} + +.orgs-section-arrow { + color: var(--color-accent-tertiary); + transition: transform 0.15s ease; +} + +.orgs-section-arrow.expanded { + transform: rotate(90deg); +} + +.org-list { + display: flex; + flex-direction: column; + gap: var(--sp-l); + margin: 0; + padding: 0; + list-style: none; +} + +.org-item { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--sp-s); +} + +.org-avatar { + width: $sz-32; + height: $sz-32; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background-color: var(--modal-section-background-color); + color: var(--modal-title-foreground-color); + text-transform: uppercase; + font-weight: 600; +} + +.org-info { + display: flex; + flex-direction: column; +} + +.org-name { + color: var(--modal-title-foreground-color); +} + +.org-counts { + display: flex; + flex-direction: row; + gap: var(--sp-s); + color: var(--df-secondary); +} + .action-buttons { @extend %modal-action-btns; } diff --git a/frontend/src/app/main/ui/settings/integrations.cljs b/frontend/src/app/main/ui/settings/integrations.cljs index 780f9918e3..2c552ddc19 100644 --- a/frontend/src/app/main/ui/settings/integrations.cljs +++ b/frontend/src/app/main/ui/settings/integrations.cljs @@ -34,15 +34,8 @@ [app.util.dom :as dom] [app.util.forms :as fm] [app.util.i18n :as i18n :refer [tr]] - [okulary.core :as l] [rumext.v2 :as mf])) -(def tokens-ref - (l/derived :access-tokens st/state)) - -(def token-created-ref - (l/derived :access-token-created st/state)) - (def notification-timeout 7000) (def ^:private schema:form-access-token @@ -78,7 +71,7 @@ (mf/defc token-created* {::mf/private true} [{:keys [title mcp-key?]}] - (let [token-created (mf/deref token-created-ref) + (let [token-created (mf/deref refs/access-token-created) on-copy-to-clipboard (mf/use-fn @@ -310,7 +303,7 @@ [] (let [created? (mf/use-state false) - tokens (mf/deref tokens-ref) + tokens (mf/deref refs/access-tokens) mcp-key (some #(when (= (:type %) "mcp") %) tokens) mcp-key-id (:id mcp-key) @@ -413,15 +406,19 @@ (mf/defc mcp-server-section* {::mf/private true} [] - (let [tokens (mf/deref tokens-ref) + (let [tokens (mf/deref refs/access-tokens) profile (mf/deref refs/profile) mcp-key (some #(when (= (:type %) "mcp") %) tokens) + mcp-token (:token mcp-key "") + mcp-url (dm/str cf/mcp-server-url "?userToken=" mcp-token) mcp-enabled? (true? (-> profile :props :mcp-enabled)) expires-at (:expires-at mcp-key) expired? (and (some? expires-at) (> (ct/now) expires-at)) + show-enabled? (and mcp-enabled? (false? expired?)) + tooltip-id (mf/use-id) @@ -462,9 +459,10 @@ on-copy-to-clipboard (mf/use-fn + (mf/deps mcp-url) (fn [event] (dom/prevent-default event) - (clipboard/to-clipboard cf/mcp-server-url) + (clipboard/to-clipboard mcp-url) (st/emit! (ntf/show {:level :info :type :toast :content (tr "integrations.notification.success.copied-link") @@ -511,14 +509,17 @@ (tr "integrations.mcp-server.status.expired.1")]]]) [:div {:class (stl/css :mcp-server-switch)} - [:> switch* {:label (if mcp-enabled? + [:> switch* {:label (if show-enabled? (tr "integrations.mcp-server.status.enabled") (tr "integrations.mcp-server.status.disabled")) - :default-checked mcp-enabled? + :default-checked show-enabled? :on-change handle-mcp-change}] (when (and (false? mcp-enabled?) (nil? mcp-key)) [:div {:class (stl/css :mcp-server-switch-cover) - :on-click handle-generate-mcp-key}])]]] + :on-click handle-generate-mcp-key}]) + (when (true? expired?) + [:div {:class (stl/css :mcp-server-switch-cover) + :on-click handle-regenerate-mcp-key}])]]] (when (some? mcp-key) [:div {:class (stl/css :mcp-server-key)} @@ -552,7 +553,7 @@ :class (stl/css :color-secondary)} (tr "integrations.mcp-server.mcp-keys.info")] - [:> input-copy* {:value (dm/str cf/mcp-server-url "?userToken=") + [:> input-copy* {:value mcp-url :on-copy-to-clipboard on-copy-to-clipboard}] [:> text* {:as "div" @@ -567,7 +568,7 @@ (mf/defc access-tokens-section* {::mf/private true} [] - (let [tokens (mf/deref tokens-ref) + (let [tokens (mf/deref refs/access-tokens) handle-click (mf/use-fn diff --git a/frontend/src/app/main/ui/settings/notifications.cljs b/frontend/src/app/main/ui/settings/notifications.cljs index 5779474c70..d9347b5ee9 100644 --- a/frontend/src/app/main/ui/settings/notifications.cljs +++ b/frontend/src/app/main/ui/settings/notifications.cljs @@ -82,6 +82,7 @@ [:> fm/submit-button* {:label (tr "dashboard.settings.notifications.submit") + :disabled (= (:data @form) (:initial @form)) :data-testid "submit-settings" :class (stl/css :update-btn)}]]]])) diff --git a/frontend/src/app/main/ui/settings/options.cljs b/frontend/src/app/main/ui/settings/options.cljs index fc5e9e14af..7a2fc59413 100644 --- a/frontend/src/app/main/ui/settings/options.cljs +++ b/frontend/src/app/main/ui/settings/options.cljs @@ -7,16 +7,24 @@ (ns app.main.ui.settings.options (:require-macros [app.main.style :as stl]) (:require + [app.config :as cf] [app.main.data.notifications :as ntf] [app.main.data.profile :as du] [app.main.refs :as refs] + [app.main.router :as rt] [app.main.store :as st] [app.main.ui.components.forms :as fm] + [app.main.ui.ds.controls.switch :refer [switch*]] + [app.main.ui.ds.foundations.assets.icon :refer [icon*]] + [app.main.ui.ds.foundations.typography :as t] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.foundations.typography.text :refer [text*]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.theme :as theme] [rumext.v2 :as mf])) + (def ^:private schema:options-form [:map {:title "OptionsForm"} [:lang {:optional true} [:string {:max 20}]] @@ -72,18 +80,49 @@ [:> fm/submit-button* {:label (tr "dashboard.update-settings") + :disabled (= (:data @form) (:initial @form)) :data-testid "submit-lang-change" :class (stl/css :btn-primary)}]])) -;; --- Password Page +(defn ^:private go-settings-feedback + [event] + (dom/prevent-default event) + (st/emit! (rt/nav :settings-feedback))) + +(mf/defc webgl-settings* + [{:keys [renderer]}] + (let [wasm-renderer? (= renderer :wasm) + handle-render-change + (mf/use-fn + (fn [enabled?] + (st/emit! (du/update-profile-props {:renderer (if enabled? :wasm :svg)}) + (ntf/success (tr (if enabled? + "webgl.toast.webgl-render-enabled" + "webgl.toast.webgl-render-disabled"))))))] + [:section {:class (stl/css :webgl-container)} + [:header {:class (stl/css :webgl-header)} + [:> heading* {:class (stl/css :title) :level 2 :typography t/title-large} (tr "dashboard.webgl-switch.title")] + [:> text* {:as "span" :class (stl/css :beta) :typography t/body-small} (tr "dashboard.webgl-switch.beta")]] + [:> text* {:class (stl/css :description) :typography t/body-medium} (tr "dashboard.webgl-switch.description")] + [:form {:class (stl/css :webgl-form)} + [:> heading* {:level 3 :typography t/headline-small} (tr "dashboard.webgl-switch.status")] + [:> switch* {:label (if wasm-renderer? (tr "dashboard.webgl-switch.enabled") (tr "dashboard.webgl-switch.disabled")) + :default-checked wasm-renderer? + :on-change handle-render-change}]] + [:> text* {:typography t/body-medium :class (stl/css :feedback)} [:a {:href "#" :on-click go-settings-feedback :class (stl/css :link)} (tr "dashboard.webgl-switch.feedback") [:> icon* {:icon-id "arrow-up-right" :size "s"}]]]])) (mf/defc options-page [] - (mf/use-effect - #(dom/set-html-title (tr "title.settings.options"))) + (let [profile (mf/deref refs/profile) + renderer (or (-> profile :props :renderer) :svg)] + (mf/use-effect + #(dom/set-html-title (tr "title.settings.options"))) - [:div {:class (stl/css :dashboard-settings)} - [:div {:class (stl/css :form-container) :data-testid "settings-form"} - [:h2 (tr "labels.settings")] - [:& options-form {}]]]) + [:div {:class (stl/css :dashboard-settings)} + [:* + [:div {:class (stl/css :form-container) :data-testid "settings-form"} + [:h2 (tr "labels.settings")] + [:& options-form {}]] + (when (contains? cf/flags :render-switch) + [:> webgl-settings* {:renderer renderer}])]])) diff --git a/frontend/src/app/main/ui/settings/options.scss b/frontend/src/app/main/ui/settings/options.scss index 474e96838f..566b6270df 100644 --- a/frontend/src/app/main/ui/settings/options.scss +++ b/frontend/src/app/main/ui/settings/options.scss @@ -5,3 +5,58 @@ // Copyright (c) KALEIDOS INC @use "./profile" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_borders.scss" as *; + +.dashboard-settings { + display: grid; +} + +.form-container { + &:first-child { + margin-block-end: var(--sp-xxxl); + } +} + +/* Copied from profile.scss .form-container, but without included nested + rules, since we want custom styles for it */ +.webgl-container { + display: grid; + grid-auto-rows: auto; + gap: var(--sp-s); + width: $sz-500; + padding-block-start: var(--sp-xxxl); + margin: var(--sp-xxxl) $sz-120; /* FIXME: this should be a proper token */ + border-block-start: $b-1 solid var(--color-background-quaternary); + color: var(--color-foreground-primary); +} + +.webgl-header { + display: flex; + flex-direction: row; + align-items: baseline; + gap: var(--sp-m); +} + +.description { + color: var(--color-foreground-secondary); +} + +.beta { + color: var(--color-accent-primary); + padding: var(--sp-xxs) var(--sp-s); + border: $b-1 solid var(--color-accent-primary); + border-radius: $br-4; +} + +.title { + color: var(--color-foreground-primary); +} + +.link { + color: var(--color-accent-primary); + text-decoration: none; + display: flex; + align-items: center; + gap: var(--sp-xs); +} diff --git a/frontend/src/app/main/ui/settings/sidebar.cljs b/frontend/src/app/main/ui/settings/sidebar.cljs index 49ffcb6d19..7f35f4787f 100644 --- a/frontend/src/app/main/ui/settings/sidebar.cljs +++ b/frontend/src/app/main/ui/settings/sidebar.cljs @@ -18,7 +18,6 @@ [app.main.ui.icons :as deprecated-icon] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (def ^:private arrow-icon @@ -52,7 +51,7 @@ (defn- show-release-notes [event] (let [version (:main cf/version)] - (st/emit! (ptk/event ::ev/event {::ev/name "show-release-notes" :version version})) + (st/emit! (ev/event {::ev/name "show-release-notes" :version version})) (if (and (kbd/alt? event) (kbd/mod? event)) (st/emit! (modal/show {:type :onboarding})) diff --git a/frontend/src/app/main/ui/settings/subscription.cljs b/frontend/src/app/main/ui/settings/subscription.cljs index d5a22232bc..1a9d2174d4 100644 --- a/frontend/src/app/main/ui/settings/subscription.cljs +++ b/frontend/src/app/main/ui/settings/subscription.cljs @@ -4,12 +4,12 @@ [app.common.data.macros :as dm] [app.common.schema :as sm] [app.common.time :as ct] - [app.common.uri :as u] [app.config :as cf] [app.main.data.auth :as da] [app.main.data.event :as ev] [app.main.data.modal :as modal] [app.main.data.nitrate :as dnt] + [app.main.data.notifications :as ntf] [app.main.refs :as refs] [app.main.router :as rt] [app.main.store :as st] @@ -18,71 +18,125 @@ [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]] + [app.main.ui.nitrate.nitrate-activation-success-modal] [app.main.ui.notifications.badge :refer [badge-notification]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr c]] - [potok.v2.core :as ptk] + [beicon.v2.core :as rx] [rumext.v2 :as mf])) (mf/defc plan-card* - {::mf/props :obj} + {::mf/wrap [mf/memo]} [{:keys [card-title card-title-icon price-value price-period cancel-at benefits-title benefits - cta-text - cta-link - cta-text-trial - cta-link-trial - cta-text-with-icon - cta-link-with-icon + cta-text cta-link + cta-text-trial cta-link-trial + cta-text-with-icon cta-link-with-icon + code-action editors recommended - show-button-cta]}] + show-button-cta + inline-error]}] - [:div {:class (stl/css-case :plan-card true - :plan-card-highlight recommended)} - [:div {:class (stl/css :plan-card-header)} - [:div {:class (stl/css :plan-card-title-container)} - (when card-title-icon - [:> icon* {:icon-id card-title-icon - :class (stl/css :plan-title-icon) - :size "s"}]) - [:h4 {:class (stl/css :plan-card-title)} card-title] - (when recommended - [:& badge-notification {:content (tr "subscription.settings.recommended") - :size :small - :is-focus true}]) - (when editors [:span {:class (stl/css :plan-editors)} (tr "subscription.settings.editors" editors)])] - (when (and price-value price-period) - [:div {:class (stl/css :plan-price)} - [:span {:class (stl/css :plan-price-value)} price-value] - [:span {:class (stl/css :plan-price-period)} " / " price-period]]) - (when cancel-at - [:div {:class (stl/css :plan-cancel)} - [:span {:class (stl/css :plan-cancel-date)} cancel-at]])] - (when benefits-title [:h5 {:class (stl/css :benefits-title)} benefits-title]) - [:ul {:class (stl/css :benefits-list)} - (for [benefit benefits] - [:li {:key (dm/str benefit) :class (stl/css :benefit)} "- " benefit])] - (when (and cta-link-with-icon cta-text-with-icon) - [:button {:class (stl/css :cta-button :more-info) - :on-click cta-link-with-icon} cta-text-with-icon - [:> icon* {:icon-id "open-link" - :size "s"}]]) - (when (and cta-link cta-text (not show-button-cta)) - [:button {:class (stl/css-case :cta-button true - :bottom-link (not (and cta-link-trial cta-text-trial))) - :on-click cta-link} cta-text]) - (when (and cta-link cta-text show-button-cta) - [:> button* {:variant "primary" - :type "button" - :class (stl/css-case :bottom-button (not (and cta-link-trial cta-text-trial))) - :on-click cta-link} cta-text]) - (when (and cta-link-trial cta-text-trial) - [:button {:class (stl/css :cta-button :bottom-link) - :on-click cta-link-trial} cta-text-trial])]) + (let [has-trial? (and cta-link-trial cta-text-trial) + has-cta-with-icon? (and cta-link-with-icon cta-text-with-icon) + has-cta-button (and cta-link cta-text show-button-cta) + has-cta-link (and cta-link cta-text (not show-button-cta))] + [:div {:class (stl/css-case :plan-card true + :plan-card-highlight recommended)} + [:div {:class (stl/css :plan-card-header)} + [:div {:class (stl/css :plan-card-title-container)} + (when card-title-icon + [:> icon* {:icon-id card-title-icon + :class (stl/css :plan-title-icon) + :size "s"}]) + [:h4 {:class (stl/css :plan-card-title)} card-title] + (when recommended + [:& badge-notification {:content (tr "subscription.settings.recommended") + :size :small + :is-focus true}]) + (when editors + [:span {:class (stl/css :plan-editors)} (tr "subscription.settings.editors" editors)])] + (when (and price-value price-period) + [:div {:class (stl/css :plan-price)} + [:span {:class (stl/css :plan-price-value)} price-value] + [:span {:class (stl/css :plan-price-period)} " / " price-period]]) + (when cancel-at + [:div {:class (stl/css :plan-cancel)} + [:span {:class (stl/css :plan-cancel-date)} cancel-at]])] + (when benefits-title + [:h5 {:class (stl/css :benefits-title)} benefits-title]) + [:ul {:class (stl/css :benefits-list)} + (for [benefit benefits] + [:li {:key (dm/str benefit) :class (stl/css :benefit)} "- " benefit])] + + (when has-cta-button + [:> button* {:variant "primary" + :type "button" + :class (stl/css-case :bottom-button (not has-trial?)) + :on-click cta-link} cta-text]) + (when has-trial? + [:button {:class (stl/css :cta-button :bottom-link) + :on-click cta-link-trial} cta-text-trial]) + (when has-cta-with-icon? + [:button {:class (stl/css :cta-button :more-info) + :on-click cta-link-with-icon} cta-text-with-icon + [:> icon* {:icon-id "open-link" + :size "s"}]]) + (when has-cta-link + [:button {:class (stl/css-case :cta-button true + :bottom-link (not (or has-trial? code-action))) + :on-click cta-link} cta-text]) + (when code-action + [:button {:class (stl/css-case :cta-button true + :activate-by-code (= code-action :activate) + :renew-by-code (= code-action :renovate) + :bottom-link (= code-action :renovate)) + ;; TODO add renovation modal + :on-click (when (= code-action :activate) + #(st/emit! (modal/show {:type :nitrate-code-activation})))} + (if (= code-action :activate) + (tr "subscription.settings.activate-by-code") + (tr "nitrate.subscription.settings.renew-with-code"))]) + (when inline-error + [:p {:class (stl/css :inline-error)} inline-error])])) + +(defn- get-subscription-name [subscription-type subscribe-to-trial?] + (if subscribe-to-trial? + (if (= subscription-type "unlimited") + (tr "subscription.settings.unlimited-trial") + (tr "subscription.settings.enterprise-trial")) + (case subscription-type + "professional" (tr "subscription.settings.professional") + "unlimited" (tr "subscription.settings.unlimited") + "enterprise" (tr "subscription.settings.enterprise")))) + +(mf/defc ^:private editors-section* + [{:keys [editors]}] + (let [show-editors-list* (mf/use-state false) + show-editors-list (deref show-editors-list*) + handle-click (mf/use-fn + (fn [event] + (dom/stop-propagation event) + (swap! show-editors-list* not)))] + [:* + [:p {:class (stl/css :editors-text)} + (tr "subscription.settings.management.dialog.currently-editors-title" (c (count editors)))] + [:button {:class (stl/css :cta-button :show-editors-button) :on-click handle-click} + (tr "subscription.settings.management.dialog.editors") + [:> icon* {:icon-id (if show-editors-list i/arrow-up i/arrow-down) + :class (stl/css :icon-dropdown) + :size "s"}]] + (when show-editors-list + [:* + [:p {:class (stl/css :editors-text :editors-list-warning)} + (tr "subscription.settings.management.dialog.editors-explanation")] + [:ul {:class (stl/css :editors-list)} + (for [editor editors] + [:li {:key (dm/str (:id editor)) :class (stl/css :team-name)} "- " (:name editor)])]])])) (defn- make-management-form-schema [min-editors] [:map {:title "SeatsForm"} @@ -101,14 +155,7 @@ (deref unlimited-modal-step*) subscription-name - (if subscribe-to-trial - (if (= subscription-type "unlimited") - (tr "subscription.settings.unlimited-trial") - (tr "subscription.settings.enterprise-trial")) - (case subscription-type - "professional" (tr "subscription.settings.professional") - "unlimited" (tr "subscription.settings.unlimited") - "enterprise" (tr "subscription.settings.enterprise"))) + (get-subscription-name subscription-type subscribe-to-trial) min-editors (if (seq editors) (count editors) 1) @@ -139,16 +186,16 @@ add-payment-details? "&quantity=" min-members "&returnUrl=" return-url)] (reset! form nil) - (st/emit! (ptk/event ::ev/event {::ev/name "create-trial-subscription" - :type "unlimited" - :quantity min-members}) + (st/emit! (ev/event {::ev/name "create-trial-subscription" + :type "unlimited" + :quantity min-members}) (rt/nav-raw :href href)))))) subscribe-to-enterprise (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "create-trial-subscription" - :type "enterprise"})) + (st/emit! (ev/event {::ev/name "create-trial-subscription" + :type "enterprise"})) (let [return-url (-> (rt/get-current-href) (rt/encode-url)) href (dm/str "payments/subscriptions/create?type=enterprise&returnUrl=" return-url)] (st/emit! (rt/nav-raw :href href))))) @@ -168,21 +215,9 @@ handle-close-dialog (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "close-subscription-modal"})) + (st/emit! (ev/event {::ev/name "close-subscription-modal"})) (modal/hide!))) - show-editors-list* - (mf/use-state false) - - show-editors-list - (deref show-editors-list*) - - handle-click - (mf/use-fn - (fn [event] - (dom/stop-propagation event) - (swap! show-editors-list* not))) - on-submit (mf/use-fn (mf/deps current-subscription unlimited-modal-step*) @@ -212,20 +247,7 @@ [:div {:class (stl/css :modal-content)} (when (and (seq editors) (not= unlimited-modal-step 2)) - [:* [:p {:class (stl/css :editors-text)} - (tr "subscription.settings.management.dialog.currently-editors-title" (c (count editors)))] - [:button {:class (stl/css :cta-button :show-editors-button) :on-click handle-click} - (tr "subscription.settings.management.dialog.editors") - [:> icon* {:icon-id (if show-editors-list i/arrow-up i/arrow-down) - :class (stl/css :icon-dropdown) - :size "s"}]] - (when show-editors-list - [:* - [:p {:class (stl/css :editors-text :editors-list-warning)} - (tr "subscription.settings.management.dialog.editors-explanation")] - [:ul {:class (stl/css :editors-list)} - (for [editor editors] - [:li {:key (dm/str (:id editor)) :class (stl/css :team-name)} "- " (:name editor)])]])]) + [:> editors-section* {:editors editors}]) (when (and (or (and (= subscription-type "professional") @@ -252,20 +274,20 @@ :class (stl/css :input-field)}]] [:div {:class (stl/css :editors-cost)} [:span {:class (stl/css :modal-text-medium)} - (when (> (get-in @form [:clean-data :min-members]) 25) + (when (> (dm/get-in @form [:clean-data :min-members]) 25) [:> i18n/tr-html* {:class (stl/css :modal-text-cap) :tag-name "span" :content (tr "subscription.settings.management.dialog.price-month" "175")}]) [:> i18n/tr-html* - {:class (stl/css-case :text-strikethrough (> (get-in @form [:clean-data :min-members]) 25)) + {:class (stl/css-case :text-strikethrough (> (dm/get-in @form [:clean-data :min-members]) 25)) :tag-name "span" :content (tr "subscription.settings.management.dialog.price-month" - (* 7 (or (get-in @form [:clean-data :min-members]) 0)))}]] + (* 7 (or (dm/get-in @form [:clean-data :min-members]) 0)))}]] [:span {:class (stl/css :modal-text-medium)} (tr "subscription.settings.management.dialog.payment-explanation")]]] - (when (get-in @form [:errors :min-members]) + (when (dm/get-in @form [:errors :min-members]) [:div {:class (stl/css :error-message)} (tr "subscription.settings.management.dialog.input-error")]) @@ -330,7 +352,7 @@ (let [profile (mf/deref refs/profile) handle-close-dialog (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "subscription-success"})) + (st/emit! (ev/event {::ev/name "subscription-success"})) (modal/hide!)))] [:div {:class (stl/css :modal-overlay)} @@ -344,14 +366,14 @@ [:div {:class (stl/css :modal-end)} [:div {:class (stl/css :modal-title)} - (tr "subscription.settings.sucess.dialog.title" subscription-name)] + (tr "subscription.settings.success.dialog.title" subscription-name)] (when (not= subscription-name "professional") [:p {:class (stl/css :modal-text-large)} (tr "subscription.settings.success.dialog.thanks" subscription-name)]) [:p {:class (stl/css :modal-text-large)} (tr "subscription.settings.success.dialog.description")] [:p {:class (stl/css :modal-text-large)} - (tr "subscription.settings.sucess.dialog.footer")] + (tr "subscription.settings.success.dialog.footer")] [:div {:class (stl/css :success-action-buttons)} [:input @@ -360,37 +382,6 @@ :value (tr "labels.close") :on-click handle-close-dialog}]]]]]])) -(mf/defc nitrate-success-dialog - {::mf/register modal/components - ::mf/register-as :nitrate-success} - [] - ;; TODO add translations for this texts when we have the definitive ones - (let [profile (mf/deref refs/profile)] - - [:div {:class (stl/css :modal-overlay)} - [:div {:class (stl/css :modal-dialog :subscription-success)} - [:button {:class (stl/css :close-btn) :on-click modal/hide!} - [:> icon* {:icon-id "close" - :size "m"}]] - [:div {:class (stl/css :modal-success-content)} - [:div {:class (stl/css :modal-start)} - [:> raw-svg* {:id (if (= "light" (:theme profile)) "logo-subscription-light" "logo-subscription")}]] - - [:div {:class (stl/css :modal-end)} - [:div {:class (stl/css :modal-title)} - "You are Business Nitrate!"] - [:p {:class (stl/css :modal-text-large)} - (tr "subscription.settings.success.dialog.description")] - [:p {:class (stl/css :modal-text-large)} - (tr "subscription.settings.sucess.dialog.footer")] - - [:div {:class (stl/css :success-action-buttons)} - [:input - {:class (stl/css :primary-button) - :type "button" - :value "CREATE ORGANIZATION" - :on-click dnt/go-to-nitrate-cc-create-org}]]]]]])) - (mf/defc subscription-page* [{:keys [profile]}] (let [route (mf/deref refs/route) @@ -410,6 +401,30 @@ (= params-subscription "subscribed-to-penpot-enterprise") (= params-subscription "subscribed-to-penpot-nitrate")) + nitrate-toast-message + (condp = params-subscription + dnt/nitrate-checkout-finish-error-token (tr "subscription.error.nitrate.checkout-finish-failed") + dnt/nitrate-checkout-cancelled-token (tr "subscription.error.nitrate.checkout-cancelled") + nil) + + nitrate-toast-level + (cond + (= params-subscription dnt/nitrate-checkout-cancelled-token) :info + (some? nitrate-toast-message) :error) + + show-nitrate-start-error? + (= params-subscription dnt/nitrate-checkout-error-token) + + nitrate-start-error* + (mf/use-state false) + + nitrate-start-error? + (deref nitrate-start-error*) + + nitrate-start-error-message + (when nitrate-start-error? + (tr "subscription.error.nitrate.checkout-failed")) + success-modal-is-trial? (-> route :params :query :trial) @@ -464,23 +479,63 @@ (modal/show :management-dialog {:subscription-type subscription-type :current-subscription current-subscription - :editors subscription-editors :subscribe-to-trial (not (:type subscription))})))))] + :editors subscription-editors :subscribe-to-trial (not (:type subscription))}))))) + + open-contact-sales-modal + (mf/use-fn + (mf/deps nitrate-license) + (fn [current-subscription subscription-type] + (if (= current-subscription "unlimited") + (st/emit! (dnt/show-nitrate-popup :nitrate-dialog {:nitrate-license nitrate-license :show-contact-sales-option true})) + (st/emit! (modal/show :nitrate-contact-sales-dialog {:subscription-type subscription-type}))))) + + open-cancel-contact-sales-modal + (mf/use-fn + (fn [] + (st/emit! (modal/show :nitrate-cancel-contact-sales-dialog {:email (:email profile)})))) + + connectivity* + (mf/use-state nil) + + connectivity + (deref connectivity*)] (mf/with-effect [] (dom/set-html-title (tr "subscription.labels"))) + (mf/with-effect [nitrate?] + (when nitrate? + (->> (dnt/fetch-connectivity) + (rx/subs! #(reset! connectivity* %))))) + (mf/with-effect [authenticated? show-subscription-success-modal? show-trial-subscription-modal? + show-nitrate-start-error? success-modal-is-trial? + nitrate-toast-message + nitrate-toast-level subscription] (when ^boolean authenticated? + (when ^boolean show-nitrate-start-error? + (reset! nitrate-start-error* true)) (cond + (some? nitrate-toast-message) + (st/emit! + (ntf/show {:content nitrate-toast-message + :type :toast + :level nitrate-toast-level + :timeout 7000}) + (rt/nav :settings-subscription {} {::rt/replace true})) + + ^boolean show-nitrate-start-error? + (st/emit! (rt/nav :settings-subscription {} {::rt/replace true})) + ^boolean show-trial-subscription-modal? (st/emit! - (ptk/event ::ev/event {::ev/name "open-subscription-modal" - ::ev/origin "settings:from-pricing-page"}) + (ev/event {::ev/name "open-subscription-modal" + ::ev/origin "settings:from-pricing-page"}) (modal/show :management-dialog {:subscription-type (if (= params-subscription "subscription-to-penpot-unlimited") "unlimited" @@ -493,7 +548,7 @@ ^boolean show-subscription-success-modal? (st/emit! (if (= params-subscription "subscribed-to-penpot-nitrate") - (modal/show :nitrate-success {}) + (modal/show :nitrate-activation-success {}) (modal/show :subscription-success {:subscription-name (if (= params-subscription "subscribed-to-penpot-unlimited") (if (= success-modal-is-trial? "true") @@ -516,21 +571,32 @@ [:> plan-card* {:card-title "Business Nitrate" :card-title-icon i/character-b :cancel-at (when (:cancel-at nitrate-license) - (dm/str "Active until " (ct/format-inst (:cancel-at nitrate-license) "d MMMM, yyyy"))) + (tr "nitrate.subscription.active-until" (ct/format-inst (:cancel-at nitrate-license) "d MMMM, yyyy"))) :benefits-title "Loren ipsum", :benefits ["Loren ipsum", "Loren ipsum", "Loren ipsum"] - :cta-text-with-icon "Control Center" - :cta-link-with-icon dnt/go-to-nitrate-cc - :cta-text (tr "subscription.settings.manage-your-subscription") - :cta-link dnt/go-to-nitrate-billing}] + :cta-text-with-icon (when (not (:manual nitrate-license)) "Admin Console") + :cta-link-with-icon (when (not (:manual nitrate-license)) dnt/go-to-nitrate-ac) + :cta-text (if (and (:licenses connectivity) (not (:manual nitrate-license))) + (tr "subscription.settings.manage-your-subscription") + (tr "nitrate.subscription.settings.manual-cancel")) + :cta-link (if (and (:licenses connectivity) (not (:manual nitrate-license))) + dnt/go-to-nitrate-billing + open-cancel-contact-sales-modal) + :code-action (when (:manual nitrate-license) :renovate)}] (case subscription-type "professional" [:> plan-card* {:card-title (tr "subscription.settings.professional") - :benefits [(tr "subscription.settings.professional.storage-benefit"), - (tr "subscription.settings.professional.autosave-benefit"), - (tr "subscription.settings.professional.teams-editors-benefit")]}] + :benefits [(if cf/saas? + (tr "subscription.settings.professional.storage-benefit") + (tr "subscription.settings.professional.selfhost.control-over-data")), + (if cf/saas? + (tr "subscription.settings.professional.autosave-benefit") + (tr "subscription.settings.professional.selfhost.unlimited-users")), + (if cf/saas? + (tr "subscription.settings.professional.teams-editors-benefit") + (tr "subscription.settings.professional.selfhost.community-support"))]}] "unlimited" (if subscription-is-trial? @@ -600,15 +666,25 @@ [:> plan-card* {:card-title (tr "subscription.settings.professional") :price-value "$0" :price-period (tr "subscription.settings.price-editor-month") - :benefits [(tr "subscription.settings.professional.storage-benefit"), - (tr "subscription.settings.professional.autosave-benefit"), - (tr "subscription.settings.professional.teams-editors-benefit")] + :benefits [(if cf/saas? + (tr "subscription.settings.professional.storage-benefit") + (tr "subscription.settings.professional.selfhost.control-over-data")), + (if cf/saas? + (tr "subscription.settings.professional.autosave-benefit") + (tr "subscription.settings.professional.selfhost.unlimited-users")), + (if cf/saas? + (tr "subscription.settings.professional.teams-editors-benefit") + (tr "subscription.settings.professional.selfhost.community-support"))] :cta-text (tr "subscription.settings.subscribe") - :cta-link #(open-subscription-modal "professional") + :cta-link (if (and (contains? cf/flags :nitrate) nitrate? (= subscription-type "nitrate")) + (if (and (:licenses connectivity) (not (:manual nitrate-license))) + dnt/go-to-nitrate-billing + open-cancel-contact-sales-modal) + go-to-payments) :cta-text-with-icon (tr "subscription.settings.more-information") :cta-link-with-icon go-to-pricing-page}]) - (when (not= subscription-type "unlimited") + (when (and (not= subscription-type "unlimited") cf/saas?) [:> plan-card* {:card-title (tr "subscription.settings.unlimited") :card-title-icon i/character-u :price-value "$7" @@ -618,13 +694,13 @@ (tr "subscription.settings.unlimited.autosave-benefit"), (tr "subscription.settings.unlimited.bill")] :cta-text (if (:type subscription) (tr "subscription.settings.subscribe") (tr "subscription.settings.try-it-free")) - :cta-link #(open-subscription-modal "unlimited" subscription) + :cta-link (if (and (contains? cf/flags :nitrate) nitrate?) #(open-contact-sales-modal subscription-type "Unlimited") #(open-subscription-modal "unlimited" subscription)) :cta-text-with-icon (tr "subscription.settings.more-information") :cta-link-with-icon go-to-pricing-page :recommended (= subscription-type "professional") :show-button-cta (= subscription-type "professional")}]) - (when (and (not= subscription-type "enterprise") (not (contains? cf/flags :nitrate))) + (when (and (not= subscription-type "enterprise") cf/saas? (not (contains? cf/flags :nitrate))) [:> plan-card* {:card-title (tr "subscription.settings.enterprise") :card-title-icon i/character-e :price-value "$950" @@ -644,16 +720,18 @@ [:> plan-card* {:card-title "Business Nitrate" :card-title-icon i/character-n :price-value "$25" - :price-period "org member" + :price-period (tr "subscription.settings.organization-member-month") :benefits-title (tr "subscription.settings.benefits.all-unlimited-benefits") :benefits ["Crea organizaciones y añade personas, que usarán Penpot con las reglas que configures." - "Acceso exclusivo al Control Center" + "Acceso exclusivo a la Admin Console" "Lorem ipsum"] :cta-text (if nitrate-license (tr "subscription.settings.subscribe") "Try 14 days for free") - :cta-link #(open-subscription-modal "nitrate" subscription) + :cta-link (if (= subscription-type "unlimited") #(open-contact-sales-modal subscription-type "Nitrate") #(open-subscription-modal "nitrate" subscription)) :cta-text-with-icon (tr "subscription.settings.more-information") :cta-link-with-icon go-to-pricing-page - :show-button-cta (not nitrate-license)}])]]])) + :code-action :activate + :show-button-cta (not nitrate-license) + :inline-error nitrate-start-error-message}])]]])) (def ^:private schema:nitrate-form @@ -663,7 +741,7 @@ (mf/defc subscribe-nitrate-dialog {::mf/register modal/components ::mf/register-as :nitrate-dialog} - [{:keys [nitrate-license] :as connectivity}] + [{:keys [nitrate-license show-contact-sales-option] :as connectivity}] ;; TODO add translations for this texts when we have the definitive ones (let [online? (:licenses connectivity) initial (mf/with-memo [] @@ -680,13 +758,8 @@ (mf/use-fn (mf/deps form) (fn [] - (let [subscription (-> @form :clean-data :subscription name) - return-url (dm/str - (rt/get-current-href) - "?" - (u/map->query-string - {:subscription "subscribed-to-penpot-nitrate"}))] - (dnt/go-to-buy-nitrate-license subscription return-url))))] + (let [subscription (-> @form :clean-data :subscription name)] + (dnt/go-to-buy-nitrate-license subscription (rt/get-current-href)))))] [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog)} @@ -696,7 +769,7 @@ [:div {:class (stl/css :modal-title :subscription-title)} "Subcribe to the Business Nitrate plan"] - (if online? + (if (and online? (not show-contact-sales-option)) [:div {:class (stl/css :modal-content)} @@ -742,5 +815,75 @@ [:a {:class (stl/css :cta-button) :href "mailto:sales@penpot.app"} "sales@penpot.app"]]])]])) +(mf/defc nitrate-contact-sales-dialog + {::mf/register modal/components + ::mf/register-as :nitrate-contact-sales-dialog} + [{:keys [subscription-type]}] + (let [handle-close-dialog + (mf/use-fn + (fn [] + (modal/hide!)))] + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-dialog)} + [:button {:class (stl/css :close-btn) :on-click handle-close-dialog} + [:> icon* {:icon-id "close" + :size "m"}]] + [:div {:class (stl/css :modal-title :subscription-title)} + (dm/str "Switch to " subscription-type " plan?")] + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-text-medium)} + "When you downgrade:"] + [:ul {:class (stl/css :downgrade-list)} + [:li {:class (stl/css :downgrade-item)} "Your organization will be deleted."] + [:li {:class (stl/css :downgrade-item)} "The teams, projects and files will no longer be part of any organization but they will remain available."] + [:li {:class (stl/css :downgrade-item)} "Your total storage, auto-version history, and file recovery period will be limited."]] + [:div {:class (stl/css :downgrade-warning)} + "To switch to this plan, please contact our sales team. +We’ll help you update your subscription and ensure everything is set up correctly."] + [:div {:class (stl/css :action-buttons)} + [:> button* {:variant "secondary" + :type "button" + :on-click handle-close-dialog} (tr "ds.confirm-cancel")] + [:> button* {:variant "primary" + :type "button" + :on-click #(dom/open-new-window "mailto:sales@penpot.app?subject=Switch%20to%20the%20Unlimited%20plan")} "Contact sales"]]]]])) + +(mf/defc nitrate-cancel-contact-sales-dialog + {::mf/register modal/components + ::mf/register-as :nitrate-cancel-contact-sales-dialog} + [{:keys [email]}] + (let [encoded-email + (js/encodeURIComponent email) + + mailto-url + (dm/str "mailto:sales@penpot.net" + "?subject=Request%20to%20Cancel%20Nitrate%20Subscription" + "&body=Hello%2C%0A%0A" + "I%20would%20like%20to%20cancel%20my%20Enterprise%20subscription.%0A" + "Account%20email%3A%20" encoded-email ".%0A%0AThank%20you.") + + handle-close-dialog + (mf/use-fn + (fn [] + (modal/hide!)))] + + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-dialog)} + [:button {:class (stl/css :close-btn) :on-click handle-close-dialog} + [:> icon* {:icon-id "close" + :size "m"}]] + [:div {:class (stl/css :modal-title :subscription-title)} + "Cancel subscription"] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-text-medium)} + "To cancel your Nitrate subscription, please contact us at:"] + [:a {:class (stl/css :cta-link) :href "mailto:sales@penpot.net"} + "sales@penpot.net"] + [:div {:class (stl/css :action-buttons)} + [:> button* {:class (stl/css :button-full-width) + :variant "primary" + :type "button" + :on-click #(dom/open-new-window mailto-url)} "Contact us"]]]]])) diff --git a/frontend/src/app/main/ui/settings/subscription.scss b/frontend/src/app/main/ui/settings/subscription.scss index 98ad84d385..436a512042 100644 --- a/frontend/src/app/main/ui/settings/subscription.scss +++ b/frontend/src/app/main/ui/settings/subscription.scss @@ -38,10 +38,6 @@ margin-block-start: var(--sp-s); } -.membership.first { - margin-block-start: var(--sp-l); -} - .membership-date { @include t.use-typography("body-small"); @@ -108,12 +104,12 @@ border-radius: 6px; border: 1.75px solid var(--color-foreground-primary); stroke-width: 2.25px; - padding: deprecated.$s-1; + padding: px2rem(3); +} - svg { - block-size: var(--sp-m); - inline-size: var(--sp-m); - } +.plan-title-icon svg { + block-size: var(--sp-m); + inline-size: var(--sp-m); } .plan-card-title, @@ -198,6 +194,13 @@ margin-block-start: var(--sp-xl); } +.inline-error { + @include t.use-typography("body-small"); + + color: var(--color-foreground-error); + margin-block: var(--sp-s) 0; +} + .modal-overlay { @extend %modal-overlay-base; } @@ -292,16 +295,16 @@ justify-content: center; max-inline-size: $sz-224; - svg { - inline-size: 100%; - block-size: auto; - } - @media (width <= 992px) { display: none; } } +.modal-start svg { + inline-size: 100%; + block-size: auto; +} + .editors-text { @include t.use-typography("body-medium"); @@ -363,21 +366,60 @@ } .radio-btns { - label { - @include t.use-typography("body-large"); - - padding: 0; - display: flex; - align-items: center; - color: var(--color-foreground-secondary); - } - display: flex; flex-direction: column; - padding: 0 0 var(--sp-xl) 0; + padding-block-end: var(--sp-xl); gap: var(--sp-s); } +.radio-btns label { + @include t.use-typography("body-large"); + + padding: 0; + display: flex; + align-items: center; + color: var(--color-foreground-secondary); +} + .modal-contact-content { gap: var(--sp-xl); } + +.downgrade-warning { + @include t.use-typography("body-medium"); + + background-color: var(--color-background-tertiary); + border-radius: var(--sp-s); + padding-block: var(--sp-s); + padding-inline: var(--sp-m); + margin-block: var(--sp-m) var(--sp-xxxl); +} + +.downgrade-list { + list-style-position: outside; + list-style-type: disc; + margin-block: var(--sp-l) 0; + padding-inline-start: var(--sp-l); +} + +.downgrade-item { + @include t.use-typography("body-medium"); + + margin-block-end: var(--sp-l); +} + +.cta-link { + @include t.use-typography("body-medium"); + + align-items: center; + color: var(--color-accent-primary); + display: flex; + margin-block-end: var(--sp-xxxl); + text-align: left; + text-decoration: underline; +} + +.button-full-width { + justify-content: center; + inline-size: 100%; +} diff --git a/frontend/src/app/main/ui/shapes/attrs.cljs b/frontend/src/app/main/ui/shapes/attrs.cljs index 4fcc386e06..61cd5da71e 100644 --- a/frontend/src/app/main/ui/shapes/attrs.cljs +++ b/frontend/src/app/main/ui/shapes/attrs.cljs @@ -19,14 +19,14 @@ [cuerdas.core :as str])) (defn- calculate-dasharray - [style width] + [style width dash gap] (let [w+5 (+ 5 width) w+1 (+ 1 width) w+10 (+ 10 width)] (case style :mixed (str/concat "" w+5 "," w+5 "," w+1 "," w+5) :dotted (str/concat "0," w+5) - :dashed (str/concat "" w+10 "," w+10) + :dashed (str/concat "" (or dash w+10) "," (or gap w+10)) ""))) (defn get-border-props @@ -97,6 +97,8 @@ (let [style (:stroke-style data :solid)] (when-not (= style :none) (let [width (:stroke-width data 1) + dash (:stroke-dash data) + gap (:stroke-gap data) gradient (:stroke-color-gradient data) color (:stroke-color data) opacity (:stroke-opacity data)] @@ -114,7 +116,7 @@ (obj/set! attrs "strokeOpacity" opacity)) (when (not= style :svg) - (obj/set! attrs "strokeDasharray" (calculate-dasharray style width))) + (obj/set! attrs "strokeDasharray" (calculate-dasharray style width dash gap))) ;; For simple line caps we use svg stroke-line-cap attribute. This ;; only works if all caps are the same and we are not using the tricks diff --git a/frontend/src/app/main/ui/shapes/export.cljs b/frontend/src/app/main/ui/shapes/export.cljs index 1ff0f8e6bf..6d13619b93 100644 --- a/frontend/src/app/main/ui/shapes/export.cljs +++ b/frontend/src/app/main/ui/shapes/export.cljs @@ -333,6 +333,8 @@ :penpot:stroke-opacity (d/name (:stroke-opacity stroke)) :penpot:stroke-style (d/name (:stroke-style stroke)) :penpot:stroke-width (d/name (:stroke-width stroke)) + :penpot:stroke-dash ((d/nilf str) (:stroke-dash stroke)) + :penpot:stroke-gap ((d/nilf str) (:stroke-gap stroke)) :penpot:stroke-alignment (d/name (:stroke-alignment stroke)) :penpot:stroke-cap-start (d/name (:stroke-cap-start stroke)) :penpot:stroke-cap-end (d/name (:stroke-cap-end stroke))}]))])))) diff --git a/frontend/src/app/main/ui/shapes/filters.cljs b/frontend/src/app/main/ui/shapes/filters.cljs index 6e0adbebea..749b926055 100644 --- a/frontend/src/app/main/ui/shapes/filters.cljs +++ b/frontend/src/app/main/ui/shapes/filters.cljs @@ -137,7 +137,7 @@ (if (or (mth/close? 0.01 (:width selrect)) (mth/close? 0.01 (:height selrect))) - ;; We cannot use "objectBoundingbox" if the shape doesn't have width/heigth + ;; We cannot use "objectBoundingbox" if the shape doesn't have width/height ;; From the SVG spec (https://www.w3.org/TR/SVG11/coords.html#ObjectBoundingBox ;; Keyword objectBoundingBox should not be used when the geometry of the applicable element ;; has no width or no height, such as the case of a horizontal or vertical line, even when diff --git a/frontend/src/app/main/ui/shapes/shape.cljs b/frontend/src/app/main/ui/shapes/shape.cljs index ba69c5753e..d8df772a89 100644 --- a/frontend/src/app/main/ui/shapes/shape.cljs +++ b/frontend/src/app/main/ui/shapes/shape.cljs @@ -125,7 +125,7 @@ [:& ed/export-data {:shape shape}]) [:defs - [:& defs/svg-defs {:shape shape :render-id render-id}] + [:> defs/svg-defs* {:shape shape :render-id render-id}] ;; The filters for frames should be setup inside the container. (when-not (cfh/frame-shape? shape) diff --git a/frontend/src/app/main/ui/shapes/svg_defs.cljs b/frontend/src/app/main/ui/shapes/svg_defs.cljs index 39328b752c..e4404b8311 100644 --- a/frontend/src/app/main/ui/shapes/svg_defs.cljs +++ b/frontend/src/app/main/ui/shapes/svg_defs.cljs @@ -24,8 +24,7 @@ (str transform-matrix " " val) (str transform-matrix))))) -(mf/defc svg-node - {::mf/wrap-props false} +(mf/defc svg-node* [{:keys [type node prefix-id transform bounds]}] (cond (string? node) node @@ -91,12 +90,12 @@ [:> (name tag) props [:> wrapper wrapper-props (for [[index node] (d/enumerate content)] - [:& svg-node {:key (dm/str "node-" index) - :type type - :node node - :prefix-id prefix-id - :transform transform - :bounds bounds}])]]))) + [:> svg-node* {:key (dm/str "node-" index) + :type type + :node node + :prefix-id prefix-id + :transform transform + :bounds bounds}])]]))) (defn- get-svg-def-bounds [{:keys [tag attrs] :as node} shape transform] @@ -108,8 +107,7 @@ (gsh/transform-rect transform)) (gsb/get-shape-filter-bounds shape))) -(mf/defc svg-defs - {::mf/wrap-props false} +(mf/defc svg-defs* [{:keys [shape render-id]}] (let [defs (:svg-defs shape) @@ -131,9 +129,9 @@ (contains? defs id) (str render-id "-"))))] (for [[key node] defs] - [:& svg-node {:key (dm/str key) - :type (:type shape) - :node node - :prefix-id prefix-id - :transform transform - :bounds (get-svg-def-bounds node shape transform)}]))) + [:> svg-node* {:key (dm/str key) + :type (:type shape) + :node node + :prefix-id prefix-id + :transform transform + :bounds (get-svg-def-bounds node shape transform)}]))) diff --git a/frontend/src/app/main/ui/shapes/text.cljs b/frontend/src/app/main/ui/shapes/text.cljs index 3610d52fe0..24cdf2ed08 100644 --- a/frontend/src/app/main/ui/shapes/text.cljs +++ b/frontend/src/app/main/ui/shapes/text.cljs @@ -43,4 +43,4 @@ ;; will give a tainted canvas error because the `foreignObject` cannot be ;; rendered. (and (nil? position-data) (or is-component? is-render?)) - [:> fo/text-shape props]))) + [:> fo/text-shape* props]))) diff --git a/frontend/src/app/main/ui/shapes/text/fo_text.cljs b/frontend/src/app/main/ui/shapes/text/fo_text.cljs index aed6e6e0b9..f5f5dcb963 100644 --- a/frontend/src/app/main/ui/shapes/text/fo_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/fo_text.cljs @@ -11,73 +11,54 @@ [app.common.geom.shapes :as gsh] [app.common.types.color :as cc] [app.main.ui.shapes.text.styles :as sts] - [app.util.object :as obj] [cuerdas.core :as str] [rumext.v2 :as mf])) -(mf/defc render-text - {::mf/wrap-props false} - [props] - (let [node (obj/get props "node") - parent (obj/get props "parent") - shape (obj/get props "shape") - text (:text node) - style (if (= text "") - (sts/generate-text-styles shape parent) - (sts/generate-text-styles shape node))] +(mf/defc render-text* + [{:keys [node parent shape]}] + (let [text (:text node) + style (if (= text "") + (sts/generate-text-styles shape parent) + (sts/generate-text-styles shape node))] [:span.text-node {:style style} (if (= text "") "\u00A0" text)])) -(mf/defc render-root - {::mf/wrap-props false} - [props] - (let [node (obj/get props "node") - children (obj/get props "children") - shape (obj/get props "shape") - style (sts/generate-root-styles shape node)] +(mf/defc render-root* + [{:keys [node children shape]}] + (let [style (sts/generate-root-styles shape node)] [:div.root.rich-text {:style style :xmlns "http://www.w3.org/1999/xhtml"} children])) -(mf/defc render-paragraph-set - {::mf/wrap-props false} - [props] - (let [children (obj/get props "children") - shape (obj/get props "shape") - style (sts/generate-paragraph-set-styles shape)] +(mf/defc render-paragraph-set* + [{:keys [children shape]}] + (let [style (sts/generate-paragraph-set-styles shape)] [:div.paragraph-set {:style style} children])) -(mf/defc render-paragraph - {::mf/wrap-props false} - [props] - (let [node (obj/get props "node") - shape (obj/get props "shape") - children (obj/get props "children") - style (sts/generate-paragraph-styles shape node) - dir (:text-direction node "auto")] +(mf/defc render-paragraph* + [{:keys [node children shape]}] + (let [style (sts/generate-paragraph-styles shape node) + dir (:text-direction node "auto")] [:p.paragraph {:style style :dir dir} children])) ;; -- Text nodes -(mf/defc render-node - {::mf/wrap-props false} - [props] - (let [{:keys [type text children]} (obj/get props "node")] +(mf/defc render-node* + {::mf/props :obj} + [{:keys [node] :as props}] + (let [{:keys [type text children]} node] (if (string? text) - [:> render-text props] + [:> render-text* props] (let [component (case type - "root" render-root - "paragraph-set" render-paragraph-set - "paragraph" render-paragraph + "root" render-root* + "paragraph-set" render-paragraph-set* + "paragraph" render-paragraph* nil)] (when component [:> component props - (for [[index node] (d/enumerate children)] - (let [props (-> (obj/clone props) - (obj/set! "node" node) - (obj/set! "index" index) - (obj/set! "key" index))] - [:> render-node props]))]))))) + (for [[index child-node] (d/enumerate children)] + [:> render-node* + (mf/spread-props props {:node child-node :index index :key index})])]))))) (defn- next-color "Given a set of colors try to get a color not yet used" @@ -168,9 +149,8 @@ [colors color-mapping color-mapping-inverse])) -(mf/defc text-shape - {::mf/props :obj - ::mf/forward-ref true} +(mf/defc text-shape* + {::mf/forward-ref true} [{:keys [shape grow-type]} ref] (let [transform (gsh/transform-str shape) id (dm/get-prop shape :id) @@ -196,6 +176,6 @@ ;; `background-clip` [:style ".text-node { background-clip: text; -webkit-background-clip: text; }"] - [:& render-node {:index 0 - :shape shape - :node content}]])) + [:> render-node* {:index 0 + :shape shape + :node content}]])) diff --git a/frontend/src/app/main/ui/shapes/text/fontfaces.cljs b/frontend/src/app/main/ui/shapes/text/fontfaces.cljs index ceca03f536..779dbe617d 100644 --- a/frontend/src/app/main/ui/shapes/text/fontfaces.cljs +++ b/frontend/src/app/main/ui/shapes/text/fontfaces.cljs @@ -9,7 +9,6 @@ [app.common.data :as d] [app.common.files.helpers :as cfh] [app.main.fonts :as fonts] - [app.util.object :as obj] [beicon.v2.core :as rx] [clojure.set :as set] [cuerdas.core :as str] @@ -38,24 +37,17 @@ (mf/ref-val fonts-css-ref))) -(mf/defc fontfaces-style-html - {::mf/wrap-props false - ::mf/wrap [#(mf/memo' % (mf/check-props ["fonts"]))]} - [props] - - (let [fonts (obj/get props "fonts") - - ;; Fetch its CSS fontfaces +(mf/defc fontfaces-style-html* + {::mf/wrap [mf/memo]} + [{:keys [fonts]}] + (let [;; Fetch its CSS fontfaces fonts-css (use-fonts-css fonts)] - [:style fonts-css])) -(mf/defc fontfaces-style-render - {::mf/wrap-props false - ::mf/wrap [#(mf/memo' % (mf/check-props ["fonts"]))]} - [props] - (let [fonts (obj/get props "fonts") - ;; Fetch its CSS fontfaces +(mf/defc fontfaces-style-render* + {::mf/wrap [mf/memo]} + [{:keys [fonts]}] + (let [;; Fetch its CSS fontfaces fonts-css (use-fonts-css fonts)] [:style fonts-css])) @@ -76,11 +68,8 @@ (map (comp fonts/get-content-fonts :content)) (reduce set/union #{}))) -(mf/defc fontfaces-style - {::mf/wrap-props false - ::mf/wrap [#(mf/memo' % (mf/check-props ["fonts"]))]} - [props] - (let [;; Retrieve the fonts ids used by the text shapes - fonts (obj/get props "fonts")] - (when (d/not-empty? fonts) - [:> fontfaces-style-render {:fonts fonts}]))) +(mf/defc fontfaces-style* + {::mf/wrap [mf/memo]} + [{:keys [fonts]}] + (when (d/not-empty? fonts) + [:> fontfaces-style-render* {:fonts fonts}])) diff --git a/frontend/src/app/main/ui/shapes/text/html_text.cljs b/frontend/src/app/main/ui/shapes/text/html_text.cljs index ff86df2e8e..a2fa9b678a 100644 --- a/frontend/src/app/main/ui/shapes/text/html_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/html_text.cljs @@ -10,99 +10,73 @@ [app.common.data.macros :as dm] [app.common.text :as legacy.txt] [app.main.ui.shapes.text.styles :as sts] - [app.util.object :as obj] [rumext.v2 :as mf])) -(mf/defc render-text - {::mf/wrap-props false} - [props] - (let [node (obj/get props "node") - parent (obj/get props "parent") - shape (obj/get props "shape") - code? (obj/get props "code?") - text (:text node) - style (if (= text "") - (sts/generate-text-styles shape parent) - (sts/generate-text-styles shape node)) - class (when code? (:$id node))] +(mf/defc render-text* + [{:keys [node parent shape is-code]}] + (let [text (:text node) + style (if (= text "") + (sts/generate-text-styles shape parent) + (sts/generate-text-styles shape node)) + class (when is-code (:$id node))] [:span.text-node {:style style :class class} (if (= text "") "\u00A0" text)])) -(mf/defc render-root - {::mf/wrap-props false} - [props] - (let [node (obj/get props "node") - children (obj/get props "children") - shape (obj/get props "shape") - code? (obj/get props "code?") - style (sts/generate-root-styles shape node code?) - class (when code? (:$id node))] +(mf/defc render-root* + [{:keys [node children shape is-code]}] + (let [style (sts/generate-root-styles shape node is-code) + class (when is-code (:$id node))] [:div.root.rich-text {:style style :class class :xmlns "http://www.w3.org/1999/xhtml"} children])) -(mf/defc render-paragraph-set - {::mf/wrap-props false} - [props] - (let [node (obj/get props "node") - children (obj/get props "children") - shape (obj/get props "shape") - code? (obj/get props "code?") - style (when-not code? (sts/generate-paragraph-set-styles shape)) - class (when code? (:$id node))] +(mf/defc render-paragraph-set* + [{:keys [node children shape is-code]}] + (let [style (when-not is-code (sts/generate-paragraph-set-styles shape)) + class (when is-code (:$id node))] [:div.paragraph-set {:style style :class class} children])) -(mf/defc render-paragraph - {::mf/wrap-props false} - [props] - (let [node (obj/get props "node") - shape (obj/get props "shape") - children (obj/get props "children") - code? (obj/get props "code?") - style (when-not code? (sts/generate-paragraph-styles shape node)) - class (when code? (:$id node)) - dir (:text-direction node "auto")] +(mf/defc render-paragraph* + [{:keys [node children shape is-code]}] + (let [style (when-not is-code (sts/generate-paragraph-styles shape node)) + class (when is-code (:$id node)) + dir (:text-direction node "auto")] [:p.paragraph {:style style :dir dir :class class} children])) ;; -- Text nodes -(mf/defc render-node - {::mf/wrap-props false} - [props] - (let [{:keys [type text children] :as parent} (obj/get props "node") - code? (obj/get props "code?")] +(mf/defc render-node* + {::mf/props :obj} + [{:keys [node is-code] :as props}] + (let [{:keys [type text children] :as parent} node] (if (string? text) - [:> render-text props] + [:> render-text* props] (let [component (case type - "root" render-root - "paragraph-set" render-paragraph-set - "paragraph" render-paragraph + "root" render-root* + "paragraph-set" render-paragraph-set* + "paragraph" render-paragraph* nil)] (when component [:> component props - (for [[index node] (d/enumerate children)] - (let [props (-> (obj/clone props) - (obj/set! "node" node) - (obj/set! "parent" parent) - (obj/set! "index" index) - (obj/set! "key" index) - (obj/set! "code?" code?))] - [:> render-node props]))]))))) + (for [[index child-node] (d/enumerate children)] + [:> render-node* + (mf/spread-props props + {:node child-node + :parent parent + :index index + :key index + :is-code is-code})])]))))) -(mf/defc text-shape - {::mf/wrap-props false - ::mf/forward-ref true} - [props ref] - (let [shape (obj/get props "shape") - grow-type (obj/get props "grow-type") - code? (obj/get props "code?") - {:keys [id x y width height content]} shape +(mf/defc text-shape* + {::mf/forward-ref true} + [{:keys [shape grow-type is-code]} ref] + (let [{:keys [id x y width height content]} shape - content (if code? (legacy.txt/index-content content) content) + content (if is-code (legacy.txt/index-content content) content) style - (when-not code? + (when-not is-code #js {:position "fixed" :left 0 :top 0 @@ -118,10 +92,10 @@ :style style} ;; We use a class here because react has a bug that won't use the appropriate selector for ;; `background-clip` - (when (not code?) + (when (not is-code) [:style ".text-node { background-clip: text; -webkit-background-clip: text; }"]) - [:& render-node {:index 0 - :shape shape - :node content - :code? code?}]])) + [:> render-node* {:index 0 + :shape shape + :node content + :is-code is-code}]])) diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs index b5337616f6..e6f8d6b605 100644 --- a/frontend/src/app/main/ui/static.cljs +++ b/frontend/src/app/main/ui/static.cljs @@ -27,6 +27,7 @@ [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]] + [app.main.ui.ds.product.loader :refer [loader*]] [app.main.ui.icons :as deprecated-icon] [app.main.ui.viewer.header :as viewer.header] [app.util.dom :as dom] @@ -41,7 +42,6 @@ (def TimeoutError rxjs/TimeoutError) (mf/defc error-container* - {::mf/props :obj} [{:keys [children]}] (let [profile-id (:profile-id @st/state) on-nav-root (mf/use-fn #(st/emit! (rt/nav-root)))] @@ -68,10 +68,26 @@ [:span (tr "not-found.made-with-love")]]])) (mf/defc invalid-token - [] + [{:keys [reason]}] + ;; Map the specific failure reason to actionable copy. Falls back to + ;; the generic invitation-invalid message when the reason is missing + ;; or unknown so the UX never regresses for unhandled cases. + ;; + ;; The branches use `tr` with literal keys (instead of `(tr key-var)`) + ;; so the i18n usage scanner can statically track every key. [:> error-container* {} - [:div {:class (stl/css :main-message)} (tr "errors.invite-invalid")] - [:div {:class (stl/css :desc-message)} (tr "errors.invite-invalid.info")]]) + (case reason + :email-mismatch + [:* + [:div {:class (stl/css :main-message)} (tr "errors.invite-email-mismatch")]] + + :token-expired + [:* + [:div {:class (stl/css :main-message)} (tr "errors.invite-expired")]] + + [:* + [:div {:class (stl/css :main-message)} (tr "errors.invite-invalid")] + [:div {:class (stl/css :desc-message)} (tr "errors.invite-invalid.info")]])]) (mf/defc login-modal* {::mf/private true} @@ -186,7 +202,6 @@ [:& recovery-sent-page {:email @user-email}]])]]])) (mf/defc request-dialog* - {::mf/props :obj} [{:keys [title content button-text on-button-click cancel-text on-close]}] (let [on-click (or on-button-click on-close)] [:div {:class (stl/css :overlay)} @@ -214,7 +229,8 @@ (mf/deps profile) (fn [] (let [team-id (:default-team-id profile)] - (st/emit! (dcm/go-to-dashboard-recent :team-id team-id))))) + (st/emit! (rt/assign-exception nil) + (dcm/go-to-dashboard-recent :team-id team-id))))) on-success (mf/use-fn @@ -306,6 +322,16 @@ [:div {:class (stl/css :sign-info)} [:button {:on-click on-click} (tr "labels.retry")]]])) +(mf/defc nitrate-unavailable* + [] + [:section {:class (stl/css :nitrate-unavailable-layout)} + [:div {:class (stl/css :nitrate-unavailable-content)} + [:> raw-svg* {:id "logo-nitrate-unavailable" :class (stl/css :nitrate-unavailable-logo)}] + [:p {:class (stl/css :nitrate-unavailable-message)} + (tr "labels.nitrate-unavailable.main-message")]] + [:p {:class (stl/css :nitrate-unavailable-footer)} + (tr "labels.copyright-period")]]) + (mf/defc webgl-context-lost* [] (let [on-reload (mf/use-fn #(js/location.reload))] @@ -477,12 +503,8 @@ :service-unavailable [:> service-unavailable*] - :wasm-error - (case (get data :code) - :webgl-context-lost - [:> webgl-context-lost*] - - [:> internal-error* props]) + :nitrate-unavailable + [:> nitrate-unavailable*] [:> internal-error* props]))) @@ -532,7 +554,6 @@ children]) (mf/defc exception-page* - {::mf/props :obj} [{:keys [data route] :as props}] (let [type (:type data) @@ -552,8 +573,10 @@ auth-error? (= type :authentication) not-found? (= type :not-found) - authenticated? - (is-authenticated? profile) + authenticated? (is-authenticated? profile) + + ;; Keeps whether the user was authenticated when this component first mounted. + initial-authenticated? (mf/with-memo [] authenticated?) request-access? (and @@ -569,13 +592,23 @@ (if (or auth-error? not-found?) - (if (not authenticated?) + (cond + (not authenticated?) [:> context-wrapper* {:is-workspace workspace? :is-dashboard dashboard? :is-viewer view? :profile profile} [:> login-modal* {}]] + + ;; The user was not authenticated when exception-page first + ;; mounted, but they have just logged in via the login modal. + ;; Show a loading indicator to prevent briefly flashing the + ;; "no permission" dialog. + (not initial-authenticated?) + [:> loader* {:title (tr "labels.loading") :overlay true}] + + :else (when (get info :loaded false) (if request-access? [:> context-wrapper* {:is-workspace workspace? diff --git a/frontend/src/app/main/ui/static.scss b/frontend/src/app/main/ui/static.scss index 32c80dce5e..8a989ee6cd 100644 --- a/frontend/src/app/main/ui/static.scss +++ b/frontend/src/app/main/ui/static.scss @@ -13,6 +13,51 @@ background-color: var(--color-background-secondary); } +.nitrate-unavailable-layout { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + background-color: var(--color-background-primary); + padding: var(--sp-xxxl); +} + +.nitrate-unavailable-content { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--sp-xxxl); +} + +.nitrate-unavailable-logo { + inline-size: 146px; + block-size: 64px; + color: var(--color-foreground-primary); + transform: scale(1, -1); +} + +.nitrate-unavailable-message { + @include t.use-typography("title-large"); + + margin: 0; + max-inline-size: 32rem; + text-align: center; + color: var(--color-foreground-primary); + font-weight: 500; +} + +.nitrate-unavailable-footer { + @include t.use-typography("title-medium"); + + margin: 0; + text-align: center; + color: var(--color-foreground-secondary); +} + .deco-before, .deco-after { position: absolute; diff --git a/frontend/src/app/main/ui/viewer.cljs b/frontend/src/app/main/ui/viewer.cljs index 8ef3e26056..47b8a7fc7e 100644 --- a/frontend/src/app/main/ui/viewer.cljs +++ b/frontend/src/app/main/ui/viewer.cljs @@ -32,7 +32,7 @@ [app.main.ui.viewer.interactions :as interactions] [app.main.ui.viewer.login] [app.main.ui.viewer.share-link] - [app.main.ui.viewer.thumbnails :refer [thumbnails-panel]] + [app.main.ui.viewer.thumbnails :refer [thumbnails-panel*]] [app.util.dom :as dom] [app.util.dom.normalize-wheel :as nw] [app.util.globals :as globals] @@ -278,7 +278,6 @@ :zoom zoom}])]]) (mf/defc viewer-content* - {::mf/props :obj} [{:keys [data page-id share-id section index interactions-mode share]}] (let [{:keys [file users project permissions]} data allowed (or @@ -556,11 +555,11 @@ :class (stl/css-case :thumbnails-close true :invisible (not (:show-thumbnails local false)))}] - [:& thumbnails-panel {:frames frames - :show? (:show-thumbnails local false) - :page page - :index index - :thumbnail-data (:thumbnails file)}] + [:> thumbnails-panel* {:frames frames + :show (:show-thumbnails local false) + :page page + :index index + :thumbnail-data (:thumbnails file)}] [:section#viewer-section {:ref viewer-section-ref :data-viewer-section true diff --git a/frontend/src/app/main/ui/viewer/comments.cljs b/frontend/src/app/main/ui/viewer/comments.cljs index 9646c81f8c..ffc3992366 100644 --- a/frontend/src/app/main/ui/viewer/comments.cljs +++ b/frontend/src/app/main/ui/viewer/comments.cljs @@ -235,7 +235,6 @@ :zoom zoom}])]]])) (mf/defc comments-sidebar* - {::mf/props :obj} [{:keys [profiles frame page]}] (let [profile (mf/deref refs/profile) local (mf/deref refs/comments-local) diff --git a/frontend/src/app/main/ui/viewer/interactions.cljs b/frontend/src/app/main/ui/viewer/interactions.cljs index ae4bec1f1e..be73931031 100644 --- a/frontend/src/app/main/ui/viewer/interactions.cljs +++ b/frontend/src/app/main/ui/viewer/interactions.cljs @@ -219,8 +219,7 @@ :fixed? fixed?}])) (mf/defc flows-menu* - {::mf/wrap [mf/memo] - ::mf/props :obj} + {::mf/wrap [mf/memo]} [{:keys [page index]}] (let [flows (not-empty (:flows page)) frames (:frames page) @@ -268,7 +267,6 @@ [:span {:class (stl/css :icon)} deprecated-icon/tick])])]]]))) (mf/defc interactions-menu* - {::mf/props :obj} [{:keys [interactions-mode]}] (let [show-dropdown? (mf/use-state false) toggle-dropdown (mf/use-fn #(swap! show-dropdown? not)) diff --git a/frontend/src/app/main/ui/viewer/share_link.cljs b/frontend/src/app/main/ui/viewer/share_link.cljs index 646405676c..952a990d12 100644 --- a/frontend/src/app/main/ui/viewer/share_link.cljs +++ b/frontend/src/app/main/ui/viewer/share_link.cljs @@ -24,7 +24,6 @@ [app.util.clipboard :as clipboard] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (log/set-level! :warn) @@ -126,10 +125,10 @@ (let [params (prepare-params options) params (assoc params :file-id (:id file))] (st/emit! (dc/create-share-link params) - (ptk/event ::ev/event {::ev/name "create-share-link" - ::ev/origin "viewer" - :can-comment (:who-comment params) - :can-inspect-code (:who-inspect params)})))) + (ev/event {::ev/name "create-share-link" + ::ev/origin "viewer" + :can-comment (:who-comment params) + :can-inspect-code (:who-inspect params)})))) copy-link (fn [_] @@ -138,8 +137,8 @@ :type :toast :content (tr "common.share-link.link-copied-success") :timeout 1000}) - (ptk/event ::ev/event {::ev/name "copy-share-link" - ::ev/origin "viewer"}))) + (ev/event {::ev/name "copy-share-link" + ::ev/origin "viewer"}))) try-delete-link (fn [_] diff --git a/frontend/src/app/main/ui/viewer/thumbnails.cljs b/frontend/src/app/main/ui/viewer/thumbnails.cljs index 62abb6b607..a121ff1418 100644 --- a/frontend/src/app/main/ui/viewer/thumbnails.cljs +++ b/frontend/src/app/main/ui/viewer/thumbnails.cljs @@ -21,8 +21,8 @@ [app.util.timers :as ts] [rumext.v2 :as mf])) -(mf/defc thumbnails-content - [{:keys [children expanded? total] :as props}] +(mf/defc thumbnails-content* + [{:keys [children is-expanded total]}] (let [container (mf/use-ref) width (mf/use-var (.. js/document -documentElement -clientWidth)) element-width (mf/use-var 152) @@ -58,7 +58,7 @@ (reset! width (obj/get dom "clientWidth"))))] (mf/use-effect on-mount) - (if expanded? + (if is-expanded [:div {:class (stl/css :thumbnails-content)} [:div {:class (stl/css :thumbnails-list-expanded)} children]] @@ -75,8 +75,8 @@ :style {:right (str (* @offset 152) "px")}} children]]]))) -(mf/defc thumbnails-summary - [{:keys [on-toggle-expand on-close total] :as props}] +(mf/defc thumbnails-summary* + [{:keys [on-toggle-expand on-close total]}] [:div {:class (stl/css :thumbnails-summary)} [:span {:class (stl/css :counter)} (tr "labels.num-of-frames" (i18n/c total))] @@ -86,10 +86,10 @@ [:button {:class (stl/css :close-btn) :on-click on-close} deprecated-icon/close]]]) -(mf/defc thumbnail-item +(mf/defc thumbnail-item* {::mf/wrap [mf/memo #(mf/deferred % ts/idle-then-raf)]} - [{:keys [selected? frame on-click index objects page-id thumbnail-data]}] + [{:keys [is-selected frame on-click index objects page-id thumbnail-data]}] (let [children-ids (cfh/get-children-ids objects (:id frame)) children-bounds (gsh/shapes->rect (concat [frame] (->> children-ids (keep (d/getf objects)))))] @@ -97,7 +97,7 @@ [:button {:class (stl/css :thumbnail-item) :on-click #(on-click % index)} [:div {:class (stl/css-case :thumbnail-preview true - :selected selected?)} + :selected is-selected)} [:& render/frame-svg {:frame (-> frame (assoc :thumbnail (get thumbnail-data (dm/str page-id (:id frame)))) (assoc :children-bounds children-bounds)) @@ -107,11 +107,11 @@ :title (:name frame)} (:name frame)]])) -(mf/defc thumbnails-panel - [{:keys [frames page index show? thumbnail-data] :as props}] +(mf/defc thumbnails-panel* + [{:keys [frames page index show thumbnail-data]}] (let [expanded-state (mf/use-state false) - expanded? (deref expanded-state) - container (mf/use-ref) + expanded? (deref expanded-state) + container (mf/use-ref) objects (:objects page) on-close #(st/emit! dv/toggle-thumbnails-panel) @@ -132,20 +132,20 @@ [:section {:class (stl/css-case :viewer-thumbnails true :expanded expanded?) ;; This is better as an inline-style so it won't make a reflow of every frame inside - :style {:display (when (not show?) "none")} + :style {:display (when (not show) "none")} :ref container} - [:& thumbnails-summary {:on-toggle-expand toggle-expand - :on-close on-close - :total (count frames)}] - [:& thumbnails-content {:expanded? expanded? - :total (count frames)} + [:> thumbnails-summary* {:on-toggle-expand toggle-expand + :on-close on-close + :total (count frames)}] + [:> thumbnails-content* {:is-expanded expanded? + :total (count frames)} (for [[i frame] (d/enumerate frames)] - [:& thumbnail-item {:index i - :key (dm/str (:id frame) "-" i) - :frame frame - :page-id (:id page) - :objects objects - :on-click on-item-click - :selected? (= i index) - :thumbnail-data thumbnail-data}])]])) + [:> thumbnail-item* {:index i + :key (dm/str (:id frame) "-" i) + :frame frame + :page-id (:id page) + :objects objects + :on-click on-item-click + :is-selected (= i index) + :thumbnail-data thumbnail-data}])]])) diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index 0ef0936d22..9bcf142358 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -77,7 +77,8 @@ (mf/deps vport) (fn [resize-type size] (when (and vport (not= size vport)) - (st/emit! (dw/update-viewport-size resize-type size))))) + (st/emit! (dw/update-viewport-size resize-type size) + (dw/sync-wasm-workspace-viewport))))) on-resize-palette (mf/use-fn diff --git a/frontend/src/app/main/ui/workspace/color_palette.cljs b/frontend/src/app/main/ui/workspace/color_palette.cljs index 5e2eb534f1..92c6fa25de 100644 --- a/frontend/src/app/main/ui/workspace/color_palette.cljs +++ b/frontend/src/app/main/ui/workspace/color_palette.cljs @@ -28,7 +28,6 @@ [app.util.object :as obj] [app.util.strings :refer [matches-search]] [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (mf/defc palette-item* @@ -41,10 +40,10 @@ (st/emit! (mdc/add-recent-color color) (mdc/apply-color-from-palette color (kbd/alt? event)) (when (not= selected :recent) - (ptk/data-event ::ev/event - {::ev/name "use-library-color" - ::ev/origin "color-palette" - :external-library (not= selected :file)}))))) + (ev/event + {::ev/name "use-library-color" + ::ev/origin "color-palette" + :external-library (not= selected :file)}))))) title (uc/get-color-name color)] diff --git a/frontend/src/app/main/ui/workspace/colorpicker.cljs b/frontend/src/app/main/ui/workspace/colorpicker.cljs index d6d4300848..c1f8ea396a 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker.cljs @@ -15,7 +15,7 @@ [app.common.types.fills :as types.fills] [app.common.types.tokens-lib :as ctob] [app.config :as cfg] - [app.main.data.event :as-alias ev] + [app.main.data.event :as ev] [app.main.data.modal :as modal] [app.main.data.shortcuts :as dsc] [app.main.data.workspace.colors :as dc] @@ -26,7 +26,6 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.file-uploader :refer [file-uploader]] - [app.main.ui.components.numeric-input :refer [numeric-input*]] [app.main.ui.components.radio-buttons :refer [radio-buttons radio-button]] [app.main.ui.components.select :refer [select]] [app.main.ui.ds.foundations.assets.icon :as i] @@ -46,7 +45,6 @@ [app.util.timers :as ts] [cuerdas.core :as str] [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf] [rumext.v2.util :as mfu])) @@ -83,6 +81,15 @@ hsl-from (cc/hsv->hsl [h 0.0 v]) hsl-to (cc/hsv->hsl [h 1.0 v]) + ;; HSL-mode gradients. For S: fix current lightness, sweep + ;; saturation 0 → 1. For L: fix current saturation, sweep + ;; lightness 0 → 0.5 (pure hue) → 1. All computed at the + ;; current hue. + [_ cur-hsl-s cur-hsl-l] (cc/rgb->hsl rgb) + hsl-sat-from [h 0.0 cur-hsl-l] + hsl-sat-to [h 1.0 cur-hsl-l] + lightness-mid [h cur-hsl-s 0.5] + format-hsl (fn [[h s l]] (str/fmt "hsl(%s, %s, %s)" h @@ -91,7 +98,10 @@ (dom/set-css-property! node "--color" (str/join ", " rgb)) (dom/set-css-property! node "--hue-rgb" (str/join ", " hue-rgb)) (dom/set-css-property! node "--saturation-grad-from" (format-hsl hsl-from)) - (dom/set-css-property! node "--saturation-grad-to" (format-hsl hsl-to))))) + (dom/set-css-property! node "--saturation-grad-to" (format-hsl hsl-to)) + (dom/set-css-property! node "--hsl-saturation-grad-from" (format-hsl hsl-sat-from)) + (dom/set-css-property! node "--hsl-saturation-grad-to" (format-hsl hsl-sat-to)) + (dom/set-css-property! node "--lightness-grad-mid" (format-hsl lightness-mid))))) (mf/defc colorpicker* [{:keys [data disable-gradient disable-opacity disable-image on-change on-accept origin combined-tokens color-origin on-token-change tab applied-token]}] @@ -129,10 +139,15 @@ active-color-tab* (hooks/use-persisted-state ::color-tab "ramp") active-color-tab (deref active-color-tab*) + ;; Inline HSB/HSL toggle inside the HSBA tab — shared between + ;; the slider selector (for labels) and the numeric inputs. + hsb-mode* (hooks/use-persisted-state ::hsb-mode :hsb) + hsb-mode (deref hsb-mode*) + drag?* (mf/use-state false) drag? (deref drag?*) - type (if (= active-color-tab "hsva") :hsv :rgb) + type (if (= active-color-tab "hsva") :hsb :rgb) fill-image-ref (mf/use-ref nil) @@ -174,9 +189,9 @@ (st/emit! (dc/update-colorpicker-color {:image image} true) - (ptk/data-event ::ev/event {::ev/name "toggle-image-aspect-ratio" - ::ev/origin "workspace:colorpicker" - :checked keep-aspect-ratio?}))))) + (ev/event {::ev/name "toggle-image-aspect-ratio" + ::ev/origin "workspace:colorpicker" + :checked keep-aspect-ratio?}))))) on-change-tab (mf/use-fn #(reset! active-color-tab* %)) @@ -341,11 +356,6 @@ (mapv #(assoc %2 :offset (:offset %1)) stops new-stops)] (st/emit! (dc/update-colorpicker-stops stops))))) - handle-change-gradient-opacity - (mf/use-fn - (fn [value] - (st/emit! (dc/update-colorpicker-gradient-opacity (/ value 100))))) - render-wasm? (features/use-feature "render-wasm/v1") @@ -357,7 +367,7 @@ {:aria-label "Harmony" :icon i/rgba-complementary :id "harmony"} - {:aria-label "HSVA" + {:aria-label "HSBA" :icon i/hsva :id "hsva"}]) @@ -366,7 +376,12 @@ ;; Initialize colorpicker state (mf/with-effect [] (st/emit! (dc/initialize-colorpicker on-change active-fill-tab)) - (partial st/emit! (dc/finalize-colorpicker))) + ;; Always deactivate picking mode on unmount so that :picking-color? never + ;; stays true if the modal closes for any reason other than the normal + ;; pointer-up path (e.g. ESC, navigation, programmatic hide). + (fn [] + (st/emit! (dc/stop-picker) + (dc/finalize-colorpicker)))) ;; Update colorpicker with external color changes (mf/with-effect [data] @@ -394,17 +409,6 @@ [:div {:class (stl/css :top-actions)} [:div {:class (stl/css :top-actions-right)} - (when (and (= color-style :direct-color) - (= :gradient selected-mode)) - [:div {:class (stl/css :opacity-input-wrapper)} - [:span {:class (stl/css :icon-text)} "%"] - [:> numeric-input* - {:value (-> data :opacity opacity->string) - :on-change handle-change-gradient-opacity - :default 100 - :data-testid "opacity-global-input" - :min 0 - :max 100}]]) (when (and (= color-style :direct-color) (or (not disable-gradient) (not disable-image))) @@ -522,6 +526,7 @@ [:> hsva-selector* {:color current-color :disable-opacity disable-opacity + :mode hsb-mode :on-change handle-change-color :on-start-drag on-start-drag :on-finish-drag on-finish-drag}]))]] @@ -529,6 +534,8 @@ [:> color-inputs* {:type type :disable-opacity disable-opacity + :mode hsb-mode + :on-mode-change #(reset! hsb-mode* %) :color current-color :on-change handle-change-color}] diff --git a/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs b/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs index 09ad2d0e8c..69c466669c 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs @@ -28,11 +28,23 @@ [val] (* (/ val 255) 100)) -(mf/defc color-inputs* [{:keys [type color disable-opacity on-change]}] +(mf/defc color-inputs* [{:keys [type color disable-opacity mode on-mode-change on-change]}] (let [{red :r green :g blue :b hue :h saturation :s value :v hex :hex alpha :alpha} color + ;; Sub-model selector for the HSB tab: users can toggle between + ;; HSB and HSL input display without leaving the tab. State is + ;; lifted to the colorpicker parent so the slider labels stay + ;; in sync with the inputs. + hsb-mode (or mode :hsb) + + ;; Compute HSL from current RGB (derived; not stored on the color map) + [_hsl-h hsl-s hsl-l] + (if (and red green blue) + (cc/rgb->hsl [red green blue]) + [0 0 0]) + refs {:hex (mf/use-ref nil) :r (mf/use-ref nil) :g (mf/use-ref nil) @@ -40,6 +52,8 @@ :h (mf/use-ref nil) :s (mf/use-ref nil) :v (mf/use-ref nil) + :hsl-s (mf/use-ref nil) + :hsl-l (mf/use-ref nil) :alpha (mf/use-ref nil)} setup-hex-color @@ -73,6 +87,7 @@ (let [val (case property :s (/ val 100) :v (value->hsv-value val) + (:hsl-s :hsl-l) (/ val 100) :alpha (/ val 100) val)] (cond @@ -87,6 +102,18 @@ :h h :s s :v v :r r :g g :b b})) + ;; HSL changes: recompute RGB/HSV from the new HSL triple, + ;; reusing the current hue when only S or L changes. + (#{:hsl-s :hsl-l} property) + (let [new-s (if (= property :hsl-s) val hsl-s) + new-l (if (= property :hsl-l) val hsl-l) + [r g b] (cc/hsl->rgb [hue new-s new-l]) + hex (cc/rgb->hex [r g b]) + [h s v] (cc/hex->hsv hex)] + (on-change {:hex hex + :h h :s s :v v + :r r :g g :b b})) + :else (let [{:keys [h s v]} (merge color (hash-map property val)) hex (cc/hsv->hex [h s v]) @@ -126,10 +153,13 @@ ;; Updates the inputs values when a property is changed in the parent (mf/use-effect - (mf/deps color type) + (mf/deps color type hsb-mode) (fn [] (doseq [ref-key (keys refs)] - (let [property-val (get color ref-key) + (let [property-val (case ref-key + :hsl-s hsl-s + :hsl-l hsl-l + (get color ref-key)) property-ref (get refs ref-key)] (when (and property-val property-ref) (when-let [node (mf/ref-val property-ref)] @@ -137,14 +167,32 @@ (case ref-key (:s :alpha) (mth/precision (* property-val 100) 2) :v (mth/precision (hsv-value->value property-val) 2) + (:hsl-s :hsl-l) (mth/precision (* property-val 100) 2) property-val)] (dom/set-value! node new-val)))))))) [:div {:class (stl/css-case :color-values true :disable-opacity disable-opacity)} + ;; Inline HSB/HSL switcher — only shown on the HSB tab so that + ;; designers can pick whichever hue-based model matches their + ;; workflow (HSB matches Figma/Sketch/XD, HSL matches CSS). + (when (and (not= type :rgb) on-mode-change) + [:div {:class (stl/css :model-switcher)} + [:button {:type "button" + :class (stl/css-case :model-pill true + :model-pill-active (= hsb-mode :hsb)) + :on-click #(on-mode-change :hsb)} + "HSB"] + [:button {:type "button" + :class (stl/css-case :model-pill true + :model-pill-active (= hsb-mode :hsl)) + :on-click #(on-mode-change :hsl)} + "HSL"]]) + [:div {:class (stl/css :colors-row)} - (if (= type :rgb) + (cond + (= type :rgb) [:* [:div {:class (stl/css :input-wrapper)} [:label {:for "red-value" :class (stl/css :input-label)} "R"] @@ -177,6 +225,42 @@ :on-change (on-change-property :b 255) :on-key-down (on-key-down-property :b 255)}]]] + (= hsb-mode :hsl) + [:* + [:div {:class (stl/css :input-wrapper)} + [:label {:for "hue-value" :class (stl/css :input-label)} "H"] + [:input {:id "hue-value" + :ref (:h refs) + :type "number" + :min 0 + :max 360 + :default-value hue + :on-change (on-change-property :h 360) + :on-key-down (on-key-down-property :h 360)}]] + [:div {:class (stl/css :input-wrapper)} + [:label {:for "hsl-saturation-value" :class (stl/css :input-label)} "S"] + [:input {:id "hsl-saturation-value" + :ref (:hsl-s refs) + :type "number" + :min 0 + :max 100 + :step 1 + :default-value (mth/precision (* hsl-s 100) 2) + :on-change (on-change-property :hsl-s 100) + :on-key-down (on-key-down-property :hsl-s 100)}]] + [:div {:class (stl/css :input-wrapper)} + [:label {:for "lightness-value" :class (stl/css :input-label)} "L"] + [:input {:id "lightness-value" + :ref (:hsl-l refs) + :type "number" + :min 0 + :max 100 + :step 1 + :default-value (mth/precision (* hsl-l 100) 2) + :on-change (on-change-property :hsl-l 100) + :on-key-down (on-key-down-property :hsl-l 100)}]]] + + :else [:* [:div {:class (stl/css :input-wrapper)} [:label {:for "hue-value" :class (stl/css :input-label)} "H"] @@ -200,8 +284,8 @@ :on-change (on-change-property :s 100) :on-key-down (on-key-down-property :s 100)}]] [:div {:class (stl/css :input-wrapper)} - [:label {:for "value-value" :class (stl/css :input-label)} "V"] - [:input {:id "value-value" + [:label {:for "brightness-value" :class (stl/css :input-label)} "B(V)"] + [:input {:id "brightness-value" :ref (:v refs) :type "number" :min 0 diff --git a/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.scss b/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.scss index fe5b93d679..3a3034bc95 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.scss @@ -11,6 +11,36 @@ margin-top: deprecated.$s-8; + .model-switcher { + display: flex; + gap: deprecated.$s-4; + margin-bottom: deprecated.$s-8; + padding: deprecated.$s-2; + background-color: var(--color-background-tertiary); + border-radius: deprecated.$s-6; + align-self: flex-start; + + .model-pill { + @include deprecated.body-small-typography; + + padding: deprecated.$s-2 deprecated.$s-8; + border: none; + border-radius: deprecated.$s-4; + background: transparent; + color: var(--color-foreground-secondary); + cursor: pointer; + + &:hover { + color: var(--color-foreground-primary); + } + + &.model-pill-active { + background-color: var(--color-background-primary); + color: var(--color-accent-primary); + } + } + } + &.disable-opacity { grid-template-columns: 3.5rem repeat(3, 1fr); } diff --git a/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs b/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs index 89be5ed48f..c06eeaab81 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs @@ -294,7 +294,7 @@ sorted-tokens (sort-combined-tokens filtered-combined)] (if (seq combined-tokens) [:div {:class (stl/css :color-tokens-section)} - [:> input* {:placeholder "Search by token name" + [:> input* {:placeholder (tr "workspace.tokens.search-by-token") :icon i/search :max-length max-input-length :variant "comfortable" diff --git a/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs b/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs index 807d976314..802f59c8c2 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs @@ -11,17 +11,45 @@ [app.main.ui.workspace.colorpicker.slider-selector :refer [slider-selector*]] [rumext.v2 :as mf])) -(mf/defc hsva-selector* [{:keys [color disable-opacity on-change on-start-drag on-finish-drag]}] - (let [{hue :h saturation :s value :v alpha :alpha} color - handle-change-slider (fn [key] - (fn [new-value] - (let [change (hash-map key new-value) - {:keys [h s v]} (merge color change) - hex (cc/hsv->hex [h s v]) - [r g b] (cc/hex->rgb hex)] - (on-change (merge change - {:hex hex - :r r :g g :b b}))))) +(mf/defc hsva-selector* [{:keys [color disable-opacity mode on-change on-start-drag on-finish-drag]}] + (let [{hue :h saturation :s value :v alpha :alpha + r-val :r g-val :g b-val :b} color + hsl-mode? (= mode :hsl) + + ;; Current HSL derived from RGB — used as the starting point + ;; for HSL saturation/lightness slider values and for + ;; recomputing the color when either is dragged. + [_ hsl-s hsl-l] (if (and r-val g-val b-val) + (cc/rgb->hsl [r-val g-val b-val]) + [0 0 0]) + + ;; HSB math — current default behavior. + handle-change-hsv + (fn [key] + (fn [new-value] + (let [change (hash-map key new-value) + {:keys [h s v]} (merge color change) + hex (cc/hsv->hex [h s v]) + [r g b] (cc/hex->rgb hex)] + (on-change (merge change + {:hex hex + :r r :g g :b b}))))) + + ;; HSL math — when the user drags the S or L slider in HSL mode, + ;; we recompute RGB from the updated HSL triple and derive HSV + ;; for the canonical color representation. + handle-change-hsl + (fn [key] + (fn [new-value] + (let [new-s (if (= key :hsl-s) new-value hsl-s) + new-l (if (= key :hsl-l) new-value hsl-l) + [r g b] (cc/hsl->rgb [hue new-s new-l]) + hex (cc/rgb->hex [r g b]) + [h s v] (cc/hex->hsv hex)] + (on-change {:hex hex + :h h :s s :v v + :r r :g g :b b})))) + on-change-opacity (fn [new-alpha] (on-change {:alpha new-alpha}))] [:div {:class (stl/css :hsva-selector)} [:div {:class (stl/css :hsva-row)} @@ -31,29 +59,47 @@ :type :hue :max-value 360 :value hue - :on-change (handle-change-slider :h) + :on-change (handle-change-hsv :h) :on-start-drag on-start-drag :on-finish-drag on-finish-drag}]] [:div {:class (stl/css :hsva-row)} [:span {:class (stl/css :hsva-selector-label)} "S"] - [:> slider-selector* - {:class (stl/css :hsva-bar) - :type :saturation - :max-value 1 - :value saturation - :on-change (handle-change-slider :s) - :on-start-drag on-start-drag - :on-finish-drag on-finish-drag}]] + (if hsl-mode? + [:> slider-selector* + {:class (stl/css :hsva-bar) + :type :hsl-saturation + :max-value 1 + :value hsl-s + :on-change (handle-change-hsl :hsl-s) + :on-start-drag on-start-drag + :on-finish-drag on-finish-drag}] + [:> slider-selector* + {:class (stl/css :hsva-bar) + :type :saturation + :max-value 1 + :value saturation + :on-change (handle-change-hsv :s) + :on-start-drag on-start-drag + :on-finish-drag on-finish-drag}])] [:div {:class (stl/css :hsva-row)} - [:span {:class (stl/css :hsva-selector-label)} "V"] - [:> slider-selector* - {:class (stl/css :hsva-bar) - :type :value - :max-value 255 - :value value - :on-change (handle-change-slider :v) - :on-start-drag on-start-drag - :on-finish-drag on-finish-drag}]] + [:span {:class (stl/css :hsva-selector-label)} (if hsl-mode? "L" "B(V)")] + (if hsl-mode? + [:> slider-selector* + {:class (stl/css :hsva-bar) + :type :lightness + :max-value 1 + :value hsl-l + :on-change (handle-change-hsl :hsl-l) + :on-start-drag on-start-drag + :on-finish-drag on-finish-drag}] + [:> slider-selector* + {:class (stl/css :hsva-bar) + :type :value + :max-value 255 + :value value + :on-change (handle-change-hsv :v) + :on-start-drag on-start-drag + :on-finish-drag on-finish-drag}])] (when (not disable-opacity) [:div {:class (stl/css :hsva-row)} [:span {:class (stl/css :hsva-selector-label)} "A"] diff --git a/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs b/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs index baf82c0f79..6e2101e53a 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs @@ -24,7 +24,6 @@ [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (mf/defc libraries* @@ -87,8 +86,7 @@ (mf/deps state selected on-select-color) (fn [event] (when-not (= :recent selected) - (st/emit! (ptk/event - ::ev/event + (st/emit! (ev/event {::ev/name "use-library-color" ::ev/origin "colorpicker" :external-library (not= :file selected)}))) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs b/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs index f125b6368b..7021db6767 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs @@ -53,7 +53,9 @@ :slider-selector true :hue (= type :hue) :opacity (= type :opacity) - :value (= type :value))) + :value (= type :value) + :hsl-saturation (= type :hsl-saturation) + :lightness (= type :lightness))) :data-testid (when (= type :opacity) "slider-opacity") :on-pointer-down handle-start-drag :on-pointer-up handle-stop-drag diff --git a/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.scss b/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.scss index 4473eaface..09b9942e22 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.scss @@ -72,6 +72,18 @@ background: linear-gradient(var(--gradient-direction), #000 0%, #fff 100%); } + &.hsl-saturation { + background: linear-gradient( + var(--gradient-direction), + var(--hsl-saturation-grad-from) 0%, + var(--hsl-saturation-grad-to) 100% + ); + } + + &.lightness { + background: linear-gradient(var(--gradient-direction), #000 0%, var(--lightness-grad-mid) 50%, #fff 100%); + } + .handler { position: absolute; left: 50%; diff --git a/frontend/src/app/main/ui/workspace/context_menu.cljs b/frontend/src/app/main/ui/workspace/context_menu.cljs index 7b645384bb..3230be463f 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/context_menu.cljs @@ -43,7 +43,6 @@ [app.util.timers :as timers] [beicon.v2.core :as rx] [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (def menu-ref @@ -55,8 +54,7 @@ (dom/stop-propagation event)) (mf/defc menu-entry* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [title shortcut on-click on-pointer-enter on-pointer-leave on-unmount children is-selected icon disabled value]}] (let [submenu-ref (mf/use-ref nil) @@ -143,14 +141,12 @@ children])]))) (mf/defc menu-separator* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [] [:li {:class (stl/css :separator)}]) (mf/defc context-menu-edit* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [shapes]}] (let [multiple? (> (count shapes) 1) @@ -229,8 +225,7 @@ [:> menu-entry* {:title (tr "workspace.shape.menu.copy-svg") :on-click handle-copy-svg}] - (when (and (some cfh/frame-shape? shapes) - (contains? cf/flags :canary)) + (when (some cfh/frame-shape? shapes) [:> menu-entry* {:title (tr "workspace.shape.menu.copy-as-image") :disabled multiple? :on-click handle-copy-as-image}]) @@ -252,8 +247,7 @@ [:> menu-separator* {}]])) (mf/defc context-menu-layer-position* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [shapes]}] (let [do-bring-forward (mf/use-fn #(st/emit! (dw/vertical-order-selected :up))) do-bring-to-front (mf/use-fn #(st/emit! (dw/vertical-order-selected :top))) @@ -299,8 +293,7 @@ [:> menu-separator* {}]])) (mf/defc context-menu-flip* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [] (let [do-flip-vertical #(st/emit! (dw/flip-vertical-selected)) do-flip-horizontal #(st/emit! (dw/flip-horizontal-selected))] @@ -315,8 +308,7 @@ [:> menu-separator* {}]])) (mf/defc context-menu-thumbnail* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [shapes]}] (let [single? (= (count shapes) 1) has-frame? (some cfh/frame-shape? shapes) @@ -332,8 +324,7 @@ [:> menu-separator* {}]]))) (mf/defc context-menu-rename* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [shapes]}] (let [do-rename #(st/emit! (dw/start-rename-selected))] (when (= (count shapes) 1) @@ -344,8 +335,7 @@ :on-click do-rename}]]))) (mf/defc context-menu-group* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [shapes]}] (let [multiple? (> (count shapes) 1) single? (= (count shapes) 1) @@ -398,8 +388,7 @@ [:> menu-separator* {}]])])) (mf/defc context-focus-mode-menu* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [] (let [focus (mf/deref refs/workspace-focus-selected) do-toggle-focus-mode #(st/emit! (dw/toggle-focus-mode))] @@ -411,8 +400,7 @@ :on-click do-toggle-focus-mode}])) (mf/defc context-menu-path* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [shapes objects disable-flatten disable-booleans]}] (let [multiple? (> (count shapes) 1) single? (= (count shapes) 1) @@ -489,8 +477,7 @@ :on-click do-transform-to-path}]])])])) (mf/defc context-menu-layer-options* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [shapes]}] (let [ids (mapv :id shapes) do-show-shape #(st/emit! (dw/update-shape-flags ids {:hidden false})) @@ -515,8 +502,7 @@ :on-click do-lock-shape}])])) (mf/defc context-menu-prototype* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [shapes]}] (let [flows (mf/deref refs/workspace-page-flows) options-mode (mf/deref refs/options-mode-global) @@ -537,8 +523,7 @@ :on-click do-add-flow}])))) (mf/defc context-menu-layout* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [shapes]}] (let [single? (= (count shapes) 1) objects (deref refs/workspace-page-objects) @@ -667,8 +652,7 @@ :on-click do-remove-guides}]]))) (mf/defc context-menu-delete* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [] (let [do-delete #(st/emit! (dw/delete-selected))] [:* @@ -711,8 +695,7 @@ [:> context-menu-delete* props]]))) (mf/defc page-item-context-menu* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [mdata]}] (let [page (:page mdata) deletable? (:deletable? mdata) @@ -725,7 +708,7 @@ :on-accept delete-fn})) do-duplicate #(st/emit! (dw/duplicate-page id) - (ptk/event ::ev/event {::ev/name "duplicate-page"})) + (ev/event {::ev/name "duplicate-page"})) do-rename #(st/emit! (dw/start-rename-page-item id))] [:* @@ -739,7 +722,6 @@ :on-click do-duplicate}]])) (mf/defc viewport-context-menu* - {::mf/props :obj} [] (let [focus (mf/deref refs/workspace-focus-selected) read-only? (mf/use-ctx ctx/workspace-read-only?) @@ -762,8 +744,7 @@ :on-click do-toggle-focus-mode}])])) (mf/defc grid-track-context-menu* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [mdata]}] (let [{:keys [type index grid-id]} mdata do-delete-track @@ -812,8 +793,7 @@ [:> menu-entry* {:title (tr "workspace.context-menu.grid-track.row.delete-shapes") :on-click do-delete-track-shapes}]]))) (mf/defc grid-cells-context-menu* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [mdata]}] (let [{:keys [grid cells]} mdata diff --git a/frontend/src/app/main/ui/workspace/left_header.scss b/frontend/src/app/main/ui/workspace/left_header.scss index 1522c0a2d5..a0cd2153e5 100644 --- a/frontend/src/app/main/ui/workspace/left_header.scss +++ b/frontend/src/app/main/ui/workspace/left_header.scss @@ -31,9 +31,10 @@ .project-tree { position: relative; flex-grow: 1; + flex-shrink: 1; + min-width: 0; height: deprecated.$s-32; min-height: deprecated.$s-32; - max-width: calc(100% - deprecated.$s-64); } .project-name, diff --git a/frontend/src/app/main/ui/workspace/libraries.cljs b/frontend/src/app/main/ui/workspace/libraries.cljs index 6b9532225a..b904d5b5e1 100644 --- a/frontend/src/app/main/ui/workspace/libraries.cljs +++ b/frontend/src/app/main/ui/workspace/libraries.cljs @@ -107,8 +107,7 @@ "\u00A0"))) (mf/defc library-description* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [summary]}] (let [components-count (get summary :components) graphics-count (get summary :graphics) @@ -133,8 +132,7 @@ (tr "workspace.libraries.typography" (c typography-count))])])) (mf/defc sample-library-entry* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [library importing]}] (let [id (:id library) importing? (deref importing) @@ -185,8 +183,7 @@ (not (ctob/empty-lib? tokens-lib)))) (mf/defc libraries-tab* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [is-shared linked-libraries shared-libraries]}] (let [file-id (mf/use-ctx ctx/current-file-id) search-term* (mf/use-state "") @@ -485,8 +482,7 @@ :typographies typographies}])) (mf/defc updates-tab* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [file-id libraries]}] ;; FIXME: naming (let [summary?* (mf/use-state true) @@ -514,12 +510,13 @@ (mf/deps file-id) (fn [event] (when-not updating? - (let [library-id (some-> (dom/get-target event) + (let [library-id (some-> (dom/get-current-target event) (dom/get-data "library-id") (uuid/parse))] - (st/emit! - (dwl/set-updating-library true) - (dwl/sync-file file-id library-id))))))] + (when library-id + (st/emit! + (dwl/set-updating-library true) + (dwl/sync-file file-id library-id)))))))] [:div {:class (stl/css :updates-content)} [:div {:class (stl/css :update-section)} @@ -621,7 +618,7 @@ (when (or (pos? (:components exceeded)) (pos? (:colors exceeded)) (pos? (:typographies exceeded))) - [:& lb/link-button + [:> lb/link-button* {:on-click see-all-assets :class (stl/css :libraries-updates-see-all) :value (str "(" (tr "workspace.libraries.update.see-all-changes") ")")}])])]])]])) diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index ab4c7896ab..7bdf91fb1f 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -18,6 +18,7 @@ [app.main.data.exports.assets :as de] [app.main.data.exports.files :as fexp] [app.main.data.modal :as modal] + [app.main.data.notifications :as ntf] [app.main.data.plugins :as dp] [app.main.data.profile :as du] [app.main.data.shortcuts :as scd] @@ -43,13 +44,8 @@ [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] [beicon.v2.core :as rx] - [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) -(def tokens-ref - (l/derived :access-tokens st/state)) - (mf/defc shortcuts* {::mf/private true} [{:keys [id]}] @@ -66,43 +62,43 @@ (let [nav-to-helpc-center (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "explore-help-center-click" - ::ev/origin "workspace-menu:in-app"})) + (st/emit! (ev/event {::ev/name "explore-help-center-click" + ::ev/origin "workspace-menu:in-app"})) (dom/open-new-window "https://help.penpot.app"))) nav-to-community (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "explore-community-click" - ::ev/origin "workspace-menu:in-app"})) + (st/emit! (ev/event {::ev/name "explore-community-click" + ::ev/origin "workspace-menu:in-app"})) (dom/open-new-window "https://community.penpot.app"))) nav-to-youtube (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "explore-tutorials-click" - ::ev/origin "workspace-menu:in-app"})) + (st/emit! (ev/event {::ev/name "explore-tutorials-click" + ::ev/origin "workspace-menu:in-app"})) (dom/open-new-window "https://www.youtube.com/c/Penpot"))) nav-to-templates (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "explore-libraries-click" - ::ev/origin "workspace"})) + (st/emit! (ev/event {::ev/name "explore-libraries-click" + ::ev/origin "workspace"})) (dom/open-new-window "https://penpot.app/libraries-templates"))) nav-to-github (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "explore-github-repository-click" - ::ev/origin "workspace-menu:in-app"})) + (st/emit! (ev/event {::ev/name "explore-github-repository-click" + ::ev/origin "workspace-menu:in-app"})) (dom/open-new-window "https://github.com/penpot/penpot"))) nav-to-terms (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "explore-terms-service-click" - ::ev/origin "workspace-menu:in-app"})) + (st/emit! (ev/event {::ev/name "explore-terms-service-click" + ::ev/origin "workspace-menu:in-app"})) (dom/open-new-window "https://penpot.app/terms"))) nav-to-feedback @@ -129,8 +125,8 @@ (mf/use-fn (fn [event] (let [version (:main cf/version)] - (st/emit! (ptk/event ::ev/event {::ev/name "show-release-notes" - :version version})) + (st/emit! (ev/event {::ev/name "show-release-notes" + :version version})) (println version) (if (and (kbd/alt? event) (kbd/mod? event)) (st/emit! (modal/show {:type :onboarding})) @@ -230,8 +226,10 @@ (mf/defc preferences-menu* {::mf/private true ::mf/wrap [mf/memo]} - [{:keys [layout profile toggle-flag on-close toggle-theme]}] - (let [show-nudge-options + [{:keys [layout profile toggle-flag on-close toggle-theme toggle-render]}] + (let [renderer (or (-> profile :props :renderer) :svg) + + show-nudge-options (mf/use-fn #(modal/show! {:type :nudge-option}))] @@ -325,7 +323,17 @@ "light" (tr "workspace.header.menu.toggle-system-theme") "system" (tr "workspace.header.menu.toggle-dark-theme") (tr "workspace.header.menu.toggle-light-theme"))] - [:> shortcuts* {:id :toggle-theme}]]])) + [:> shortcuts* {:id :toggle-theme}]] + (when (contains? cf/flags :render-switch) + [:> dropdown-menu-item* {:on-click toggle-render + :class (stl/css :base-menu-item :submenu-item) + :on-key-down (fn [event] + (when (kbd/enter? event) + (toggle-render event)))} + [:span {:class (stl/css :item-name)} + (if (= renderer :wasm) + (tr "workspace.header.menu.disable-webgl") + (tr "workspace.header.menu.enable-webgl"))]])])) (mf/defc view-menu* {::mf/private true @@ -747,10 +755,10 @@ (fn [event] (if can-open? (do - (st/emit! (ptk/event ::ev/event {::ev/name "start-plugin" - ::ev/origin "workspace:menu" - :name name - :host host})) + (st/emit! (ev/event {::ev/name "start-plugin" + ::ev/origin "workspace:menu" + :name name + :host host})) (dp/open-plugin! manifest user-can-edit?)) (dom/stop-propagation event)))) on-key-down @@ -759,10 +767,10 @@ (fn [event] (when can-open? (when (kbd/enter? event) - (st/emit! (ptk/event ::ev/event {::ev/name "start-plugin" - ::ev/origin "workspace:menu" - :name name - :host host})) + (st/emit! (ev/event {::ev/name "start-plugin" + ::ev/origin "workspace:menu" + :name name + :host host})) (dp/open-plugin! manifest user-can-edit?)))))] [:> dropdown-menu-item* {:key (dm/str "plugins-menu-" idx) @@ -782,17 +790,26 @@ [{:keys [on-close]}] (let [plugins? (features/active-feature? @st/state "plugins/runtime") - profile (mf/deref refs/profile) - mcp (mf/deref refs/mcp) + profile (mf/deref refs/profile) + mcp (mf/deref refs/mcp) + tokens (mf/deref refs/access-tokens) - mcp-enabled? (true? (-> profile :props :mcp-enabled)) - mcp-connected? (= "connected" (get mcp :connection-status)) + expires-at (some->> tokens + (some #(when (= (:type %) "mcp") %)) + :expires-at) + expired? (and (some? expires-at) (> (ct/now) expires-at)) + + mcp-enabled? (true? (-> profile :props :mcp-enabled)) + mcp-connection (get mcp :connection-status) + mcp-connected? (= mcp-connection "connected") + + show-enabled? (and mcp-enabled? (false? expired?)) on-nav-to-integrations (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "manage-mpc-option" - ::ev/origin "workspace-menu"})) + (st/emit! (ev/event {::ev/name "manage-mpc-option" + ::ev/origin "workspace-menu"})) (dom/open-new-window "/#/settings/integrations"))) on-nav-to-integrations-key-down @@ -806,11 +823,11 @@ (fn [] (if mcp-connected? (st/emit! (mcp/user-disconnect-mcp) - (ptk/event ::ev/event {::ev/name "disconnect-mcp-plugin" - ::ev/origin "workspace-menu"})) + (ev/event {::ev/name "disconnect-mcp-plugin" + ::ev/origin "workspace-menu"})) (st/emit! (mcp/connect-mcp) - (ptk/event ::ev/event {::ev/name "connect-mcp-plugin" - ::ev/origin "workspace-menu"}))))) + (ev/event {::ev/name "connect-mcp-plugin" + ::ev/origin "workspace-menu"}))))) on-toggle-mcp-plugin-key-down (mf/use-fn @@ -825,7 +842,7 @@ :pos-6 plugins?) :on-close on-close} - (when mcp-enabled? + (when (and show-enabled? (not expired?)) [:> dropdown-menu-item* {:id "mcp-menu-toggle-mcp-plugin" :class (stl/css :base-menu-item :submenu-item) :on-click on-toggle-mcp-plugin @@ -840,7 +857,7 @@ :on-click on-nav-to-integrations :on-key-down on-nav-to-integrations-key-down} [:span {:class (stl/css :item-name)} - (if mcp-enabled? + (if show-enabled? (tr "workspace.header.menu.mcp.server.status.enabled") (tr "workspace.header.menu.mcp.server.status.disabled"))]]])) @@ -892,8 +909,8 @@ on-power-up-click (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "explore-pricing-click" - ::ev/origin "workspace-menu"})) + (st/emit! (ev/event {::ev/name "explore-pricing-click" + ::ev/origin "workspace-menu"})) (dom/open-new-window "https://penpot.app/pricing"))) toggle-flag @@ -915,6 +932,18 @@ (dom/stop-propagation event) (st/emit! (du/toggle-theme)))) + toggle-render + (mf/use-fn + (mf/deps profile) + (fn [event] + (dom/stop-propagation event) + (let [renderer (or (-> profile :props :renderer) :svg) + next-renderer (if (= renderer :wasm) :svg :wasm)] + (st/emit! (du/update-profile-props {:renderer next-renderer}) + (ntf/success (tr (if (= next-renderer :wasm) + "webgl.toast.webgl-render-enabled" + "webgl.toast.webgl-render-disabled"))))))) + open-plugins-manager (mf/use-fn (fn [event] @@ -922,8 +951,8 @@ (reset! show-menu* false) (reset! selected-sub-menu* nil) (st/emit! - (ptk/event ::ev/event {::ev/name "open-plugins-manager" - ::ev/origin "workspace:menu"}) + (ev/event {::ev/name "open-plugins-manager" + ::ev/origin "workspace:menu"}) (modal/show :plugin-management {})))) subscription (:subscription (:props profile)) @@ -1014,11 +1043,11 @@ :class (stl/css :item-arrow)}]]) (when (contains? cf/flags :mcp) - (let [tokens (mf/deref tokens-ref) - expired? (some->> tokens - (some #(when (= (:type %) "mcp") %)) - :expires-at - (> (ct/now))) + (let [tokens (mf/deref refs/access-tokens) + expires-at (some->> tokens + (some #(when (= (:type %) "mcp") %)) + :expires-at) + expired? (and (some? expires-at) (> (ct/now) expires-at)) mcp-enabled? (true? (-> profile :props :mcp-enabled)) mcp-connection (get mcp :connection-status) @@ -1094,6 +1123,7 @@ :profile profile :toggle-flag toggle-flag :toggle-theme toggle-theme + :toggle-render toggle-render :on-close close-sub-menu}] :plugins diff --git a/frontend/src/app/main/ui/workspace/plugins.cljs b/frontend/src/app/main/ui/workspace/plugins.cljs index fc319bf925..20173fe701 100644 --- a/frontend/src/app/main/ui/workspace/plugins.cljs +++ b/frontend/src/app/main/ui/workspace/plugins.cljs @@ -29,7 +29,6 @@ [app.util.i18n :as i18n :refer [tr]] [beicon.v2.core :as rx] [cuerdas.core :as str] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (def ^:private close-icon @@ -144,7 +143,7 @@ (reset! fetching-manifest? false) (if plugin (do - (st/emit! (ptk/event ::ev/event {::ev/name "install-plugin" :name (:name plugin) :url plugin-url})) + (st/emit! (ev/event {::ev/name "install-plugin" :name (:name plugin) :url plugin-url})) (modal/show! :plugin-permissions {:plugin plugin :on-accept @@ -163,10 +162,10 @@ on-open-plugin (mf/use-fn (fn [manifest] - (st/emit! (ptk/event ::ev/event {::ev/name "start-plugin" - ::ev/origin "workspace:plugins" - :name (:name manifest) - :host (:host manifest)})) + (st/emit! (ev/event {::ev/name "start-plugin" + ::ev/origin "workspace:plugins" + :name (:name manifest) + :host (:host manifest)})) (dp/open-plugin! manifest user-can-edit?) (modal/hide!))) @@ -176,9 +175,9 @@ (fn [plugin-index] (let [plugins-list (preg/plugins-list) plugin (nth plugins-list plugin-index)] - (st/emit! (ptk/event ::ev/event {::ev/name "remove-plugin" - :name (:name plugin) - :host (:host plugin)})) + (st/emit! (ev/event {::ev/name "remove-plugin" + :name (:name plugin) + :host (:host plugin)})) (dp/close-plugin! plugin) (preg/remove-plugin! plugin) (reset! plugins-state* (preg/plugins-list)))))] @@ -212,7 +211,7 @@ (when-not (empty? plugins-state) [:> i18n/tr-html* {:class (stl/css :discover) - :on-click #(st/emit! (ptk/event ::ev/event {::ev/name "open-plugins-list"})) + :on-click #(st/emit! (ev/event {::ev/name "open-plugins-list"})) :content (tr "workspace.plugins.discover" cfg/plugins-list-uri)}]) [:hr] @@ -224,7 +223,7 @@ [:a {:class (stl/css :plugins-link) :href cfg/plugins-list-uri :target "_blank" - :on-click #(st/emit! (ptk/event ::ev/event {::ev/name "open-plugins-list"}))} + :on-click #(st/emit! (ev/event {::ev/name "open-plugins-list"}))} (tr "workspace.plugins.plugin-list-link") deprecated-icon/external-link]] [:* @@ -302,7 +301,20 @@ [:div {:class (stl/css :permissions-list-entry)} deprecated-icon/oauth-1 [:p {:class (stl/css :permissions-list-text)} - (tr "workspace.plugins.permissions.allow-localstorage")]])]) + (tr "workspace.plugins.permissions.allow-localstorage")]]) + + (cond + (contains? permissions "clipboard:write") + [:div {:class (stl/css :permissions-list-entry)} + deprecated-icon/oauth-1 + [:p {:class (stl/css :permissions-list-text)} + (tr "workspace.plugins.permissions.clipboard-write")]] + + (contains? permissions "clipboard:read") + [:div {:class (stl/css :permissions-list-entry)} + deprecated-icon/oauth-1 + [:p {:class (stl/css :permissions-list-text)} + (tr "workspace.plugins.permissions.clipboard-read")]])]) (mf/defc plugins-permissions-dialog {::mf/register modal/components @@ -384,9 +396,9 @@ (mf/deps on-accept) (fn [event] (dom/prevent-default event) - (st/emit! (ptk/event ::ev/event {::ev/name "allow-plugin-permissions" - :host host - :permissions (->> permissions (str/join ", "))}) + (st/emit! (ev/event {::ev/name "allow-plugin-permissions" + :host host + :permissions (->> permissions (str/join ", "))}) (modal/hide)) (when on-accept (on-accept)))) @@ -395,9 +407,9 @@ (mf/deps on-close) (fn [event] (dom/prevent-default event) - (st/emit! (ptk/event ::ev/event {::ev/name "reject-plugin-permissions" - :host host - :permissions (->> permissions (str/join ", "))}) + (st/emit! (ev/event {::ev/name "reject-plugin-permissions" + :host host + :permissions (->> permissions (str/join ", "))}) (modal/hide)) (when on-close (on-close))))] @@ -443,7 +455,7 @@ (mf/deps on-accept) (fn [event] (dom/prevent-default event) - (st/emit! (ptk/event ::ev/event {::ev/name "try-out-accept"}) + (st/emit! (ev/event {::ev/name "try-out-accept"}) (modal/hide)) (when on-accept (on-accept)))) @@ -452,7 +464,7 @@ (mf/deps on-close) (fn [event] (dom/prevent-default event) - (st/emit! (ptk/event ::ev/event {::ev/name "try-out-cancel"}) + (st/emit! (ev/event {::ev/name "try-out-cancel"}) (modal/hide)) (when on-close (on-close))))] diff --git a/frontend/src/app/main/ui/workspace/right_header.cljs b/frontend/src/app/main/ui/workspace/right_header.cljs index c719aae349..f4801f021d 100644 --- a/frontend/src/app/main/ui/workspace/right_header.cljs +++ b/frontend/src/app/main/ui/workspace/right_header.cljs @@ -9,8 +9,8 @@ (:require [app.main.data.common :as dcm] [app.main.data.event :as ev] - [app.main.data.modal :as modal] [app.main.data.shortcuts :as scd] + [app.main.data.team :as dtm] [app.main.data.workspace :as dw] [app.main.data.workspace.drawing.common :as dwc] [app.main.data.workspace.history :as dwh] @@ -186,9 +186,8 @@ (mf/use-fn (mf/deps team) (fn [] - (st/emit! (modal/show {:type :invite-members - :team team - :origin :workspace}))))] + (st/emit! (dtm/check-and-invite-members {:team-id (:id team) + :origin :workspace}))))] (mf/with-effect [editing?] (when ^boolean editing? diff --git a/frontend/src/app/main/ui/workspace/shapes.cljs b/frontend/src/app/main/ui/workspace/shapes.cljs index d1889e620e..26855718ef 100644 --- a/frontend/src/app/main/ui/workspace/shapes.cljs +++ b/frontend/src/app/main/ui/workspace/shapes.cljs @@ -60,10 +60,11 @@ {::mf/wrap [mf/memo] ::mf/wrap-props false} [props] - (let [objects (obj/get props "objects") - active-frames (obj/get props "active-frames") - shapes (cfh/get-immediate-children objects) - vbox (mf/use-ctx ctx/current-vbox) + (let [objects (obj/get props "objects") + active-frames (obj/get props "active-frames") + disable-thumbnails (obj/get props "disable-thumbnails") + shapes (cfh/get-immediate-children objects) + vbox (mf/use-ctx ctx/current-vbox) frame-overlap? (mf/with-memo [vbox objects] #(make-is-frame-overlap vbox objects)) @@ -80,58 +81,65 @@ (let [xform (comp (remove cfh/frame-shape?) (mapcat #(cfh/get-children-with-self objects (:id %))))] - [:& ff/fontfaces-style {:shapes (into [] xform shapes)}]) + [:> ff/fontfaces-style* {:shapes (into [] xform shapes)}]) [:g.frame-children (for [shape shapes] - [:g.ws-shape-wrapper {:key (dm/str (dm/get-prop shape :id))} - (if ^boolean (cfh/frame-shape? shape) - [:& root-frame-wrapper - {:shape shape - :objects objects - :thumbnail? (not (contains? active-frames (dm/get-prop shape :id)))}] - [:& shape-wrapper {:shape shape}])])]]])) + (let [thumbnail? + (and (not disable-thumbnails) + (contains? active-frames (dm/get-prop shape :id)))] + [:g.ws-shape-wrapper {:key (dm/str (dm/get-prop shape :id))} + (if ^boolean (cfh/frame-shape? shape) + [:& root-frame-wrapper + {:shape shape + :objects objects + :thumbnail? thumbnail?}] + [:& shape-wrapper {:shape shape}])]))]]])) + +(defn- render-shape-content + [shape thumbnail?] + (let [shape-type (dm/get-prop shape :type) + rawsvg? (= :svg-raw shape-type) + wrapper-elem (if ^boolean rawsvg? mf/Fragment "g") + wrapper-props (if ^boolean rawsvg? + #js {} + #js {:className "workspace-shape-wrapper"}) + props #js {:shape shape :thumbnail? thumbnail?}] + (when (and (some? shape) + (not ^boolean (:hidden shape))) + (mf/html + [:> wrapper-elem wrapper-props + (case shape-type + :path [:> path/path-wrapper* props] + :text [:> text/text-wrapper props] + :group [:> group-wrapper props] + :rect [:> rect-wrapper props] + :image [:> image-wrapper props] + :circle [:> circle-wrapper props] + :svg-raw [:> svg-raw-wrapper props] + :bool [:> bool-wrapper props] + :frame [:> nested-frame-wrapper props] + nil)])))) + +(mf/defc root-frame-shape-wrapper + {::mf/wrap [#(mf/memo' % common/check-shape-props)] + ::mf/wrap-props false} + [props] + (let [shape (unchecked-get props "shape") + shape-id (dm/get-prop shape :id) + active-frames (mf/use-ctx ctx/active-frames) + thumbnail? (and (some? active-frames) + (not (contains? active-frames shape-id)))] + (render-shape-content shape thumbnail?))) (mf/defc shape-wrapper {::mf/wrap [#(mf/memo' % common/check-shape-props)] ::mf/wrap-props false} [props] - (let [shape (unchecked-get props "shape") - shape-type (dm/get-prop shape :type) - shape-id (dm/get-prop shape :id) - - ;; FIXME: WARN: this breaks react rule of hooks (hooks can't be under conditional) - active-frames - (when (cfh/root-frame? shape) - (mf/use-ctx ctx/active-frames)) - - thumbnail? - (and (some? active-frames) - (not (contains? active-frames shape-id))) - - props #js {:shape shape :thumbnail? thumbnail?} - - rawsvg? (= :svg-raw shape-type) - wrapper-elem (if ^boolean rawsvg? mf/Fragment "g") - wrapper-props (if ^boolean rawsvg? - #js {} - #js {:className "workspace-shape-wrapper"})] - - (when (and (some? shape) - (not ^boolean (:hidden shape))) - [:> wrapper-elem wrapper-props - (case shape-type - :path [:> path/path-wrapper props] - :text [:> text/text-wrapper props] - :group [:> group-wrapper props] - :rect [:> rect-wrapper props] - :image [:> image-wrapper props] - :circle [:> circle-wrapper props] - :svg-raw [:> svg-raw-wrapper props] - :bool [:> bool-wrapper props] - :frame [:> nested-frame-wrapper props] - - nil)]))) + (let [shape (unchecked-get props "shape")] + (if ^boolean (cfh/root-frame? shape) + [:> root-frame-shape-wrapper props] + (render-shape-content shape false)))) (def group-wrapper (group/group-wrapper-factory shape-wrapper)) (def svg-raw-wrapper (svg-raw/svg-raw-wrapper-factory shape-wrapper)) diff --git a/frontend/src/app/main/ui/workspace/shapes/bool.cljs b/frontend/src/app/main/ui/workspace/shapes/bool.cljs index 536bd7fe68..db69d45585 100644 --- a/frontend/src/app/main/ui/workspace/shapes/bool.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/bool.cljs @@ -41,5 +41,5 @@ [:& bool-shape {:shape shape :childs childs}] (when *assert* - [:& wsd/shape-debug {:shape shape}])])))) + [:> wsd/shape-debug* {:shape shape}])])))) diff --git a/frontend/src/app/main/ui/workspace/shapes/common.cljs b/frontend/src/app/main/ui/workspace/shapes/common.cljs index e09092ca7f..43d3056d58 100644 --- a/frontend/src/app/main/ui/workspace/shapes/common.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/common.cljs @@ -25,4 +25,4 @@ [:> shape-container {:shape shape} [:& component {:shape shape}] (when *assert* - [:& wsd/shape-debug {:shape shape}])]))) + [:> wsd/shape-debug* {:shape shape}])]))) diff --git a/frontend/src/app/main/ui/workspace/shapes/debug.cljs b/frontend/src/app/main/ui/workspace/shapes/debug.cljs index a1a97e65dc..551baea09d 100644 --- a/frontend/src/app/main/ui/workspace/shapes/debug.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/debug.cljs @@ -23,7 +23,7 @@ [cuerdas.core :as str] [rumext.v2 :as mf])) -(mf/defc debug-bounding-boxes +(mf/defc debug-bounding-boxes* [{:keys [shape]}] (let [points (->> (:points shape) (map #(dm/fmt "%,%" (dm/get-prop % :x) (dm/get-prop % :y))) @@ -47,11 +47,9 @@ :stroke-width 1 :stroke color}]])) -(mf/defc debug-text-bounds - {::mf/wrap-props false} - [props] - (let [shape (unchecked-get props "shape") - zoom (mf/deref refs/selected-zoom) +(mf/defc debug-text-bounds* + [{:keys [shape]}] + (let [zoom (mf/deref refs/selected-zoom) bounding-box (gst/shape->rect shape) ctx (js* "document.createElement(\"canvas\").getContext(\"2d\")")] [:g {:transform (gsh/transform-str shape)} @@ -91,8 +89,7 @@ :style {:stroke "green" :stroke-width (/ 2 zoom)}}]]))])) -(mf/defc debug-bool-shape - {::mf/wrap-props false} +(mf/defc debug-bool-shape* [{:keys [shape]}] (let [objects (mf/deref refs/workspace-page-objects) @@ -172,17 +169,17 @@ (when hp [:circle {:data-i i :key (dm/str "c13-" i) :cx (:x hp) :cy (:y hp) :r radius :fill "green"}])]))])) -(mf/defc shape-debug +(mf/defc shape-debug* [{:keys [shape]}] [:* (when ^boolean (dbg/enabled? :bounding-boxes) - [:& debug-bounding-boxes {:shape shape}]) + [:> debug-bounding-boxes* {:shape shape}]) (when (and ^boolean (dbg/enabled? :bool-shapes) ^boolean (cfh/bool-shape? shape)) - [:& debug-bool-shape {:shape shape}]) + [:> debug-bool-shape* {:shape shape}]) (when (and ^boolean (dbg/enabled? :text-outline) ^boolean (cfh/text-shape? shape) ^boolean (seq (:position-data shape))) - [:& debug-text-bounds {:shape shape}])]) + [:> debug-text-bounds* {:shape shape}])]) diff --git a/frontend/src/app/main/ui/workspace/shapes/frame.cljs b/frontend/src/app/main/ui/workspace/shapes/frame.cljs index e6db4677a2..42af2a3ff0 100644 --- a/frontend/src/app/main/ui/workspace/shapes/frame.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/frame.cljs @@ -47,7 +47,7 @@ [:& shape-container {:shape shape :ref ref} [:& frame-shape {:shape shape :childs childs}] (when *assert* - [:& wsd/shape-debug {:shape shape}])])))) + [:> wsd/shape-debug* {:shape shape}])])))) (defn check-props [new-props old-props] @@ -230,5 +230,5 @@ [:& frame-shape {:shape shape :ref content-ref}]])] (when *assert* - [:& wsd/shape-debug {:shape shape}])])))) + [:> wsd/shape-debug* {:shape shape}])])))) diff --git a/frontend/src/app/main/ui/workspace/shapes/group.cljs b/frontend/src/app/main/ui/workspace/shapes/group.cljs index d98d58a2f7..4b0b073079 100644 --- a/frontend/src/app/main/ui/workspace/shapes/group.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/group.cljs @@ -18,11 +18,9 @@ [shape-wrapper] (let [group-shape (group/group-shape shape-wrapper)] (mf/fnc group-wrapper - {::mf/wrap [#(mf/memo' % check-shape-props)] - ::mf/wrap-props false} - [props] - (let [shape (unchecked-get props "shape") - shape-id (dm/get-prop shape :id) + {::mf/wrap [#(mf/memo' % check-shape-props)]} + [{:keys [shape]}] + (let [shape-id (dm/get-prop shape :id) childs* (mf/with-memo [shape-id] (refs/children-objects shape-id)) @@ -33,5 +31,4 @@ {:shape shape :childs childs}] (when *assert* - [:& wsd/shape-debug {:shape shape}])])))) - + [:> wsd/shape-debug* {:shape shape}])])))) diff --git a/frontend/src/app/main/ui/workspace/shapes/path.cljs b/frontend/src/app/main/ui/workspace/shapes/path.cljs index 0f8da115b3..8363e621a7 100644 --- a/frontend/src/app/main/ui/workspace/shapes/path.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/path.cljs @@ -26,8 +26,7 @@ (let [shape (update shape :content types.path/apply-content-modifiers content-modifiers)] (types.path/update-geometry shape))) -(mf/defc path-wrapper - {::mf/wrap-props false} +(mf/defc path-wrapper* [{:keys [shape]}] (let [shape-id (dm/get-prop shape :id) @@ -55,4 +54,4 @@ :pointer-events (when editing? "none")} [:& path/path-shape {:shape shape}] (when *assert* - [:& wsd/shape-debug {:shape shape}])])) + [:> wsd/shape-debug* {:shape shape}])])) diff --git a/frontend/src/app/main/ui/workspace/shapes/svg_raw.cljs b/frontend/src/app/main/ui/workspace/shapes/svg_raw.cljs index de1701e016..2eaa47ef4b 100644 --- a/frontend/src/app/main/ui/workspace/shapes/svg_raw.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/svg_raw.cljs @@ -29,7 +29,7 @@ [:& svg-raw-shape {:shape shape :childs childs}] (when *assert* - [:& wsd/shape-debug {:shape shape}])] + [:> wsd/shape-debug* {:shape shape}])] [:& svg-raw-shape {:shape shape :childs childs}]))))) diff --git a/frontend/src/app/main/ui/workspace/shapes/text.cljs b/frontend/src/app/main/ui/workspace/shapes/text.cljs index dfd2bcde83..601f5148fa 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text.cljs @@ -36,4 +36,4 @@ [:& text/text-shape {:shape shape}]] (when *assert* - [:& wsd/shape-debug {:shape shape}])])) + [:> wsd/shape-debug* {:shape shape}])])) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs index e81f841baf..b716336961 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs @@ -147,7 +147,13 @@ on-style-change (fn [event] - (let [styles (styles/get-styles-from-event event)] + (let [styles (styles/get-styles-from-event event) + fills (:fills styles) + fill-color (when (sequential? fills) (some :fill-color fills))] + ;; Dynamically update the caret color as the cursor moves between spans + (when-let [container-node (mf/ref-val container-ref)] + (dom/set-style! container-node "--text-editor-caret-color" + (or fill-color text-color))) (st/emit! (dwt/v2-update-text-editor-styles shape-id styles)))) on-needs-layout @@ -219,10 +225,18 @@ (= (:vertical-align content) "bottom")])) (defn get-color-from-content [content] - (let [fills (->> (tree-seq map? :children content) - (mapcat :fills) - (filter :fill-color))] - (some :fill-color fills))) + (let [nodes (tree-seq map? :children content) + get-color (fn [node] + ;; Handle both new format (:fills vector) and old/deprecated format + ;; (direct :fill-color on the content node — pre-fills-refactor files) + (or (some :fill-color (:fills node)) + (:fill-color node)))] + ;; Prefer inline (leaf) text nodes over paragraph nodes. The paragraph's :fills + ;; tracks the last-typed color, so using it directly would make the caret take + ;; the last span's color rather than the first visible span's color. + ;; Inline nodes have no :type; they are identified by the presence of :text. + (or (->> nodes (filter #(contains? % :text)) (some get-color)) + (->> nodes (some get-color))))) (defn get-default-text-color "Returns the appropriate text color based on fill, frame, and background." @@ -289,7 +303,9 @@ :ref container-ref :data-testid "text-editor-container" :style {:width "var(--editor-container-width)" - :height "var(--editor-container-height)"}} + :height "var(--editor-container-height)" + :min-width "var(--editor-container-min-width, 1px)" + :min-height "var(--editor-container-min-height, 1px)"}} ;; We hide the editor when is blurred because otherwise the ;; selection won't let us see the underlying text. Use opacity ;; because display or visibility won't allow to recover focus @@ -379,7 +395,7 @@ render-wasm? (mf/use-memo #(features/active-feature? @st/state "render-wasm/v1")) - [{:keys [x y width height]} transform] + [{:keys [x y width height selrect-width selrect-height]} transform] (if render-wasm? (let [{:keys [width height]} (wasm.api/get-text-dimensions shape-id) selrect-transform (mf/deref refs/workspace-selrect) @@ -401,7 +417,8 @@ "bottom" (+ y (- selrect-height height)) "center" (+ y (/ (- selrect-height height) 2)) y)] - [(assoc selrect :y y :width overlay-width :height max-height) transform]) + [(assoc selrect :y y :width overlay-width :height max-height + :selrect-width selrect-width :selrect-height selrect-height) transform]) (let [bounds (gst/shape->rect shape) x (mth/min (dm/get-prop bounds :x) @@ -418,14 +435,17 @@ (cond-> #js {:pointerEvents "all"} render-wasm? (obj/merge! - #js {"--editor-container-width" (dm/str width "px") - "--editor-container-height" (dm/str height "px") - "--fallback-families" (if (seq fallback-families) (dm/str (str/join ", " fallback-families)) "sourcesanspro")}) + #js {"--editor-container-width" "auto" + "--editor-container-height" "auto" + "--editor-container-min-width" (dm/str (max 1 selrect-width) "px") + "--editor-container-min-height" (dm/str (max 1 selrect-height) "px") + "--fallback-families" (if (seq fallback-families) (dm/str (str/join ", " fallback-families)) "sourcesanspro") + :display "flex"}) (not render-wasm?) (obj/merge! - #js {"--editor-container-width" (dm/str width "px") - "--editor-container-height" (dm/str height "px")}) + #js {"--editor-container-width" (dm/str (max 1 width) "px") + "--editor-container-height" (dm/str (max 1 height) "px")}) ;; Transform is necessary when there is a text overflow and the vertical ;; aligment is center or bottom. diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index 8a2138bb99..d4a51bac77 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -189,6 +189,13 @@ (sync-wasm-text-editor-content!) (wasm.api/request-render "text-delete-forward")) + ;; Insert + (= key "Insert") + (do + (dom/prevent-default event) + (text-editor/text-editor-toggle-overtype-mode) + (wasm.api/request-render "text-overtype-mode")) + ;; Arrow keys (= key "ArrowLeft") (do diff --git a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs index fda9e4e911..0fefa347a8 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs @@ -130,10 +130,10 @@ (when (some? node) (on-update shape node))))] - [:& html/text-shape {:key (str "shape-" (:id shape)) - :ref handle-update - :shape shape - :grow-type (:grow-type shape)}])) + [:> html/text-shape* {:key (str "shape-" (:id shape)) + :ref handle-update + :shape shape + :grow-type (:grow-type shape)}])) (defn text-properties-equal? [shape other] diff --git a/frontend/src/app/main/ui/workspace/sidebar.cljs b/frontend/src/app/main/ui/workspace/sidebar.cljs index ae43b60052..3ec4505a02 100644 --- a/frontend/src/app/main/ui/workspace/sidebar.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar.cljs @@ -42,8 +42,8 @@ [app.main.ui.workspace.sidebar.versions :refer [versions-toolbox*]] [app.main.ui.workspace.tokens.sidebar :refer [tokens-sidebar-tab*]] [app.util.debug :as dbg] + [app.util.dom :as dom] [app.util.i18n :refer [tr]] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) ;; --- Left Sidebar (Component) @@ -146,7 +146,7 @@ (fn [id] (st/emit! (dcm/go-to-workspace :layout (keyword id))) (when (= id "tokens") - (st/emit! (ptk/event ::ev/event {::ev/name "open-tokens-tab"}))))) + (st/emit! (ev/event {::ev/name "open-tokens-tab"}))))) tabs (mf/with-memo [mode-inspect? design-tokens?] @@ -183,6 +183,7 @@ :data-testid "left-sidebar" :data-width (str width) :class aside-class + :on-context-menu dom/prevent-default-context-menu :style {:--left-sidebar-width (dm/str width "px")}} [:> left-header* {:file file @@ -329,6 +330,7 @@ :id "right-sidebar-aside" :data-testid "right-sidebar" :data-size (str width) + :on-context-menu dom/prevent-default-context-menu :style {:--right-sidebar-width (if can-be-expanded? (dm/str width "px") (dm/str right-sidebar-default-width "px"))}} @@ -386,6 +388,10 @@ resolved-active-tokens (sd/use-resolved-tokens* active-tokens) + tokenscript-resolved-active-tokens + (mf/with-memo [active-tokens tokenscript?] + (when tokenscript? (ts/resolve-tokens active-tokens))) + tokenscript-resolved-active-tokens-force-set (mf/with-memo [active-tokens-force-set tokenscript?] (when tokenscript? (ts/resolve-tokens active-tokens-force-set))) @@ -412,4 +418,6 @@ :file-id file-id :page-id page-id :tokens-lib tokens-lib - :active-tokens resolved-active-tokens}]])) + :active-tokens (if tokenscript? + tokenscript-resolved-active-tokens + resolved-active-tokens)}]])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs index 206a16f8e0..72a709bfb5 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs @@ -29,7 +29,6 @@ (mf/defc assets-libraries* {::mf/wrap [mf/memo] - ::mf/props :obj ::mf/private true} [{:keys [filters]}] (let [file-id (mf/use-ctx ctx/current-file-id) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs index c0395cf4f4..0cf5d8ae98 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs @@ -11,7 +11,6 @@ [app.common.data.macros :as dm] [app.common.math :as mth] [app.common.path-names :as cpn] - [app.config :as cf] [app.main.constants :refer [max-input-length]] [app.main.data.event :as ev] [app.main.data.modal :as modal] @@ -29,11 +28,10 @@ [app.main.ui.workspace.sidebar.assets.groups :as grp] [app.util.color :as uc] [app.util.dom :as dom] - [app.util.i18n :as i18n :refer [tr]] + [app.util.i18n :refer [tr]] [app.util.keyboard :as kbd] [cuerdas.core :as str] [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (mf/defc color-item @@ -194,10 +192,10 @@ (mf/deps color on-asset-click read-only? file-id) (fn [event] (when-not read-only? - (st/emit! (ptk/data-event ::ev/event - {::ev/name "use-library-color" - ::ev/origin "sidebar" - :external-library (not local?)})) + (st/emit! (ev/event + {::ev/name "use-library-color" + ::ev/origin "sidebar" + :external-library (not local?)})) (when-not (on-asset-click event (:id color)) (st/emit! (dc/apply-color-from-assets file-id color (kbd/alt? event)))))))] @@ -261,8 +259,7 @@ {:name (tr "workspace.assets.edit") :id "assets-edit-color" :handler edit-color-clicked}) - (when (and (not (or multi-colors? multi-assets?)) - (contains? cf/flags :canary)) + (when-not (or multi-colors? multi-assets?) {:name (tr "workspace.assets.duplicate") :id "assets-duplicate-color" :handler duplicate-color}) @@ -280,7 +277,7 @@ (mf/defc colors-group [{:keys [file-id prefix groups open-groups force-open? local? selected multi-colors? multi-assets? on-asset-click on-assets-delete - on-clear-selection on-group on-rename-group on-ungroup colors + on-clear-selection on-group on-rename-group on-ungroup on-delete-group colors selected-full]}] (let [group-open? (if (false? (get open-groups prefix)) ;; if the user has closed it specifically, respect that false @@ -325,7 +322,8 @@ :path prefix :is-group-open group-open? :on-rename on-rename-group - :on-ungroup on-ungroup}] + :on-ungroup on-ungroup + :on-delete-group on-delete-group}] (when group-open? [:* (let [colors (get groups "" [])] @@ -378,6 +376,7 @@ :on-group on-group :on-rename-group on-rename-group :on-ungroup on-ungroup + :on-delete-group on-delete-group :colors colors :selected-full selected-full}]))])])) @@ -421,8 +420,8 @@ y-position (:top bounds)] (st/emit! (dw/set-assets-section-open file-id :colors true) - (ptk/event ::ev/event {::ev/name "add-asset-to-library" - :asset-type "color"}) + (ev/event {::ev/name "add-asset-to-library" + :asset-type "color"}) (modal/show :colorpicker {:x x-position :y y-position @@ -499,6 +498,13 @@ file-id)))) (st/emit! (dwu/commit-undo-transaction undo-id))))) + on-delete-group + (mf/with-memo [colors on-clear-selection] + (cmm/make-delete-asset-group-fn + {:assets colors + :on-clear-selection on-clear-selection + :delete-events #(map (fn [c] (dwl/delete-color {:id (:id c)})) %)})) + on-asset-click (mf/use-fn (mf/deps groups on-asset-click) (partial on-asset-click groups))] @@ -533,5 +539,6 @@ :on-group on-group :on-rename-group on-rename-group :on-ungroup on-ungroup + :on-delete-group on-delete-group :colors colors :selected-full selected-full}]]])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs index 14f494504d..419ab73531 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs @@ -198,6 +198,39 @@ (run! st/emit!)) (st/emit! (dwu/commit-undo-transaction undo-id)))) +(defn make-delete-asset-group-fn + "Build an `:on-delete-group` handler that filters `assets` by group + path, asks the user to confirm, and on accept emits every event + produced by `delete-events` inside one undo transaction. + + Options: + - `:assets` collection to filter. + - `:on-clear-selection` invoked before the deletes. + - `:delete-events` `(fn [matching-assets] => seq-of-events)`. + - `:path-filter` `(fn [asset-path group-path] => bool)` deciding + which assets fall under the group. Defaults to + `str/starts-with?`." + [{:keys [assets on-clear-selection delete-events path-filter] + :or {path-filter str/starts-with?}}] + (fn [path] + (let [matching (filter #(path-filter (:path %) path) assets) + undo-id (js/Symbol) + do-delete + (fn [] + (on-clear-selection) + (st/emit! (dwu/start-undo-transaction undo-id)) + (run! st/emit! (delete-events matching)) + (st/emit! (dwu/commit-undo-transaction undo-id)))] + (when (seq matching) + (st/emit! + (modal/show + {:type :confirm + :title (tr "modals.delete-asset-group.title") + :message (tr "modals.delete-asset-group.message" + (c (count matching))) + :accept-label (tr "labels.delete") + :on-accept do-delete})))))) + (defn on-drop-asset [event asset dragging* selected selected-full selected-paths rename] (let [create-typed-assets-group (partial create-assets-group rename)] @@ -291,14 +324,35 @@ current-page-id (mf/deref refs/current-page-id) thumbnail-requested? (mf/use-ref false) - thumbnail-uri* + object-id (mf/with-memo [file-id page-id root-id] - (let [object-id (thc/fmt-object-id file-id page-id root-id "component")] - (refs/workspace-thumbnail-by-id object-id))) + (thc/fmt-object-id file-id page-id root-id "component")) + + thumbnail-uri* + (mf/with-memo [object-id] + (refs/workspace-thumbnail-by-id object-id)) thumbnail-uri (mf/deref thumbnail-uri*) + rendered-at* + (mf/with-memo [object-id] + (refs/workspace-thumbnail-rendered-at object-id)) + + rendered-at + (mf/deref rendered-at*) + + modified-at + (some-> (:modified-at component) (.getTime)) + + ;; Stale if there's no in-session render record + ;; or the component was modified after the last render + stale? + (and (some? thumbnail-uri) + (or (nil? rendered-at) + (and (some? modified-at) + (> modified-at rendered-at)))) + on-error (mf/use-fn (mf/deps @retry) @@ -307,20 +361,21 @@ (inc retry))))] ;; Lazy WASM thumbnail rendering: when the component becomes - ;; visible, has no cached thumbnail, and lives on the current page - ;; trigger a render. Ref is used to avoid triggering multiple renders - ;; while the component is still not rendered and the thumbnail URI - ;; is not available. + ;; visible and either has no cached thumbnail or the cached one is + ;; stale relative to the last recorded edit, trigger a render. Ref + ;; is used to avoid triggering multiple renders while the previous + ;; render is in flight. (mf/use-effect - (mf/deps is-hidden thumbnail-uri wasm? current-page-id file-id page-id) + (mf/deps is-hidden thumbnail-uri stale? wasm? current-page-id file-id page-id) (fn [] - (if (some? thumbnail-uri) + (if (and (some? thumbnail-uri) (not stale?)) (mf/set-ref-val! thumbnail-requested? false) (when (and wasm? (not is-hidden) (not (mf/ref-val thumbnail-requested?)) (= page-id current-page-id)) (mf/set-ref-val! thumbnail-requested? true) (st/emit! (dwt.wasm/render-thumbnail file-id page-id root-id)))))) (if (and (some? thumbnail-uri) + (not stale?) (or (contains? cf/flags :component-thumbnails) wasm?)) [:& component-svg-thumbnail diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/components.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/components.cljs index 077742d71d..ca17118a7e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/components.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/components.cljs @@ -17,6 +17,7 @@ [app.main.data.workspace :as dw] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.media :as dwm] + [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.undo :as dwu] [app.main.data.workspace.variants :as dwv] [app.main.refs :as refs] @@ -32,10 +33,9 @@ [app.main.ui.workspace.sidebar.assets.groups :as grp] [app.util.dom :as dom] [app.util.dom.dnd :as dnd] - [app.util.i18n :as i18n :refer [tr]] + [app.util.i18n :refer [tr]] [cuerdas.core :as str] [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (def drag-data* (atom {:is-local false})) @@ -191,7 +191,7 @@ (mf/defc components-group* [{:keys [file-id prefix groups open-groups is-force-open renaming is-listing-thumbs selected on-asset-click - on-drag-start do-rename cancel-rename on-rename-group on-group on-ungroup on-context-menu + on-drag-start do-rename cancel-rename on-rename-group on-group on-ungroup on-delete-group on-context-menu selected-full is-local count-variants on-group-combine-variants]}] (let [group-open? (if (false? (get open-groups prefix)) ;; if the user has closed it specifically, respect that @@ -246,6 +246,7 @@ :is-can-combine can-combine? :on-rename on-rename-group :on-ungroup on-ungroup + :on-delete-group on-delete-group :on-group-combine-variants on-group-combine-variants}] (when group-open? @@ -303,6 +304,7 @@ :cancel-rename cancel-rename :on-rename-group on-rename-group :on-ungroup on-ungroup + :on-delete-group on-delete-group :on-context-menu on-context-menu :on-group-combine-variants on-group-combine-variants :selected-full selected-full @@ -373,8 +375,8 @@ (let [params {:file-id file-id :blobs (seq blobs)}] (st/emit! (dwm/upload-media-components params) - (ptk/event ::ev/event {::ev/name "add-asset-to-library" - :asset-type "components"}))))) + (ev/event {::ev/name "add-asset-to-library" + :asset-type "components"}))))) on-duplicate (mf/use-fn @@ -493,6 +495,33 @@ (map #(dwv/rename-comp-or-variant-and-main (:id %) (cmm/ungroup % path))))) (st/emit! (dwu/commit-undo-transaction undo-id))))) + on-delete-group + (mf/with-memo [components on-clear-selection] + (cmm/make-delete-asset-group-fn + {:assets components + :on-clear-selection on-clear-selection + :path-filter cpn/inside-path? + ;; Variants are handled via their variant container + ;; (matching the per-item delete dispatch in + ;; file_library.cljs); sibling variants sharing a + ;; container are deduplicated so we delete each container + ;; only once. + :delete-events + (fn [matching] + (let [{variants true non-variants false} + (group-by (comp boolean ctc/is-variant?) matching) + + variant-containers + (->> variants + (group-by :variant-id) + (map (fn [[_ comps]] (first comps))))] + (concat + (map #(dwsh/delete-shapes (:main-instance-page %) + #{(:variant-id %)}) + variant-containers) + (map #(dwl/delete-component {:id (:id %)}) + non-variants))))})) + on-group-combine-variants (mf/use-fn (mf/deps components on-clear-selection) @@ -602,6 +631,7 @@ :on-rename-group on-rename-group :on-group on-group :on-ungroup on-ungroup + :on-delete-group on-delete-group :on-group-combine-variants on-group-combine-variants :on-context-menu on-context-menu :selected-full selected-full diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.cljs index 0cb88cd0df..a308232239 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.cljs @@ -32,7 +32,6 @@ [app.util.keyboard :as kbd] [cuerdas.core :as str] [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -94,7 +93,7 @@ (mf/use-fn (fn [ev] (dom/stop-propagation ev) - (st/emit! (ptk/data-event ::ev/event {::ev/name "navigate-to-library-file"}))))] + (st/emit! (ev/event {::ev/name "navigate-to-library-file"}))))] [:div {:class (stl/css-case :library-title true diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs index 4a781fa48a..c7e72c871b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs @@ -23,7 +23,7 @@ [rumext.v2 :as mf])) (mf/defc asset-group-title* - [{:keys [file-id section path is-group-open on-rename on-ungroup on-group-combine-variants is-can-combine on-add]}] + [{:keys [file-id section path is-group-open on-rename on-ungroup on-delete-group on-group-combine-variants is-can-combine on-add]}] (when-not (empty? path) (let [[other-path last-path truncated] (cpn/compact-path path 35 true) menu-state (mf/use-state cmm/initial-context-menu-state) @@ -69,6 +69,12 @@ {:name (tr "workspace.assets.ungroup") :id "assets-ungroup-group" :handler #(on-ungroup path)}] + on-delete-group + (conj + {:name (tr "workspace.assets.delete-group") + :id "assets-delete-group" + :handler #(on-delete-group path)}) + is-can-combine (conj {:name (tr "workspace.shape.menu.combine-as-variants") diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs index 082fecb996..bc56af6891 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs @@ -10,7 +10,6 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.path-names :as cpn] - [app.config :as cf] [app.main.data.event :as ev] [app.main.data.modal :as modal] [app.main.data.workspace :as dw] @@ -26,10 +25,9 @@ [app.main.ui.workspace.sidebar.assets.groups :as grp] [app.main.ui.workspace.sidebar.options.menus.typography :refer [typography-entry]] [app.util.dom :as dom] - [app.util.i18n :as i18n :refer [tr]] + [app.util.i18n :refer [tr]] [cuerdas.core :as str] [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (def lens:typography-section-state @@ -98,10 +96,10 @@ (mf/deps typography on-asset-click read-only? local?) (fn [event] (when-not read-only? - (st/emit! (ptk/data-event ::ev/event - {::ev/name "use-library-typography" - ::ev/origin "sidebar" - :external-library (not local?)})) + (st/emit! (ev/event + {::ev/name "use-library-typography" + ::ev/origin "sidebar" + :external-library (not local?)})) (when-not (on-asset-click event (:id typography)) (st/emit! (dwt/apply-typography typography file-id))))))] @@ -134,7 +132,7 @@ {::mf/wrap-props false} [{:keys [file-id prefix groups open-groups force-open? file local? selected local-data editing-id renaming-id on-asset-click handle-change on-rename-group - on-ungroup on-context-menu selected-full is-read-only]}] + on-ungroup on-delete-group on-context-menu selected-full is-read-only]}] (let [group-open? (if (false? (get open-groups prefix)) ;; if the user has closed it specifically, respect that false (get open-groups prefix true)) @@ -185,6 +183,7 @@ :is-group-open group-open? :on-rename on-rename-group :on-ungroup on-ungroup + :on-delete-group on-delete-group :on-add (when (and local? (not is-read-only)) add-typography-to-group)}] @@ -238,6 +237,7 @@ :handle-change handle-change :on-rename-group on-rename-group :on-ungroup on-ungroup + :on-delete-group on-delete-group :on-context-menu on-context-menu :selected-full selected-full :is-read-only is-read-only}]))])])) @@ -352,6 +352,13 @@ (cmm/ungroup % path))))) (st/emit! (dwu/commit-undo-transaction undo-id))))) + on-delete-group + (mf/with-memo [typographies on-clear-selection] + (cmm/make-delete-asset-group-fn + {:assets typographies + :on-clear-selection on-clear-selection + :delete-events #(map (fn [t] (dwl/delete-typography (:id t))) %)})) + on-context-menu (mf/use-fn (mf/deps selected on-clear-selection read-only?) @@ -441,6 +448,7 @@ :handle-change handle-change :on-rename-group on-rename-group :on-ungroup on-ungroup + :on-delete-group on-delete-group :on-context-menu on-context-menu :selected-full selected-full :is-read-only read-only?}] @@ -459,8 +467,7 @@ :id "assets-edit-typography" :handler handle-edit-typography-clicked}) - (when (and (not (or multi-typographies? multi-assets?)) - (contains? cf/flags :canary)) + (when-not (or multi-typographies? multi-assets?) {:name (tr "workspace.assets.duplicate") :id "assets-duplicate-typography" :handler handle-duplicate-typography}) diff --git a/frontend/src/app/main/ui/workspace/sidebar/history.cljs b/frontend/src/app/main/ui/workspace/sidebar/history.cljs index 366fc1715e..eae0f0dcea 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/history.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/history.cljs @@ -9,6 +9,7 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.time :as ct] [app.main.data.workspace.undo :as dwu] [app.main.refs :as refs] [app.main.store :as st] @@ -172,6 +173,17 @@ (->> redo-changes (map parse-change))) +(defn- short-id + "Build a short git-like label for an undo entry. Derives it from the + entry's `:undo-group` uuid (which the undo subsystem already generates + for every action). Issue #7660." + [entry] + (when-let [group (:undo-group entry)] + (let [s (str group)] + (-> s + (str/replace #"[^0-9a-f]" "") + (subs 0 (min 7 (count s))))))) + (defn safe-name [maybe-keyword] (if (keyword? maybe-keyword) (name maybe-keyword) @@ -253,10 +265,17 @@ (assoc selected-entry :detail detail))) (defn parse-entries [entries objects] - (->> entries - (map parse-entry) - (map (resolve-shape-types entries objects)) - (mapv select-entry))) + ;; Propagate per-entry metadata (timestamp, undo-group, author) onto + ;; the summarized result so the UI can show when the action happened, + ;; a short stable identifier, and who made the change. Issue #7660. + (mapv (fn [raw-entry] + (-> (parse-entry raw-entry) + ((resolve-shape-types entries objects)) + (select-entry) + (assoc :timestamp (:timestamp raw-entry) + :undo-group (:undo-group raw-entry) + :by (:by raw-entry)))) + entries)) (mf/defc history-entry-details* [{:keys [entry]}] (let [{entries :items} (mf/deref workspace-undo) @@ -289,6 +308,11 @@ [{:keys [entry idx-entry disabled? current?]}] (let [hover? (mf/use-state false) show-detail? (mf/use-state false) + + relative-time (ct/timeago (:timestamp entry)) + label (short-id entry) + author (:by entry) + toggle-show-detail (mf/use-fn (fn [event] @@ -310,7 +334,28 @@ [:div {:class (stl/css :history-entry-summary)} [:div {:class (stl/css :history-entry-summary-icon)} (entry->icon entry)] - [:div {:class (stl/css :history-entry-summary-text)} (entry->message entry)] + [:div {:class (stl/css :history-entry-summary-text)} + [:div {:class (stl/css :history-entry-title)} + (entry->message entry)] + ;; Metadata row: short identifier, relative timestamp, and + ;; author. Rendered as plain inline text so the natural word + ;; spacing between tokens ("17bc430 · just now by ") is + ;; preserved without relying on flex gap. Issue #7660. + (when (or label relative-time author) + [:div {:class (stl/css :history-entry-meta)} + (when label + [:span {:class (stl/css :history-entry-hash) + :title (dm/str (:undo-group entry))} + label]) + (when (and label relative-time) + [:span {:class (stl/css :history-entry-meta-sep)} " · "]) + (when relative-time + [:span {:class (stl/css :history-entry-time)} + relative-time]) + (when (and (or label relative-time) author) " ") + (when author + [:span {:class (stl/css :history-entry-author)} + (tr "workspace.undo.entry.by" author)])])] (when (:detail entry) [:div {:class (stl/css-case :history-entry-summary-button true :button-opened @show-detail?) diff --git a/frontend/src/app/main/ui/workspace/sidebar/history.scss b/frontend/src/app/main/ui/workspace/sidebar/history.scss index 069d1d5d73..7af8eaad1d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/history.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/history.scss @@ -54,8 +54,49 @@ } .history-entry-summary-text { + display: flex; + flex-direction: column; + gap: deprecated.$s-2; margin: 0 deprecated.$s-8; color: var(--color-foreground-primary); + min-inline-size: 0; + } + + .history-entry-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + // Metadata row: short identifier, relative timestamp, and author. + // Plain inline layout so the literal spaces embedded in the + // template ("hash · time by Name") survive rendering. Issue #7660. + .history-entry-meta { + color: var(--color-foreground-secondary); + font-size: deprecated.$fs-10; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .history-entry-hash { + font-family: monospace; + letter-spacing: 0.03em; + } + + .history-entry-meta-sep { + opacity: 0.6; + } + + .history-entry-time { + white-space: nowrap; + } + + // Author ("by ") — the surrounding meta row already + // truncates, so keep the author span as a plain inline. Issue + // #7660. + .history-entry-author { + white-space: nowrap; } .history-entry-summary-button { diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs index b17698d659..8ba48062fb 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs @@ -16,7 +16,6 @@ [app.common.types.container :as ctn] [app.common.types.shape.layout :as ctl] [app.common.uuid :as uuid] - [app.config :as cf] [app.main.data.workspace :as dw] [app.main.data.workspace.collapse :as dwc] [app.main.refs :as refs] @@ -275,11 +274,19 @@ toggle-collapse (mf/use-fn - (mf/deps is-expanded) + (mf/deps is-expanded id objects) (fn [event] (dom/stop-propagation event) - (if (and is-expanded (kbd/shift? event)) + (cond + ;; Shift+click while expanded collapses every layer in the sidebar + (and is-expanded (kbd/shift? event)) (st/emit! (dwc/collapse-all)) + + ;; Alt+click while collapsed expands the entire subtree rooted at this id + (and (not is-expanded) (kbd/alt? event)) + (st/emit! (dwc/expand-subtree id objects)) + + :else (st/emit! (dwc/toggle-collapse id))))) toggle-blocking @@ -433,20 +440,19 @@ (mf/use-fn (mf/deps id objects) (fn [event] - (when (contains? cf/flags :canary) - (let [shift? (kbd/shift? event) - shape (get objects id) - parent (get objects (:parent-id shape)) - siblings (:shapes parent) - pos (d/index-of siblings id)] - (when (some? pos) - (let [;; Layers render in reverse: Tab (visually down) = dec index, - ;; Shift+Tab (visually up) = inc index - target-id (if shift? - (get siblings (inc pos)) - (get siblings (dec pos)))] - (when (some? target-id) - (st/emit! (dw/start-rename-shape target-id)))))))))] + (let [shift? (kbd/shift? event) + shape (get objects id) + parent (get objects (:parent-id shape)) + siblings (:shapes parent) + pos (d/index-of siblings id)] + (when (some? pos) + (let [;; Layers render in reverse: Tab (visually down) = dec index, + ;; Shift+Tab (visually up) = inc index + target-id (if shift? + (get siblings (inc pos)) + (get siblings (dec pos)))] + (when (some? target-id) + (st/emit! (dw/start-rename-shape target-id))))))))] (mf/with-effect [is-selected selected] (let [single? (= (count selected) 1) diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs b/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs index 5c0f181c1d..181ea70d47 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs @@ -38,7 +38,7 @@ shape-name) default-value - (mf/with-memo [variant-id variant-error variant-properties] + (mf/with-memo [variant-id variant-error variant-properties shape-name] (if variant-id (or variant-error (ctv/properties-map->formula variant-properties)) shape-name)) diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs index 7d41d982c1..df02e1d0d9 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs @@ -115,7 +115,8 @@ result)) result)))] - [:div {:class (stl/css :element-list) :data-testid "layer-item"} + [:div {:class (stl/css :element-list) + :data-testid "layer-item"} [:> hooks/sortable-container* {} (for [obj shapes] (if (cfh/frame-shape? obj) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options.cljs b/frontend/src/app/main/ui/workspace/sidebar/options.cljs index c7413af9ac..59c1afa0f1 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options.cljs @@ -73,7 +73,7 @@ nil))) (mf/defc shape-options* - {::mf/wrap [#(mf/throttle % 100)] + {::mf/wrap [#(mf/throttle % 200)] ::mf/private true} [{:keys [shapes shapes-with-children selected page-id file-id libraries]}] (if (= 1 (count selected)) @@ -219,6 +219,7 @@ [{:keys [objects selected page-id file-id on-change-section on-expand]}] (let [permissions (mf/use-ctx ctx/permissions) + render-context-lost? (mf/deref refs/render-context-lost?) options-mode (mf/deref refs/options-mode-global) @@ -228,7 +229,7 @@ (sequence (keep (d/getf objects)) selected))] [:div {:class (stl/css :tool-window)} - (if (:can-edit permissions) + (if (and (:can-edit permissions) (not render-context-lost?)) [:> tab-switcher* {:tabs options-tabs :on-change on-option-tab-change :selected (name options-mode) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs index 275ad11e8d..fa40189182 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs @@ -13,8 +13,10 @@ [app.main.store :as st] [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]] + [app.main.ui.components.search-bar :refer [search-bar*]] [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.icons :as deprecated-icon] + [app.main.ui.workspace.sidebar.options.menus.measures :as measures] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [rumext.v2 :as mf])) @@ -31,11 +33,35 @@ selected-preset-name (deref selected-preset-name*) - on-open - (mf/use-fn (fn [] (reset! show* true))) + search-term* + (mf/use-state "") + + search-term + (deref search-term*) + + container-ref + (mf/use-ref nil) + + on-toggle + (mf/use-fn + (fn [] + (swap! show* not) + (reset! search-term* ""))) on-close - (mf/use-fn (fn [] (reset! show* false))) + (mf/use-fn + (fn [] + (reset! show* false) + (reset! search-term* ""))) + + on-search-change + (mf/use-fn + (fn [value _event] + (reset! search-term* value))) + + filtered-presets + (mf/with-memo [search-term] + (measures/filter-size-presets search-term size-presets)) on-preset-selected (mf/use-fn @@ -48,7 +74,9 @@ (d/read-string))] (reset! selected-preset-name* name) - (st/emit! (dwd/set-default-size width height))))) + (st/emit! (dwd/set-default-size width height)) + (reset! show* false) + (reset! search-term* "")))) orientation (when (:width drawing-state) @@ -65,35 +93,49 @@ [:div {:class (stl/css :presets)} [:div {:class (stl/css-case :presets-wrapper true :opened show?) - :on-click on-open} + :ref container-ref + :on-click on-toggle} [:span {:class (stl/css :select-name)} (or selected-preset-name (tr "workspace.options.size-presets"))] [:span {:class (stl/css :collapsed-icon)} deprecated-icon/arrow] [:& dropdown {:show show? - :on-close on-close} - [:ul {:class (stl/css :custom-select-dropdown)} - (for [preset size-presets] - (if-not (:width preset) - [:li {:key (:name preset) - :class (stl/css-case :dropdown-element true - :disabled true)} - [:span {:class (stl/css :preset-name)} (:name preset)]] + :on-close on-close + :container container-ref} + [:div {:class (stl/css :custom-select-dropdown) + :on-click dom/stop-propagation} + [:div {:class (stl/css :preset-search)} + [:> search-bar* {:on-change on-search-change + :value search-term + :auto-focus true + :placeholder (tr "workspace.options.search-size-preset")}]] + [:ul {:class (stl/css :preset-list)} + (if (empty? filtered-presets) + [:li {:class (stl/css-case :dropdown-element true + :disabled true)} + [:span {:class (stl/css :preset-name)} + (tr "workspace.options.no-size-preset-results")]] + (for [preset filtered-presets] + (if-not (:width preset) + [:li {:key (:name preset) + :class (stl/css-case :dropdown-element true + :disabled true)} + [:span {:class (stl/css :preset-name)} (:name preset)]] - (let [preset-match (and (= (:width preset) (:width drawing-state)) - (= (:height preset) (:height drawing-state)))] - [:li {:key (:name preset) - :class (stl/css-case :dropdown-element true - :match preset-match) - :data-width (str (:width preset)) - :data-height (str (:height preset)) - :data-name (:name preset) - :on-click on-preset-selected} - [:div {:class (stl/css :name-wrapper)} - [:span {:class (stl/css :preset-name)} (:name preset)] - [:span {:class (stl/css :preset-size)} (:width preset) " x " (:height preset)]] - (when preset-match - [:span {:class (stl/css :check-icon)} deprecated-icon/tick])])))]]] + (let [preset-match (and (= (:width preset) (:width drawing-state)) + (= (:height preset) (:height drawing-state)))] + [:li {:key (:name preset) + :class (stl/css-case :dropdown-element true + :match preset-match) + :data-width (str (:width preset)) + :data-height (str (:height preset)) + :data-name (:name preset) + :on-click on-preset-selected} + [:div {:class (stl/css :name-wrapper)} + [:span {:class (stl/css :preset-name)} (:name preset)] + [:span {:class (stl/css :preset-size)} (:width preset) " x " (:height preset)]] + (when preset-match + [:span {:class (stl/css :check-icon)} deprecated-icon/tick])]))))]]]] [:& radio-buttons {:selected (or (d/name orientation) "") :on-change on-orientation-change diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.scss b/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.scss index 1599bcad25..53221cd2a5 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.scss @@ -64,6 +64,23 @@ margin-top: deprecated.$s-2; max-height: 70vh; width: deprecated.$s-252; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.preset-search { + padding: deprecated.$s-4; + border-bottom: deprecated.$s-1 solid var(--menu-border-color-rest, transparent); +} + +.preset-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + margin: 0; + padding: 0; + list-style: none; .dropdown-element { @extend %dropdown-element-base; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs index cbc9e6c31a..0145c0bc30 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs @@ -49,16 +49,16 @@ • :prop → the property type (:fill, :stroke, :shadow, etc.) • :shape-id → the UUID of the shape using this color • :index → index of the color in the shape's fill/stroke list - + Example of groups: { {:color \"#9f2929\", :opacity 0.3, :token-name \"asd2\" :has-token-applied true} [{:prop :fill, :shape-id #uuid \"d0231035-25c9-80d5-8006-eae4c3dff32e\", :index 0}] - + {:color \"#1b54b6\", :opacity 1} [{:prop :fill, :shape-id #uuid \"aab34f9a-98c1-801a-8006-eae5e8236f1b\", :index 0}] } - + This structure allows fast lookups of all shapes using the same visual color, regardless of whether it comes from local fills, strokes or shadow-colors." @@ -69,12 +69,11 @@ ;; Unique color attribute maps all-colors (distinct (mapv :attrs data)) - ;; Split into: library colors, token colors, and plain colors - library-colors (filterv :ref-id all-colors) + ;; Split into mutually exclusive groups: + ;; token-colors take priority; library-colors and plain colors exclude tokens token-colors (filterv :token-name all-colors) - colors (filterv #(and (nil? (:ref-id %)) - (not (:token-name %))) - all-colors)] + library-colors (filterv (fn [c] (and (some? (:ref-id c)) (nil? (:token-name c)))) all-colors) + colors (filterv (fn [c] (and (nil? (:ref-id c)) (nil? (:token-name c)))) all-colors)] {:groups groups :all-colors all-colors :colors colors @@ -91,7 +90,7 @@ (d/without-nils)) prev-color (d/seek (partial get groups) prev-colors) color-operations-old (get groups old-color) - color-operations-prev (get groups prev-colors) + color-operations-prev (get groups prev-color) color-operations (or color-operations-prev color-operations-old) old-color (or prev-color old-color)] [color-operations old-color])) @@ -107,21 +106,26 @@ open? (deref open*) has-colors? (or (some? (seq colors)) (some? (seq library-colors))) - toggle-content (mf/use-fn #(swap! open* not)) - expand-lib-color (mf/use-state false) - expand-color (mf/use-state false) - expand-token-color (mf/use-state false) + expand-lib-color (mf/use-state false) + expand-color (mf/use-state false) + expand-token-color (mf/use-state false) ;; TODO: Review if this is still necessary. prev-colors-ref (mf/use-ref nil) + ;; Always keep this ref pointing to the latest groups so that on-change + ;; (which may be captured stale by the colorpicker's rx subscription) can + ;; still read the current groups and find the correct color operations. + groups-ref (mf/use-ref nil) + _ (mf/set-ref-val! groups-ref groups) + on-change (mf/use-fn - (mf/deps groups) (fn [old-color new-color from-picker?] - (let [prev-colors (mf/ref-val prev-colors-ref) + (let [groups (mf/ref-val groups-ref) + prev-colors (mf/ref-val prev-colors-ref) [color-operations old-color] (retrieve-color-operations groups old-color prev-colors)] ;; TODO: Review if this is still necessary. @@ -243,8 +247,7 @@ [:div {:class (stl/css :selected-color-group)} (let [token-color-extract (cond->> token-colors (not @expand-token-color) (take 3))] (for [[index token-color] (d/enumerate token-color-extract)] - (let [color {:color (:color token-color) - :opacity (:opacity token-color)}] + (let [color (dissoc token-color :token-name :has-token-applied)] [:> color-row* {:key index :color color diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs index 529eee6b36..c420053c43 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs @@ -194,7 +194,8 @@ (dom/set-attribute! checkbox "indeterminate" true) (dom/remove-attribute! checkbox "indeterminate")))) - [:section {:class (stl/css :fill-section) :aria-label (tr "workspace.options.fill.section")} + [:section {:class (stl/css :fill-section) + :aria-label (tr "workspace.options.fill.section")} [:div {:class (stl/css :fill-title)} [:> title-bar* {:collapsable has-fills? :collapsed (not open?) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs index c1a54d9764..7b5995fba5 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs @@ -450,7 +450,9 @@ [:div {:class (stl/css :interaction-row-select)} [:& select {:default-value (str (:destination interaction)) :options destination-options - :on-change change-destination}]]]) + :on-change change-destination + :searchable? true + :search-placeholder (tr "workspace.options.interaction-destination")}]]]) ;; Preserve scroll (when (ctsi/has-preserve-scroll interaction) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs index 41ec031902..111f3757fc 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs @@ -125,9 +125,14 @@ handle-blend-mode-leave (mf/use-fn - (mf/deps ids) + (mf/deps ids current-blend-mode wasm-renderer-enabled?) (fn [_value] (swap! state* assoc :preview-complete? true) + (when wasm-renderer-enabled? + (doseq [id ids] + (wasm.api/use-shape id) + (wasm.api/set-shape-blend-mode current-blend-mode) + (wasm.api/request-render "preview-blend-mode"))) (st/emit! (dw/unset-preview-blend-mode ids)))) handle-opacity-change diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs index dd992ce08b..08e1774904 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs @@ -1377,6 +1377,7 @@ [:div {:class (stl/css :padding-row)} [:> padding-section* {:value (:layout-padding values) :type (:layout-padding-type values) + :ids ids :applied-tokens applied-tokens :on-type-change on-padding-type-change :on-change on-padding-change}]]] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs index c8e43e7f1c..e98e294810 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs @@ -147,6 +147,7 @@ :icon i/margin-top-bottom :min 0 :attr :m1 + :default nil :input-type :vertical-margin :property "Vertical margin " :nillable true @@ -178,6 +179,7 @@ :min 0 :attr :m2 :align :right + :default nil :input-type :horizontal-margin :property "Horizontal margin" :nillable true @@ -270,6 +272,7 @@ :icon i/margin-top :class (stl/css :top-margin-wrapper) :min 0 + :default nil :attr :m1 :input-type :vertical-margin :property "Top margin" @@ -298,6 +301,7 @@ :icon i/margin-right :class (stl/css :right-margin-wrapper) :min 0 + :default nil :attr :m2 :align :right :input-type :horizontal-margin @@ -329,6 +333,7 @@ :class (stl/css :bottom-margin-wrapper) :min 0 :attr :m3 + :default nil :align :right :input-type :vertical-margin :property "Bottom margin" @@ -358,6 +363,7 @@ :icon i/margin-left :class (stl/css :left-margin-wrapper) :min 0 + :default nil :attr :m4 :property "Left margin" :input-type :horizontal-margin diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs index ef9936d90e..b29d3fd47b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs @@ -26,6 +26,7 @@ [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.components.numeric-input :as deprecated-input] [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]] + [app.main.ui.components.search-bar :refer [search-bar*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.icons :as deprecated-icon] @@ -34,6 +35,7 @@ [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [clojure.set :as set] + [cuerdas.core :as str] [rumext.v2 :as mf])) (def measure-attrs @@ -105,6 +107,29 @@ (number? value) (parse-double (.toFixed value decimals))))) +(defn filter-size-presets + "Filter the `size-presets` list by `term`, preserving category headers only + when at least one of their following presets matches." + [term presets] + (if (str/blank? term) + presets + (let [lterm (str/lower term) + matches? (fn [p] (and (:width p) + (str/includes? (str/lower (:name p)) lterm)))] + (loop [remaining presets + acc []] + (if-let [head (first remaining)] + (if (:width head) + (recur (rest remaining) + (cond-> acc (matches? head) (conj head))) + (let [[items tail] (split-with :width (rest remaining)) + matching-items (filter matches? items)] + (recur tail + (if (seq matching-items) + (into (conj acc head) matching-items) + acc)))) + acc))))) + (mf/defc measures-menu* [{:keys [ids values applied-tokens type shapes]}] (let [token-numeric-inputs @@ -235,17 +260,36 @@ show-presets-dropdown? (deref preset-state*) - open-presets + preset-search-term* + (mf/use-state "") + + preset-search-term + (deref preset-search-term*) + + preset-container-ref + (mf/use-ref nil) + + toggle-presets (mf/use-fn - (mf/deps show-presets-dropdown?) (fn [] - (reset! preset-state* true))) + (swap! preset-state* not) + (reset! preset-search-term* ""))) close-presets (mf/use-fn (mf/deps show-presets-dropdown?) (fn [] - (reset! preset-state* false))) + (reset! preset-state* false) + (reset! preset-search-term* ""))) + + on-preset-search-change + (mf/use-fn + (fn [value _event] + (reset! preset-search-term* value))) + + filtered-size-presets + (mf/with-memo [preset-search-term] + (filter-size-presets preset-search-term size-presets)) on-preset-selected (mf/use-fn @@ -258,7 +302,9 @@ (dom/get-data "height") (d/read-string))] (st/emit! (udw/update-dimensions ids :width width) - (udw/update-dimensions ids :height height))))) + (udw/update-dimensions ids :height height)) + (reset! preset-state* false) + (reset! preset-search-term* "")))) ;; ORIENTATION @@ -379,33 +425,47 @@ [:div {:class (stl/css :presets)} [:div {:class (stl/css-case :presets-wrapper true :opened show-presets-dropdown?) - :on-click open-presets} + :ref preset-container-ref + :on-click toggle-presets} [:span {:class (stl/css :select-name)} (tr "workspace.options.size-presets")] [:span {:class (stl/css :collapsed-icon)} deprecated-icon/arrow] [:& dropdown {:show show-presets-dropdown? - :on-close close-presets} - [:ul {:class (stl/css :custom-select-dropdown)} - (for [size-preset size-presets] - (if-not (:width size-preset) - [:li {:key (:name size-preset) - :class (stl/css-case :dropdown-element true - :disabled true)} - [:span {:class (stl/css :preset-name)} (:name size-preset)]] + :on-close close-presets + :container preset-container-ref} + [:div {:class (stl/css :custom-select-dropdown) + :on-click dom/stop-propagation} + [:div {:class (stl/css :preset-search)} + [:> search-bar* {:on-change on-preset-search-change + :value preset-search-term + :auto-focus true + :placeholder (tr "workspace.options.search-size-preset")}]] + [:ul {:class (stl/css :preset-list)} + (if (empty? filtered-size-presets) + [:li {:class (stl/css-case :dropdown-element true + :disabled true)} + [:span {:class (stl/css :preset-name)} + (tr "workspace.options.no-size-preset-results")]] + (for [size-preset filtered-size-presets] + (if-not (:width size-preset) + [:li {:key (:name size-preset) + :class (stl/css-case :dropdown-element true + :disabled true)} + [:span {:class (stl/css :preset-name)} (:name size-preset)]] - (let [preset-match (and (= (:width size-preset) (d/parse-integer (:width values) 0)) - (= (:height size-preset) (d/parse-integer (:height values) 0)))] - [:li {:key (:name size-preset) - :class (stl/css-case :dropdown-element true - :match preset-match) - :data-width (str (:width size-preset)) - :data-height (str (:height size-preset)) - :on-click on-preset-selected} - [:div {:class (stl/css :name-wrapper)} - [:span {:class (stl/css :preset-name)} (:name size-preset)] - [:span {:class (stl/css :preset-size)} (:width size-preset) " x " (:height size-preset)]] - (when preset-match - [:span {:class (stl/css :check-icon)} deprecated-icon/tick])])))]]] + (let [preset-match (and (= (:width size-preset) (d/parse-integer (:width values) 0)) + (= (:height size-preset) (d/parse-integer (:height values) 0)))] + [:li {:key (:name size-preset) + :class (stl/css-case :dropdown-element true + :match preset-match) + :data-width (str (:width size-preset)) + :data-height (str (:height size-preset)) + :on-click on-preset-selected} + [:div {:class (stl/css :name-wrapper)} + [:span {:class (stl/css :preset-name)} (:name size-preset)] + [:span {:class (stl/css :preset-size)} (:width size-preset) " x " (:height size-preset)]] + (when preset-match + [:span {:class (stl/css :check-icon)} deprecated-icon/tick])]))))]]]] [:& radio-buttons {:selected (or (d/name orientation) "") :on-change on-orientation-change @@ -559,8 +619,7 @@ :value (get values :rotation)}] [:div {:class (stl/css :rotation) - :title (tr "workspace.options.rotation") - :data-testid "rotation"} + :title (tr "workspace.options.rotation")} [:span {:class (stl/css :icon)} deprecated-icon/rotation] [:> deprecated-input/numeric-input* {:no-validate true diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.scss index e3605152d8..357df42145 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.scss @@ -75,6 +75,23 @@ margin-top: deprecated.$s-2; max-height: 70vh; width: deprecated.$s-252; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.preset-search { + padding: deprecated.$s-4; + border-bottom: deprecated.$s-1 solid var(--menu-border-color-rest, transparent); +} + +.preset-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + margin: 0; + padding: 0; + list-style: none; .dropdown-element { @extend %dropdown-element-base; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs index 154be987f7..5748251fd6 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs @@ -30,6 +30,8 @@ :stroke-style :stroke-alignment :stroke-width + :stroke-dash + :stroke-gap :stroke-color :stroke-color-ref-id :stroke-color-ref-file @@ -113,6 +115,18 @@ (st/emit! (udw/trigger-bounding-box-cloaking ids)) (st/emit! (dc/change-stroke-attrs ids {:stroke-width value} index)))) + on-stroke-dash-change + (fn [index value] + (when-not (str/empty? value) + (st/emit! (udw/trigger-bounding-box-cloaking ids)) + (st/emit! (dc/change-stroke-attrs ids {:stroke-dash value} index)))) + + on-stroke-gap-change + (fn [index value] + (when-not (str/empty? value) + (st/emit! (udw/trigger-bounding-box-cloaking ids)) + (st/emit! (dc/change-stroke-attrs ids {:stroke-gap value} index)))) + open-caps-select (fn [caps-state] (fn [event] @@ -186,7 +200,7 @@ :shape-ids ids}))))] [:section {:class (stl/css :stroke-section) - :aria-label "stroke-section"} + :aria-label "Stroke section"} [:div {:class (stl/css :stroke-title)} [:> title-bar* {:collapsable has-strokes? :collapsed (not open?) @@ -203,7 +217,9 @@ [:div {:class (stl/css-case :stroke-content true :stroke-content-empty (not has-strokes?))} (cond - (= :multiple strokes) + (or (= :multiple (:stroke-color applied-tokens)) + (= :multiple (:stroke-width applied-tokens)) + (= :multiple strokes)) [:div {:class (stl/css :stroke-multiple)} [:div {:class (stl/css :stroke-multiple-label)} (tr "settings.multiple")] @@ -224,6 +240,8 @@ :on-color-change on-color-change :on-color-detach on-color-detach :on-stroke-width-change on-stroke-width-change + :on-stroke-dash-change on-stroke-dash-change + :on-stroke-gap-change on-stroke-gap-change :on-stroke-style-change on-stroke-style-change :on-stroke-alignment-change on-stroke-alignment-change :open-caps-select open-caps-select diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs index 0c388627e0..7b762f68af 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs @@ -30,7 +30,7 @@ [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.hooks :as hooks] [app.main.ui.workspace.sidebar.options.menus.token-typography-row :refer [token-typography-row*]] - [app.main.ui.workspace.sidebar.options.menus.typography :refer [text-options typography-entry]] + [app.main.ui.workspace.sidebar.options.menus.typography :refer [text-options* typography-entry]] [app.main.ui.workspace.tokens.management.forms.controls.utils :as csu] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] @@ -292,7 +292,7 @@ (csu/get-token-dropdown-options typography-tokens nil)) selected-token-id* - (mf/use-state #(when current-token-name + (mf/use-state #(when (and (not= :multiple current-token-name) current-token-name) (:id (get-option-by-name dropdown-options current-token-name)))) selected-token-id (deref selected-token-id*) @@ -411,11 +411,19 @@ detach-token (mf/use-fn + (mf/deps ids) (fn [token-name] (st/emit! (dwta/unapply-token {:token-name token-name :attributes #{:typography} :shape-ids ids})))) + handle-detach-all-tokens + (mf/use-fn + (mf/deps ids) + (fn [] + (st/emit! (dwta/unapply-multiple-tokens {:attributes #{:typography} + :shape-ids ids})))) + expand-stream (mf/with-memo [] (->> st/stream (rx/filter (ptk/type? :expand-text-more-options)))) @@ -429,10 +437,12 @@ (when (not= "INPUT" (-> (dom/get-active) dom/get-tag-name)) (dom/focus! (txu/get-text-editor-content))))))) - common-props - (mf/props {:values values - :on-change on-change - :on-blur on-text-blur})] + common-props (mf/props + {:ids ids + :values values + :on-change on-change + :show-recent true + :on-blur on-text-blur})] (hooks/use-stream expand-stream @@ -443,7 +453,7 @@ (mf/with-effect [applied-token-name dropdown-options] (reset! selected-token-id* - (when applied-token-name + (when (and (not= :multiple applied-token-name) applied-token-name) (:id (get-option-by-name dropdown-options applied-token-name))))) (mf/with-effect [token-dropdown-open?] @@ -475,11 +485,36 @@ (when main-menu-open? [:div {:class (stl/css :element-content)} (cond + (and token-typography-row-enabled? (= :multiple current-token-name) (= typography-id :multiple)) + [:div {:class (stl/css :multiple-typography)} + [:span {:class (stl/css :multiple-text)} + (tr "workspace.libraries.text.mixed-tokens-and-assets")]] + + (and token-typography-row-enabled? (= :multiple current-token-name)) + [:div {:class (stl/css :multiple-typography)} + [:span {:class (stl/css :multiple-text)} + (tr "workspace.libraries.text.mixed-tokens")] + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.libraries.text.multiple-token-tooltip") + :tooltip-placement "top-left" + :on-click handle-detach-all-tokens + :icon i/detach}]] + (and token-typography-row-enabled? current-token-name) [:> token-typography-row* {:token-name current-token-name :detach-token detach-token :active-tokens (resolve-delay typography-tokens)}] + (= typography-id :multiple) + [:div {:class (stl/css :multiple-typography)} + [:span {:class (stl/css :multiple-text)} + (tr "workspace.libraries.text.mixed-typography")] + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.libraries.text.multiple-assets-tooltip") + :on-click handle-detach-typography + :tooltip-placement "top-left" + :icon i/detach}]] + typography [:& typography-entry {:file-id typography-file-id :typography typography @@ -487,24 +522,14 @@ :on-detach handle-detach-typography :on-change handle-change-typography}] - (= typography-id :multiple) - [:div {:class (stl/css :multiple-typography)} - [:span {:class (stl/css :multiple-text)} (tr "workspace.libraries.text.multiple-typography")] - [:> icon-button* {:variant "ghost" - :aria-label (tr "workspace.libraries.text.multiple-typography-tooltip") - :on-click handle-detach-typography - :icon i/detach}]] + :else - [:> text-options #js {:ids ids - :values values - :on-change on-change - :show-recent true - :on-blur on-text-blur}]) + [:> text-options* common-props]) [:div {:class (stl/css :text-align-options)} [:> text-align-options* common-props] - [:> grow-options* (mf/spread-props common-props {:ids ids})] + [:> grow-options* common-props] [:> icon-button* {:variant "ghost" :aria-label (tr "labels.options") :data-testid "text-align-options-button" @@ -523,4 +548,5 @@ :options (resolve-delay dropdown-options) :selected selected-token-id :align "right" + :placeholder (tr "workspace.tokens.search-by-token") :ref set-option-ref}])])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss index e589c4dd79..5650b0c68b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss @@ -4,8 +4,13 @@ // // Copyright (c) KALEIDOS INC -@use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; +@use "ds/typography.scss" as t; +@use "ds/_utils.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/spacing.scss" as *; +@use "ds/mixins.scss" as *; .element-set { @include sidebar.option-grid-structure; @@ -18,37 +23,37 @@ } .element-content { - @include deprecated.flex-column; - + display: flex; + flex-direction: column; + gap: var(--sp-xs); grid-column: span 8; - margin-top: deprecated.$s-4; + margin-block-start: var(--sp-xs); } .multiple-typography { - @extend %mixed-bar; + @include t.use-typography("body-small"); + + display: flex; + align-items: center; + flex-grow: 1; + border-radius: $br-8; + block-size: $sz-32; + padding-block: var(--sp-s); + padding-inline: var(--sp-s) 0; + background-color: var(--color-background-tertiary); + color: var(--color-foreground-primary); } .multiple-text { - @include deprecated.body-small-typography; + @include t.use-typography("body-small"); flex-grow: 1; - color: var(--input-foreground-color-active); -} - -.multiple-typography-button { - @extend %button-tertiary; - - height: deprecated.$s-32; - width: deprecated.$s-28; - - svg { - @extend %button-icon; - } + color: var(--color-foreground-secondary); } .text-align-options { display: flex; - gap: deprecated.$s-4; + gap: var(--sp-xs); } .align-options, @@ -56,10 +61,10 @@ .vertical-align-options, .grow-options, .text-decoration-options { - height: deprecated.$s-32; + block-size: $sz-32; } .text-decoration-options { display: flex; - gap: deprecated.$s-4; + gap: var(--sp-xs); } diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.cljs index 9d9944a1d2..1b3015afab 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.cljs @@ -10,28 +10,12 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.controls.shared.token-option :as to] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.tooltip :refer [tooltip*]] [app.util.i18n :as i18n :refer [tr]] - [cuerdas.core :as str] [rumext.v2 :as mf])) -(mf/defc resolved-value-tooltip* - {::mf/private true} - [{:keys [token-name resolved-value]}] - [:* - [:span (dm/str (tr "workspace.tokens.token-name") ": ")] - [:span {:class (stl/css :token-name-tooltip)} token-name] - [:div - [:span (tr "inspect.tabs.styles.token-resolved-value")] - [:ul - (for [[k v] resolved-value] - [:li {:key (d/name k)} - [:span {:class (stl/css :resolved-key)} (str "- " (d/name k) ": ")] - [:span {:class (stl/css :resolved-value)} - (if (sequential? v) - (str/join ", " (map #(dm/str "\"" % "\"") v)) - (dm/str v))]])]]]) (mf/defc token-typography-row* [{:keys [token-name active-tokens detach-token] :rest props}] @@ -59,8 +43,8 @@ has-errors (tr "options.deleted-token") :else - (mf/html [:> resolved-value-tooltip* {:token-name token-name - :resolved-value resolved-value}]))] + (mf/html [:> to/resolved-value-tooltip* {:token-name token-name + :resolved-value resolved-value}]))] [:div {:class (stl/css-case :token-typography-row true :token-typography-row-with-errors has-errors 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 ba2380bfba..6b4ded0961 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 @@ -315,7 +315,12 @@ :on-click #(reset! open-selector? true)} (cond (or (= :multiple font-id) (= "mixed" font-id)) - "--" + [:* + [:span {:class (stl/css :font-option-name :font-family-mixed)} + (tr "inspect.attributes.typography.mixed-font-family")] + [:> icon* {:icon-id i/arrow-down + :class (stl/css :dropdown-icon) + :size "s"}]] (some? font) [:* @@ -451,8 +456,7 @@ :value "lowercase" :id "text-transform-lowercase"}]]])) -(mf/defc text-options - {::mf/wrap-props false} +(mf/defc text-options* [{:keys [ids editor values on-change on-blur show-recent]}] (let [full-size-selector? (and show-recent (= (mf/use-ctx ctx/sidebar) :right)) opts #js {:editor editor @@ -541,9 +545,9 @@ :on-click on-close :icon i/tick}]]] - [:& text-options {:values typography - :on-change on-change - :show-recent false}]] + [:> text-options* {:values typography + :on-change on-change + :show-recent false}]] [:div {:class (stl/css :typography-info-wrapper)} [:div {:class (stl/css :typography-name-wrapper)} diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss index d01a04d186..5e630aea3d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss @@ -51,6 +51,10 @@ visibility: var(--actions-visibility); } +.font-family-mixed { + color: var(--color-foreground-secondary); +} + .typography-selection-wrapper { display: grid; grid-template-columns: $sz-24 auto 1fr; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs index 740750b0cf..737d204640 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs @@ -24,33 +24,68 @@ (-> (l/key :background) (l/derived refs/workspace-page))) +(def ^:private ref:pixel-grid-color + (-> (l/key :pixel-grid-color) + (l/derived refs/workspace-page))) + +(def ^:private ref:pixel-grid-opacity + (-> (l/key :pixel-grid-opacity) + (l/derived refs/workspace-page))) + (mf/defc options* {::mf/wrap [mf/memo]} [] (let [background (mf/deref ref:background-color) + grid-color (mf/deref ref:pixel-grid-color) + grid-alpha (mf/deref ref:pixel-grid-opacity) + on-change (mf/use-fn #(st/emit! (dw/change-canvas-color %))) on-open (mf/use-fn #(st/emit! (dwu/start-undo-transaction :options))) on-close (mf/use-fn #(st/emit! (dwu/commit-undo-transaction :options))) + on-grid-change + (mf/use-fn #(st/emit! (dw/change-pixel-grid-color %))) + color (mf/with-memo [background] {:color (d/nilv background clr/canvas) - :opacity 1})] + :opacity 1}) - [:div {:class (stl/css :element-set)} - [:div {:class (stl/css :element-title)} - [:> title-bar* {:collapsable false - :title (tr "workspace.options.canvas-background") - :class (stl/css :title-spacing-page)}]] - [:div {:class (stl/css :element-content)} + grid-color (mf/with-memo [grid-color grid-alpha] + {:color (d/nilv grid-color clr/default-pixel-grid-color) + :opacity (d/nilv grid-alpha clr/default-pixel-grid-opacity)})] - [:> color-row* - {:disable-gradient true - :disable-opacity true - :disable-image true - :title (tr "workspace.options.canvas-background") - :color color - :on-change on-change - :origin :canvas - :on-open on-open - :on-close on-close}]]])) + [:* [:div {:class (stl/css :element-set)} + [:div {:class (stl/css :element-title)} + [:> title-bar* {:collapsable false + :title (tr "workspace.options.canvas-background") + :class (stl/css :title-spacing-page)}]] + [:div {:class (stl/css :element-content)} + + [:> color-row* + {:disable-gradient true + :disable-opacity true + :disable-image true + :title (tr "workspace.options.canvas-background") + :color color + :on-change on-change + :origin :canvas + :on-open on-open + :on-close on-close}]]] + + [:div {:class (stl/css :element-set)} + [:div {:class (stl/css :element-title)} + [:> title-bar* {:collapsable false + :title (tr "workspace.options.pixel grid-color") + :class (stl/css :title-spacing-page)}]] + [:div {:class (stl/css :element-content)} + + [:> color-row* + {:disable-gradient true + :disable-image true + :title (tr "workspace.options.pixel grid-color") + :color grid-color + :on-change on-grid-change + :origin :pixel-grid + :on-open on-open + :on-close on-close}]]]])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs index 31e98bf832..229deb9460 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs @@ -71,12 +71,13 @@ [{:keys [active-tokens applied-token-name color on-swatch-click-token detach-token open-modal-from-token]}] (let [;; `active-tokens` may be provided as a `delay` (lazy computation). ;; In that case we must deref it (`@active-tokens`) to force evaluation - ;; and obtain the actual value. If it’s already realized (not a delay), + ;; and obtain the actual value. If it's already realized (not a delay), ;; we just use it directly. active-tokens (if (delay? active-tokens) @active-tokens active-tokens) + active-color-tokens (:color active-tokens) token (some #(when (= (:name %) applied-token-name) %) active-color-tokens) @@ -240,7 +241,16 @@ open-modal (mf/use-fn - (mf/deps disable-gradient disable-opacity disable-image disable-picker on-change on-close on-open tokens index applied-token) + (mf/deps disable-gradient + disable-opacity + disable-image + disable-picker + on-change + on-close + on-open + tokens + index + applied-token) (fn [color pos tab] (let [color (cond ^boolean has-multiple-colors @@ -345,6 +355,11 @@ (mf/with-effect [color prev-color disable-picker] (when (and (not disable-picker) (not= prev-color color)) (modal/update-props! :colorpicker {:data (parse-color color)}))) + + (mf/with-effect [applied-token disable-picker] + (when (not disable-picker) + (modal/update-props! :colorpicker {:applied-token applied-token}))) + [:div {:class [class row-class]} ;; Drag handler (when (some? on-reorder) @@ -436,4 +451,5 @@ [:> icon-button* {:variant "ghost" :aria-label (tr "settings.select-this-color") :on-click handle-select + :tooltip-position "top-left" :icon i/move}])])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss index 39f56950d8..931a7431ca 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss @@ -65,6 +65,7 @@ block-size: $sz-32; min-inline-size: 0; inline-size: 100%; + max-inline-size: 100%; padding: 0; margin-inline-end: 0; border: $b-1 solid var(--color-name-wrapper-boder-color); @@ -195,11 +196,6 @@ display: flex; align-items: center; - - &:not(:focus-within) { - cursor: ew-resize; - } - block-size: $sz-32; inline-size: px2rem(60); padding-inline-start: var(--sp-xs); @@ -259,11 +255,6 @@ margin: var(--sp-xxs) 0; padding: 0 0 0 px2rem(6); color: var(--color-foreground-primary); - cursor: ew-resize; - - &:focus { - cursor: text; - } &[disabled] { opacity: 0.5; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs index 723a658466..b535c9c821 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs @@ -35,6 +35,8 @@ on-color-detach on-remove on-stroke-width-change + on-stroke-dash-change + on-stroke-gap-change on-stroke-style-change on-stroke-alignment-change on-stroke-cap-start-change @@ -106,6 +108,23 @@ ids #{:stroke-width}))) + ;; The SVG renderer defaults dash and gap to `stroke-width + 10` when + ;; unset. Showing that value as placeholder makes the override obvious. + default-dash-gap (when (number? stroke-width) (+ 10 stroke-width)) + + stroke-gap (or (:stroke-gap stroke) default-dash-gap) + stroke-dash (or (:stroke-dash stroke) default-dash-gap) + + on-dash-change + (mf/use-fn + (mf/deps index on-stroke-dash-change) + #(on-stroke-dash-change index %)) + + on-gap-change + (mf/use-fn + (mf/deps index on-stroke-gap-change) + #(on-stroke-gap-change index %)) + stroke-alignment (or (:stroke-alignment stroke) :center) stroke-alignment-options @@ -294,6 +313,30 @@ :disabled hidden? :on-change on-style-change}]])]) + ;; Stroke Dash / Gap (only visible for dashed style) + (when (= stroke-style :dashed) + [:div {:class (stl/css :stroke-dash-options) + :data-testid "stroke.dash-options"} + [:> numeric-input-wrapper* {:on-change on-dash-change + :text-icon "DASH" + :min 0 + :on-focus on-focus + :on-blur on-blur + :attr :stroke-dash + :class (stl/css :numeric-input-wrapper) + :property (tr "workspace.options.stroke-dash") + :value stroke-dash}] + [:> numeric-input-wrapper* {:on-change on-gap-change + :text-icon "GAP" + :min 0 + :on-focus on-focus + :on-blur on-blur + :attr :stroke-gap + :tooltip-placement "top-left" + :class (stl/css :numeric-input-wrapper) + :property (tr "workspace.options.stroke-gap") + :value stroke-gap}]]) + ;; Stroke Caps (when show-caps [:div {:class (stl/css :stroke-caps-options)} diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.scss b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.scss index c764e60f3f..2c4a9d2dfb 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.scss @@ -77,6 +77,12 @@ column-gap: var(--sp-xs); } +.stroke-dash-options { + display: grid; + grid-template-columns: 1fr 1fr; + column-gap: var(--sp-xs); +} + .stroke-options-tokens { @include sidebar.option-grid-structure; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs index 70827abf97..e889f28f9f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs @@ -274,7 +274,7 @@ (merge-attrs shape-attrs) (merge-attrs content-attrs)) - new-token-acc (merge-token-values token-acc content-attrs applied-tokens)] + new-token-acc (merge-token-values token-acc editable-attrs applied-tokens)] [(conj ids id) new-values new-token-acc]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs index a8509dbe81..88d9332ef1 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs @@ -84,25 +84,29 @@ ;; - If the user clicks again during the transition, keep showing the original (A) snapshot (if (and (features/active-feature? @st/state "render-wasm/v1") (not= id current-page-id)) - (do - (-> (wasm.api/apply-canvas-blur) - (p/finally - (fn [] - ;; NOTE: it seems we need two RAF so the blur is actually applied and visible - ;; in the canvas :( - (timers/raf - (fn [] - (timers/raf navigate-fn))))))) + (-> (if @wasm.api/page-transition? + (p/resolved nil) + (wasm.api/capture-canvas-snapshot-url)) + (p/finally + (fn [] + (wasm.api/apply-canvas-blur) + ;; NOTE: it seems we need two RAF so the blur is actually applied and visible + ;; in the canvas :( + (timers/raf + (fn [] + (timers/raf navigate-fn)))))) (navigate-fn))))) on-delete (mf/use-fn (mf/deps id) - #(st/emit! (modal/show - {:type :confirm - :title (tr "modals.delete-page.title") - :message (tr "modals.delete-page.body") - :on-accept delete-fn}))) + (fn [event] + (dom/stop-propagation event) + (st/emit! (modal/show + {:type :confirm + :title (tr "modals.delete-page.title") + :message (tr "modals.delete-page.body") + :on-accept delete-fn})))) on-double-click (mf/use-fn @@ -216,10 +220,11 @@ name] [:div {:class (stl/css :page-actions)} (when (and deletable? (not read-only?)) - [:> icon-button* {:variant "ghost" + [:> icon-button* {:variant "action" :aria-label (tr "modals.delete-page.title") :on-click on-delete :icon-size "s" + :icon-class (stl/css :page-delete-button-icon) :icon i/delete}])]])])]]))) ;; --- Page Item Wrapper diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss b/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss index dd0370c5ed..b642d89450 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss @@ -55,6 +55,10 @@ margin-bottom: deprecated.$s-12; } +.page-delete-button-icon { + color: transparent; +} + .page-element { @include deprecated.body-small-typography; @@ -96,37 +100,12 @@ height: deprecated.$s-32; width: deprecated.$s-24; padding: 0 deprecated.$s-4 0 deprecated.$s-8; - - svg { - @extend %button-icon-small; - - color: transparent; - fill: none; - stroke: var(--icon-foreground); - } } .page-actions { height: deprecated.$s-32; display: flex; align-items: center; - - button { - @include deprecated.button-style; - @include deprecated.flex-center; - - width: deprecated.$s-24; - height: 100%; - opacity: deprecated.$op-0; - - svg { - @extend %button-icon-small; - - color: transparent; - fill: none; - stroke: var(--icon-foreground); - } - } } .element-name { diff --git a/frontend/src/app/main/ui/workspace/sidebar/versions.cljs b/frontend/src/app/main/ui/workspace/sidebar/versions.cljs index 37edf428cd..0289f7c2f6 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/versions.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/versions.cljs @@ -11,7 +11,7 @@ [app.common.time :as ct] [app.common.uuid :as uuid] [app.config :as cfg] - [app.main.data.notifications :as ntf] + [app.main.data.event :as ev] [app.main.data.workspace.versions :as dwv] [app.main.refs :as refs] [app.main.store :as st] @@ -77,20 +77,49 @@ (assoc item :index index))) (reverse))) -(defn- open-restore-version-dialog - [origin id] - (st/emit! (ntf/dialog - :content (tr "workspace.versions.restore-warning") - :controls :inline-actions - :cancel {:label (tr "workspace.updates.dismiss") - :callback #(st/emit! (ntf/hide))} - :accept {:label (tr "labels.restore") - :callback #(st/emit! (dwv/restore-version id origin))} - :tag :restore-dialog))) +(defn- on-name-input-focus + [event] + (dom/select-text! (dom/get-target event))) + +(defn- extract-id-from-event + [event] + (-> event dom/get-current-target (dom/get-data "id") uuid/parse)) + +(defn- on-create-version + [] + (st/emit! (dwv/create-version))) + +(defn- on-edit-version + [id _event] + (st/emit! (dwv/update-versions-state {:editing id}))) + +(defn- on-cancel-version-edition + [_id _event] + (st/emit! (dwv/update-versions-state {:editing nil}))) + +(defn- on-rename-version + [id label] + (st/emit! (dwv/rename-version id label))) + +(defn- on-delete-version + [id] + (st/emit! (dwv/delete-version id))) + +(defn- on-pin-version + [id] + (st/emit! (dwv/pin-version id))) + +(defn- on-lock-version + [id] + (st/emit! (dwv/lock-version id))) + +(defn- on-unlock-version + [id] + (st/emit! (dwv/unlock-version id))) (mf/defc version-entry* {::mf/private true} - [{:keys [entry current-profile on-restore on-delete on-rename on-lock on-unlock on-edit on-cancel-edit is-editing]}] + [{:keys [entry current-profile on-preview on-restore on-delete on-rename on-lock on-unlock on-edit on-cancel-edit is-editing]}] (let [show-menu? (mf/use-state false) profiles (mf/deref refs/profiles) @@ -108,6 +137,13 @@ (fn [event] (on-edit (:id entry) event))) + on-preview + (mf/use-fn + (mf/deps entry on-preview) + (fn [] + (when (fn? on-preview) + (on-preview (:id entry))))) + on-restore (mf/use-fn (mf/deps entry on-restore) @@ -136,11 +172,6 @@ (when on-unlock (on-unlock (:id entry))))) - on-name-input-focus - (mf/use-fn - (fn [event] - (dom/select-text! (dom/get-target event)))) - on-name-input-blur (mf/use-fn (mf/deps entry on-rename on-cancel-edit) @@ -191,6 +222,11 @@ :on-click on-edit} (tr "labels.rename")]) + [:li {:class (stl/css :menu-option) + :role "button" + :on-click on-preview} + (tr "workspace.versions.button.preview")] + [:li {:class (stl/css :menu-option) :role "button" :on-click on-restore} @@ -216,7 +252,7 @@ (tr "labels.delete")])])]])) (mf/defc snapshot-entry* - [{:keys [entry on-pin-snapshot on-restore-snapshot]}] + [{:keys [entry on-pin-snapshot on-restore-snapshot on-preview-snapshot]}] (let [open-menu* (mf/use-state nil) entry-ref (mf/use-ref nil) @@ -225,23 +261,22 @@ (mf/use-fn (mf/deps on-pin-snapshot) (fn [event] - (let [node (dom/get-current-target event) - id (-> node - (dom/get-data "id") - (uuid/parse))] - (when (fn? on-pin-snapshot) - (on-pin-snapshot id event))))) + (when (fn? on-pin-snapshot) + (on-pin-snapshot (extract-id-from-event event) event)))) on-restore-snapshot (mf/use-fn (mf/deps on-restore-snapshot) (fn [event] - (let [node (dom/get-current-target event) - id (-> node - (dom/get-data "id") - (uuid/parse))] - (when (fn? on-restore-snapshot) - (on-restore-snapshot id event))))) + (when (fn? on-restore-snapshot) + (on-restore-snapshot (extract-id-from-event event) event)))) + + on-preview-snapshot + (mf/use-fn + (mf/deps on-preview-snapshot) + (fn [event] + (when (fn? on-preview-snapshot) + (on-preview-snapshot (extract-id-from-event event) event)))) on-open-snapshot-menu (mf/use-fn @@ -266,6 +301,11 @@ :on-close #(reset! open-menu* nil)} [:ul {:class (stl/css :version-options-dropdown) :style {"--offset" (dm/str (:offset @open-menu*) "px")}} + [:li {:class (stl/css :menu-option) + :role "button" + :data-id (dm/str (:snapshot @open-menu*)) + :on-click on-preview-snapshot} + (tr "workspace.versions.button.preview")] [:li {:class (stl/css :menu-option) :role "button" :data-id (dm/str (:snapshot @open-menu*)) @@ -302,66 +342,50 @@ (= (:filter state) (:profile-id %))))) (group-snapshots))) - on-create-version + on-preview-version (mf/use-fn - (fn [] (st/emit! (dwv/create-version)))) + (fn [id] + (st/emit! (dwv/enter-preview id) + (ev/event {::ev/name "preview-version" + ::ev/origin "workspace:sidebar" + :type "pinned-version"})))) - on-edit-version + on-preview-snapshot (mf/use-fn (fn [id _event] - (st/emit! (dwv/update-versions-state {:editing id})))) - - on-cancel-version-edition - (mf/use-fn - (fn [_id _event] - (st/emit! (dwv/update-versions-state {:editing nil})))) - - on-rename-version - (mf/use-fn - (fn [id label] - (st/emit! (dwv/rename-version id label)))) + (st/emit! (dwv/enter-preview id) + (ev/event {::ev/name "preview-version" + ::ev/origin "workspace:sidebar" + :type "autosaved-version"})))) on-restore-version (mf/use-fn (fn [id _event] - (open-restore-version-dialog :version id))) + (st/emit! (dwv/enter-restore id) + (ev/event {::ev/name "restore-version" + ::ev/origin "workspace:sidebar" + :type "pinned-version"})))) on-restore-snapshot (mf/use-fn (fn [id _event] - (open-restore-version-dialog :snapshot id))) - - on-delete-version - (mf/use-fn - (fn [id] - (st/emit! (dwv/delete-version id)))) - - on-pin-version - (mf/use-fn - (fn [id] (st/emit! (dwv/pin-version id)))) - - on-lock-version - (mf/use-fn - (fn [id] - (st/emit! (dwv/lock-version id)))) - - on-unlock-version - (mf/use-fn - (fn [id] - (st/emit! (dwv/unlock-version id)))) + (st/emit! (dwv/enter-restore id) + (ev/event {::ev/name "restore-version" + ::ev/origin "workspace:sidebar" + :type "autosaved-version"})))) on-change-filter (mf/use-fn - (fn [filter] + (fn [filter-value] (cond - (= :all filter) + (= :all filter-value) (st/emit! (dwv/update-versions-state {:filter nil})) - (= :own filter) + (= :own filter-value) (st/emit! (dwv/update-versions-state {:filter (:id profile)})) :else - (st/emit! (dwv/update-versions-state {:filter filter}))))) + (st/emit! (dwv/update-versions-state {:filter filter-value}))))) options (mf/with-memo [users profile] @@ -415,6 +439,7 @@ :on-edit on-edit-version :on-cancel-edit on-cancel-version-edition :on-rename on-rename-version + :on-preview on-preview-version :on-restore on-restore-version :on-delete on-delete-version :on-lock on-lock-version @@ -423,6 +448,7 @@ :snapshot [:> snapshot-entry* {:key (:index entry) :entry entry + :on-preview-snapshot on-preview-snapshot :on-restore-snapshot on-restore-snapshot :on-pin-snapshot on-pin-version}] diff --git a/frontend/src/app/main/ui/workspace/text_palette.cljs b/frontend/src/app/main/ui/workspace/text_palette.cljs index 7599a1baed..2a5732af93 100644 --- a/frontend/src/app/main/ui/workspace/text_palette.cljs +++ b/frontend/src/app/main/ui/workspace/text_palette.cljs @@ -19,7 +19,6 @@ [app.util.i18n :refer [tr]] [app.util.object :as obj] [cuerdas.core :as str] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (mf/defc typography-item* @@ -39,8 +38,7 @@ :typography-ref-id (:id typography)} (dissoc typography :id :name))] - (st/emit! (ptk/event - ::ev/event + (st/emit! (ev/event {::ev/name "use-library-typography" ::ev/origin "text-palette" :external-library (not= file-id current-file-id)})) diff --git a/frontend/src/app/main/ui/workspace/tokens/export/modal.cljs b/frontend/src/app/main/ui/workspace/tokens/export/modal.cljs index 0db9bc5fcb..9b00a48b2c 100644 --- a/frontend/src/app/main/ui/workspace/tokens/export/modal.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/export/modal.cljs @@ -13,7 +13,7 @@ [app.main.data.modal :as modal] [app.main.refs :as refs] [app.main.store :as st] - [app.main.ui.components.code-block :refer [code-block]] + [app.main.ui.components.code-block :refer [code-block*]] [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.foundations.typography.heading :refer [heading*]] @@ -23,7 +23,6 @@ [app.util.i18n :refer [tr]] [app.util.webapi :as wapi] [app.util.zip :as zip] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (mf/defc export-tab* @@ -61,13 +60,13 @@ (mf/deps tokens-json) (fn [] (when tokens-json - (st/emit! (ptk/data-event ::ev/event {::ev/name "export-tokens" :type "single"})) + (st/emit! (ev/event {::ev/name "export-tokens" :type "single"})) (->> (wapi/create-blob (or tokens-json "{}") "application/json") (dom/trigger-download "tokens.json")))))] [:> export-tab* {:is-disabled is-disabled :on-export on-export} [:div {:class (stl/css :json-preview)} - [:> code-block {:code tokens-json :type "json"}]]])) + [:> code-block* {:code tokens-json :type "json"}]]])) (defn download-tokens-zip! [multi-file-entries] (let [writer (-> (zip/blob-writer {:mtype "application/zip"}) @@ -88,7 +87,7 @@ (mf/use-fn (mf/deps files) (fn [] - (st/emit! (ptk/data-event ::ev/event {::ev/name "export-tokens" :type "multiple"})) + (st/emit! (ev/event {::ev/name "export-tokens" :type "multiple"})) (download-tokens-zip! files)))] [:> export-tab* {:on-export on-export :is-disabled is-disabled} diff --git a/frontend/src/app/main/ui/workspace/tokens/import/modal.cljs b/frontend/src/app/main/ui/workspace/tokens/import/modal.cljs index 8e47009bbb..ac2e265929 100644 --- a/frontend/src/app/main/ui/workspace/tokens/import/modal.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/import/modal.cljs @@ -27,7 +27,6 @@ [app.util.zip :as uz] [beicon.v2.core :as rx] [cuerdas.core :as str] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (defn- on-stream-imported @@ -35,7 +34,7 @@ (rx/sub! tokens-lib-stream (fn [lib] - (st/emit! (ptk/data-event ::ev/event {::ev/name "import-tokens" :type type}) + (st/emit! (ev/event {::ev/name "import-tokens" :type type}) (dwtl/import-tokens-lib lib)) (modal/hide!)) (fn [err] diff --git a/frontend/src/app/main/ui/workspace/tokens/management.cljs b/frontend/src/app/main/ui/workspace/tokens/management.cljs index e3a0dafed1..15f780914a 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management.cljs @@ -327,4 +327,5 @@ :type type :selected-shapes selected-shapes :is-selected-inside-layout is-selected-inside-layout - :active-theme-tokens resolved-active-tokens}])])) + :active-theme-tokens resolved-active-tokens + :selected-token-set-id selected-token-set-id}])])) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/color.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/color.cljs index 6c295990e7..b6d41273ca 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/color.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/color.cljs @@ -8,12 +8,13 @@ (:require [app.common.files.tokens :as cfo] [app.common.schema :as sm] + [app.common.types.tokens-lib :as ctob] [app.main.ui.workspace.tokens.management.forms.controls :as token.controls] [app.main.ui.workspace.tokens.management.forms.generic-form :as generic] [rumext.v2 :as mf])) (mf/defc form* - [{:keys [token token-type] :as props}] + [{:keys [token token-type selected-token-set-id] :as props}] (let [initial (mf/with-memo [token-type token] {:type token-type @@ -22,7 +23,11 @@ :description (:description token "") :color-result ""}) - props (mf/spread-props props {:make-schema #(-> (cfo/make-token-schema %1 token-type) + props (mf/spread-props props {:make-schema #(-> (cfo/make-token-schema %1 + token-type + selected-token-set-id + (when (ctob/token? token) + (ctob/get-id token))) (sm/dissoc-key :id) (sm/assoc-key :color-result :string)) :initial initial diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs index 1cd082dcbf..3defb347d3 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs @@ -7,7 +7,6 @@ (ns app.main.ui.workspace.tokens.management.forms.controls.color-input (:require-macros [app.main.style :as stl]) (:require - [app.common.colors :as color] [app.common.data :as d] [app.common.data.macros :as dm] [app.common.types.color :as cl] @@ -171,8 +170,8 @@ default-bullet-color (case (:theme profile) "light" - color/background-quaternary-light - color/background-quaternary) + cl/background-quaternary-light + cl/background-quaternary) hex (if valid-color (tinycolor/->hex-string (tinycolor/valid-color valid-color)) @@ -337,8 +336,8 @@ default-bullet-color (case (:theme profile) "light" - color/background-quaternary-light - color/background-quaternary) + cl/background-quaternary-light + cl/background-quaternary) hex (if valid-color diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs index 137551c260..59e3b409a3 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs @@ -99,11 +99,10 @@ container (hooks/use-portal-container) - raw-tokens-by-type (mf/use-ctx muc/active-tokens-by-type) - + resolved-tokens-by-type (mf/use-ctx muc/active-tokens-by-type) filtered-tokens-by-type - (mf/with-memo [raw-tokens-by-type token-type] - (csu/filter-tokens-for-input raw-tokens-by-type token-type)) + (mf/with-memo [resolved-tokens-by-type token-type] + (csu/filter-tokens-for-input resolved-tokens-by-type token-type)) visible-options (mf/with-memo [filtered-tokens-by-type token] diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/floating_dropdown.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/floating_dropdown.cljs index d7cf90a3f3..cddb3b9eb4 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/floating_dropdown.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/floating_dropdown.cljs @@ -7,6 +7,7 @@ (ns app.main.ui.workspace.tokens.management.forms.controls.floating-dropdown (:require [app.util.dom :as dom] + [app.util.timers :as timers] [rumext.v2 :as mf])) (defn use-floating-dropdown [is-open input-wrapper-ref outer-wrapper-ref dropdown-ref] @@ -48,7 +49,7 @@ (when is-open (let [recalculate (fn [] - (js/requestAnimationFrame + (timers/raf (fn [] (let [input-node (mf/ref-val input-wrapper-ref)] (calculate-position input-node))))) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/font_family.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/font_family.cljs index b7bba0ea45..4815952948 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/font_family.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/font_family.cljs @@ -9,6 +9,7 @@ [app.common.files.tokens :as cfo] [app.common.schema :as sm] [app.common.types.token :as cto] + [app.common.types.tokens-lib :as ctob] [app.main.data.workspace.tokens.errors :as wte] [app.main.ui.workspace.tokens.management.forms.controls :as token.controls] [app.main.ui.workspace.tokens.management.forms.generic-form :as generic] @@ -29,7 +30,7 @@ (default-validate-token))) (mf/defc form* - [{:keys [token token-type] :rest props}] + [{:keys [token token-type selected-token-set-id] :rest props}] (let [token (mf/with-memo [token] (if token @@ -37,7 +38,11 @@ {:type token-type})) props (mf/spread-props props {:token token :token-type token-type - :make-schema #(-> (cfo/make-token-schema %1 token-type) + :make-schema #(-> (cfo/make-token-schema %1 + token-type + selected-token-set-id + (when (ctob/token? token) + (ctob/get-id token))) (sm/dissoc-key :id) ;; The value as edited in the form is a simple stirng. ;; It's converted to vector in the validator. diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/form_container.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/form_container.cljs index 0e6d55df03..ae02728793 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/form_container.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/form_container.cljs @@ -6,8 +6,6 @@ (ns app.main.ui.workspace.tokens.management.forms.form-container (:require - [app.common.data :as d] - [app.common.types.tokens-lib :as ctob] [app.config :as cf] [app.main.refs :as refs] [app.main.ui.workspace.tokens.management.forms.color :as color] @@ -24,24 +22,29 @@ (let [token-type (or (:type token) token-type) - tokens-in-selected-set - (mf/deref refs/workspace-all-tokens-in-selected-set) - - token-path - (mf/with-memo [token] - (ctob/get-token-path token)) - - tokens-tree-in-selected-set - (mf/with-memo [token-path tokens-in-selected-set] - (-> (ctob/tokens-tree tokens-in-selected-set) - (d/dissoc-in token-path))) props - (mf/spread-props props {:token-type token-type - :tokens-tree-in-selected-set tokens-tree-in-selected-set - :token token}) - text-case-props (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.text-case-value-enter")}) - text-decoration-props (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.text-decoration-value-enter")}) - font-weight-props (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.font-weight-value-enter")}) + (if (contains? cf/flags :token-combobox) + (mf/spread-props props {:token-type token-type + :selected-token-set-id (mf/deref refs/selected-token-set-id) + :token token + :input-component token.controls/value-combobox*}) + (mf/spread-props props {:token-type token-type + :selected-token-set-id (mf/deref refs/selected-token-set-id) + :token token})) + text-case-props (if (contains? cf/flags :token-combobox) + (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.text-case-value-enter") + :input-component token.controls/value-combobox*}) + (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.text-case-value-enter")})) + text-decoration-props (if (contains? cf/flags :token-combobox) + (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.text-decoration-value-enter") + :input-component token.controls/value-combobox*}) + (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.text-decoration-value-enter")})) + + font-weight-props (if (contains? cf/flags :token-combobox) + (mf/spread-props props {:input-component token.controls/value-combobox* + :input-value-placeholder (tr "workspace.tokens.font-weight-value-enter")}) + (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.font-weight-value-enter")})) + border-radius-props (if (contains? cf/flags :token-combobox) (mf/spread-props props {:input-component token.controls/value-combobox*}) props)] @@ -55,4 +58,4 @@ :text-decoration [:> generic/form* text-decoration-props] :font-weight [:> generic/form* font-weight-props] :border-radius [:> generic/form* border-radius-props] - [:> generic/form* props]))) \ No newline at end of file + [:> generic/form* props]))) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs index a4cc813bbc..e5643b9692 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs @@ -10,9 +10,12 @@ [app.common.files.tokens :as cfo] [app.common.schema :as sm] [app.common.types.tokens-lib :as ctob] + [app.config :as cf] [app.main.constants :refer [max-input-length]] [app.main.data.helpers :as dh] [app.main.data.modal :as modal] + [app.main.data.style-dictionary :as sd] + [app.main.data.tokenscript :as ts] [app.main.data.workspace.tokens.application :as dwta] [app.main.data.workspace.tokens.errors :as wte] [app.main.data.workspace.tokens.library-edit :as dwtl] @@ -48,7 +51,6 @@ (if (= active-tab :reference) (get value :reference) value) - value)) (mf/defc form* @@ -57,7 +59,6 @@ action is-create selected-token-set-id - tokens-tree-in-selected-set token-type make-schema input-component @@ -66,7 +67,11 @@ value-subfield input-value-placeholder] :as props}] - (let [make-schema (or make-schema #(-> (cfo/make-token-schema % token-type) + (let [make-schema (or make-schema #(-> (cfo/make-token-schema % + token-type + selected-token-set-id + (when (ctob/token? token) + (ctob/get-id token))) (sm/dissoc-key :id))) input-component (or input-component token.controls/input*) validate-token (or validator default-validate-token) @@ -83,27 +88,47 @@ token-title (str/lower (:title token-properties)) - tokens (mf/deref refs/workspace-all-tokens-map) + tokens-lib (mf/deref refs/tokens-lib) + ;; All tokens in the lib, as a map name -> token, flattened + ;; including tokens in inactive sets. + tokens-tree (mf/deref refs/workspace-all-tokens-map) + + ;; A map name -> token, tokens only in actual set. tokens-in-selected-set (mf/deref refs/workspace-all-tokens-in-selected-set) + ;; Make actual set tokens take precedence over tokens in other sets. tokens - (mf/with-memo [tokens tokens-in-selected-set token] + (mf/with-memo [tokens-tree tokens-in-selected-set token] ;; Ensure that the resolved value uses the currently editing token ;; even if the name has been overriden by a token with the same name ;; in another set below. - (cond-> (merge tokens tokens-in-selected-set) + (cond-> (merge tokens-tree tokens-in-selected-set) (and (:name token) (:value token)) (assoc (:name token) token))) + tokenscript? (contains? cf/flags :tokenscript) + + ;; A map name-> token with resolved-values, resolved with style dictionary + resolved-active-tokens + (sd/use-resolved-tokens* tokens-tree) + + ;; A map name-> token with resolved-values, resolved with tokescript + tokenscript-resolved-active-tokens + (mf/with-memo [tokens-tree tokenscript?] + (when tokenscript? (ts/resolve-tokens tokens-tree))) + + ;; A map which keys are token types and values are vectors of tokens of that type, with resolved values. active-tokens-by-type - (mf/with-memo [tokens] - (delay (ctob/group-by-type tokens))) + (mf/with-memo [resolved-active-tokens tokenscript-resolved-active-tokens tokenscript?] + (delay (ctob/group-by-type (if tokenscript? + tokenscript-resolved-active-tokens + resolved-active-tokens)))) schema - (mf/with-memo [tokens-tree-in-selected-set active-tab] - (make-schema tokens-tree-in-selected-set active-tab)) + (mf/with-memo [tokens-lib active-tab] + (make-schema tokens-lib active-tab)) initial (mf/with-memo [token initial] @@ -158,9 +183,10 @@ on-remap-token (mf/use-fn - (mf/deps token) + (mf/deps token token-type) (fn [valid-token new-name old-name description] (st/emit! + (dwtl/toggle-nested-token-path token-type new-name) (dwtl/update-token (:id token) {:name new-name :value (:value valid-token) @@ -171,9 +197,10 @@ on-rename-token (mf/use-fn - (mf/deps token) + (mf/deps token token-type) (fn [valid-token name description] (st/emit! + (dwtl/toggle-nested-token-path token-type name) (dwtl/update-token (:id token) {:name name :value (:value valid-token) @@ -183,11 +210,12 @@ on-submit (mf/use-fn (mf/deps validate-token token tokens token-type value-subfield value-type active-tab on-remap-token on-rename-token is-create) - (fn [form _event] + (fn [form event] (let [name (get-in @form [:clean-data :name]) description (get-in @form [:clean-data :description]) value (get-in @form [:clean-data :value]) value-for-validation (get-value-for-validator active-tab value value-subfield value-type)] + (dom/stop-propagation event) (->> (validate-token {:token-value value-for-validation :token-name name :token-description description @@ -209,19 +237,22 @@ (st/emit! (modal/show :tokens/remapping-confirmation {:remap-data remap-data :on-remap on-remap :on-rename on-rename})) - (st/emit! - (if is-create - (dwtl/create-token (ctob/make-token {:name name - :type token-type - :value (:value valid-token) - :description description})) - (dwtl/update-token (:id token) - {:name name - :value (:value valid-token) - :description description})) - (dwtl/open-token-type (:type token)) - (dwtp/propagate-workspace-tokens) - (modal/hide!))))) + (do + (when is-rename + (st/emit! (dwtl/toggle-nested-token-path token-type name))) + (st/emit! + (if is-create + (dwtl/create-token (ctob/make-token {:name name + :type token-type + :value (:value valid-token) + :description description})) + (dwtl/update-token (:id token) + {:name name + :value (:value valid-token) + :description description})) + (dwtl/open-token-type (:type token)) + (dwtp/propagate-workspace-tokens) + (modal/hide!)))))) ;; WORKAROUND: display validation errors in the form instead of crashing (fn [{:keys [errors]}] (let [error-messages (wte/humanize-errors errors) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.cljs index 9356d45725..6d0ae946da 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.cljs @@ -118,27 +118,15 @@ [properties] [:& token-update-create-modal properties]) -(mf/defc color-modal - {::mf/register modal/components - ::mf/register-as :tokens/color} - [properties] - [:& token-update-create-modal properties]) - -(mf/defc stroke-width-modal - {::mf/register modal/components - ::mf/register-as :tokens/stroke-width} - [properties] - [:& token-update-create-modal properties]) - (mf/defc box-shadow-modal {::mf/register modal/components ::mf/register-as :tokens/shadow} [properties] [:& token-update-create-modal properties]) -(mf/defc sizing-modal +(mf/defc color-modal {::mf/register modal/components - ::mf/register-as :tokens/sizing} + ::mf/register-as :tokens/color} [properties] [:& token-update-create-modal properties]) @@ -148,6 +136,30 @@ [properties] [:& token-update-create-modal properties]) +(mf/defc font-familiy-modal + {::mf/register modal/components + ::mf/register-as :tokens/font-family} + [properties] + [:& token-update-create-modal properties]) + +(mf/defc font-size-modal + {::mf/register modal/components + ::mf/register-as :tokens/font-size} + [properties] + [:& token-update-create-modal properties]) + +(mf/defc font-weight-modal + {::mf/register modal/components + ::mf/register-as :tokens/font-weight} + [properties] + [:& token-update-create-modal properties]) + +(mf/defc letter-spacing-modal + {::mf/register modal/components + ::mf/register-as :tokens/letter-spacing} + [properties] + [:& token-update-create-modal properties]) + (mf/defc number-modal {::mf/register modal/components ::mf/register-as :tokens/number} @@ -172,6 +184,12 @@ [properties] [:& token-update-create-modal properties]) +(mf/defc sizing-modal + {::mf/register modal/components + ::mf/register-as :tokens/sizing} + [properties] + [:& token-update-create-modal properties]) + (mf/defc spacing-modal {::mf/register modal/components ::mf/register-as :tokens/spacing} @@ -184,27 +202,9 @@ [properties] [:& token-update-create-modal properties]) -(mf/defc typography-modal +(mf/defc stroke-width-modal {::mf/register modal/components - ::mf/register-as :tokens/typography} - [properties] - [:& token-update-create-modal properties]) - -(mf/defc font-size-modal - {::mf/register modal/components - ::mf/register-as :tokens/font-size} - [properties] - [:& token-update-create-modal properties]) - -(mf/defc letter-spacing-modal - {::mf/register modal/components - ::mf/register-as :tokens/letter-spacing} - [properties] - [:& token-update-create-modal properties]) - -(mf/defc font-familiy-modal - {::mf/register modal/components - ::mf/register-as :tokens/font-family} + ::mf/register-as :tokens/stroke-width} [properties] [:& token-update-create-modal properties]) @@ -220,8 +220,8 @@ [properties] [:& token-update-create-modal properties]) -(mf/defc font-weight-modal +(mf/defc typography-modal {::mf/register modal/components - ::mf/register-as :tokens/font-weight} + ::mf/register-as :tokens/typography} [properties] [:& token-update-create-modal properties]) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs index 194b731f03..601a77f13a 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs @@ -3,8 +3,8 @@ (:require [app.common.data :as d] [app.common.files.tokens :as cfo] - [app.common.types.tokens-lib :as ctob] [app.main.data.modal :as modal] + [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] @@ -18,8 +18,8 @@ [rumext.v2 :as mf])) (mf/defc rename-node-form* - [{:keys [new-node-name node active-tokens tokens-tree variant on-close on-submit]}] - (let [make-schema #(cfo/make-node-token-schema active-tokens tokens-tree node) + [{:keys [new-node-name node active-tokens tokens-lib selected-token-set-id variant on-close on-submit]}] + (let [make-schema #(cfo/make-node-token-schema active-tokens tokens-lib node selected-token-set-id) schema (mf/with-memo [active-tokens] @@ -82,10 +82,9 @@ (let [variant (d/nilv variant "rename") ;; "rename" or "duplicate" - tokens-tree-in-selected-set - (mf/with-memo [tokens-in-active-set node] - (-> (ctob/tokens-tree tokens-in-active-set) - (d/dissoc-in (:name node)))) + selected-token-set-id (mf/deref refs/selected-token-set-id) + + tokens-lib (mf/deref refs/tokens-lib) close-modal (mf/use-fn @@ -118,6 +117,7 @@ :node node :variant variant :active-tokens tokens-in-active-set - :tokens-tree tokens-tree-in-selected-set + :tokens-lib tokens-lib + :selected-token-set-id selected-token-set-id :on-close close-modal :on-submit rename}]]])) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs index 722683d54a..c928cc785a 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs @@ -255,7 +255,7 @@ ;; TODO: use cfo/make-schema:token-value and extend it with shadow and reference fields (defn- make-schema - [tokens-tree active-tab] + [set-id token-id tokens-lib active-tab] (sm/schema [:and [:map @@ -266,7 +266,7 @@ (sm/update-properties cto/schema:token-name assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))) [:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))} - #(not (ctob/token-name-path-exists? % tokens-tree))]]] + #(not (ctob/token-name-path-exists? % tokens-lib set-id token-id))]]] [:value [:map @@ -296,7 +296,7 @@ [:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]]] [:fn {:error/field [:value :reference] - :error/fn #(tr "workspace.tokens.self-reference")} + :error/fn #(tr "errors.tokens.self-reference")} (fn [{:keys [name value]}] (let [reference (get value :reference)] (if (and reference name) @@ -340,8 +340,7 @@ :shadow [default-token-shadow]})) (mf/defc form* - [{:keys [token - token-type] :as props}] + [{:keys [token token-type selected-token-set-id] :as props}] (let [token (mf/with-memo [token] (or token @@ -352,6 +351,12 @@ {:type token-type :value {:reference nil :shadow [default-token-shadow]}}))) + + make-schema + (mf/with-memo [selected-token-set-id token] + (partial make-schema selected-token-set-id (when (ctob/token? token) + (ctob/get-id token)))) + initial (mf/with-memo [token] (let [raw-value (:value token) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs index cb926f72fd..25f41e867c 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs @@ -209,7 +209,7 @@ ;; TODO: use cfo/make-schema:token-value and extend it with typography and reference fields (defn- make-schema - [tokens-tree active-tab] + [set-id token-id tokens-lib active-tab] (sm/schema [:and [:map @@ -220,7 +220,7 @@ (sm/update-properties cto/schema:token-name assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))) [:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))} - #(not (ctob/token-name-path-exists? % tokens-tree))]]] + #(not (ctob/token-name-path-exists? % tokens-lib set-id token-id))]]] [:value [:map @@ -239,7 +239,7 @@ [:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]]] [:fn {:error/field [:value :reference] - :error/fn #(tr "workspace.tokens.self-reference")} + :error/fn #(tr "errors.tokens.self-reference")} (fn [{:keys [name value]}] (let [reference (get value :reference)] (if (and reference name) @@ -247,7 +247,7 @@ true)))] [:fn {:error/field [:value :line-height] - :error/fn #(tr "workspace.tokens.composite-line-height-needs-font-size")} + :error/fn #(tr "errors.tokens.composite-line-height-needs-font-size")} (fn [{:keys [value]}] (let [line-heigh (get value :line-height) font-size (get value :font-size)] @@ -269,7 +269,7 @@ result))]])) (mf/defc form* - [{:keys [token] :as props}] + [{:keys [token selected-token-set-id] :as props}] (let [initial (mf/with-memo [token] (let [value (:value token) @@ -296,6 +296,12 @@ {:name (:name token "") :value processed-value :description (:description token "")})) + + make-schema + (mf/with-memo [selected-token-set-id token] + (partial make-schema selected-token-set-id (when (ctob/token? token) + (ctob/get-id token)))) + props (mf/spread-props props {:initial initial :make-schema make-schema :token token diff --git a/frontend/src/app/main/ui/workspace/tokens/management/group.cljs b/frontend/src/app/main/ui/workspace/tokens/management/group.cljs index a9b6914b9f..f3c2ad22a8 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/group.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/group.cljs @@ -131,7 +131,7 @@ on-popover-open-click (mf/use-fn - (mf/deps type title modal) + (mf/deps type title modal selected-token-set-id) (fn [event] (dom/stop-propagation event) (st/emit! @@ -143,11 +143,12 @@ :fields (:fields modal) :title title :action "create" - :token-type type}))))) + :token-type type + :selected-token-set-id selected-token-set-id}))))) on-token-pill-click (mf/use-fn - (mf/deps not-editing? selected-ids tokens-lib) + (mf/deps not-editing? selected-ids tokens-lib selected-token-set-id) (fn [event token] (let [token (ctob/get-token tokens-lib selected-token-set-id (:id token))] (dom/stop-propagation event) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_tree.scss b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.scss index 9edd7caf40..9411ec58d9 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_tree.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.scss @@ -13,6 +13,7 @@ display: flex; flex-wrap: wrap; padding-inline-start: calc(var(--node-spacing)); + gap: var(--sp-xs); & .node-parent { flex: 1 0 100%; diff --git a/frontend/src/app/main/ui/workspace/tokens/sets/helpers.cljs b/frontend/src/app/main/ui/workspace/tokens/sets/helpers.cljs index 825e31571c..e57d50d884 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sets/helpers.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/sets/helpers.cljs @@ -7,8 +7,7 @@ [app.main.data.notifications :as ntf] [app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.store :as st] - [app.util.i18n :refer [tr]] - [potok.v2.core :as ptk])) + [app.util.i18n :refer [tr]])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; HELPERS - Shared functions for token sets management @@ -37,7 +36,7 @@ [tokens-lib parent-set name] (let [name (ctob/make-child-name parent-set name) errors (sm/validation-errors name (cfo/make-token-set-name-schema tokens-lib nil))] - (st/emit! (ptk/data-event ::ev/event {::ev/name "create-token-set" :name name}) + (st/emit! (ev/event {::ev/name "create-token-set" :name name}) (dwtl/clear-token-set-creation)) (if (empty? errors) (let [token-set (ctob/make-token-set :name name)] diff --git a/frontend/src/app/main/ui/workspace/tokens/sets/lists.cljs b/frontend/src/app/main/ui/workspace/tokens/sets/lists.cljs index ad510bc7d6..4fe6934999 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sets/lists.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/sets/lists.cljs @@ -240,7 +240,7 @@ on-checkbox-click (mf/use-fn - (mf/deps id on-toggle) + (mf/deps set on-toggle) (fn [event] (dom/stop-propagation event) (when (fn? on-toggle) diff --git a/frontend/src/app/main/ui/workspace/tokens/settings/menu.cljs b/frontend/src/app/main/ui/workspace/tokens/settings/menu.cljs index 8469d83f28..a26400d7cd 100644 --- a/frontend/src/app/main/ui/workspace/tokens/settings/menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/settings/menu.cljs @@ -26,7 +26,6 @@ [rumext.v2 :as mf])) (mf/defc token-settings* - {::mf/wrap-props false} [] (let [file-data (deref refs/workspace-data) base-font-size* (mf/use-state #(ctf/get-base-font-size file-data)) diff --git a/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.cljs b/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.cljs index 32abd69298..0483d8d755 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.cljs @@ -33,7 +33,6 @@ [app.util.i18n :refer [tr]] [app.util.keyboard :as k] [cuerdas.core :as str] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) ;; Form Component -------------------------------------------------------------- @@ -411,7 +410,7 @@ on-save (mf/use-fn (fn [theme] - (st/emit! (ptk/event ::ev/event {::ev/name "create-tokens-theme"}) + (st/emit! (ev/event {::ev/name "create-tokens-theme"}) (dwtl/create-token-theme theme)))) has-prev-view (has-prev-view (:prev-type state))] diff --git a/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.scss b/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.scss index 1526c63b7a..4cc6c1ef06 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.scss +++ b/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.scss @@ -4,19 +4,36 @@ // // Copyright (c) KALEIDOS INC +@use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/mixins.scss" as *; +@use "ds/z-index.scss" as *; .modal-overlay { - @extend %modal-overlay-base; + display: flex; + justify-content: center; + align-items: center; + position: fixed; + inset-inline-start: 0; + inset-block-start: 0; + block-size: 100%; + inline-size: 100%; + z-index: var(--z-index-set); + background-color: var(--color-overlay-default); } .modal-dialog { - @extend %modal-container-base; - - width: 100%; - max-width: deprecated.$s-512; - max-height: unset; + position: relative; + inline-size: 100%; + min-inline-size: $sz-364; + min-block-size: $sz-192; + max-inline-size: $sz-512; + max-block-size: calc(100dvh - #{px2rem(192)}); + padding: var(--sp-xxxl); + border-radius: $br-8; + border: $b-2 solid var(--color-background-quaternary); + background-color: var(--color-background-primary); user-select: none; } @@ -25,14 +42,15 @@ flex-direction: column; justify-content: center; align-items: center; - gap: deprecated.$s-12; - padding: deprecated.$s-72 0; + gap: var(--sp-m); + padding-block: px2rem(72); + padding-inline: 0; } .themes-modal-wrapper { display: flex; flex-direction: column; - gap: deprecated.$s-16; + gap: var(--sp-l); } .edit-theme-form { @@ -52,10 +70,10 @@ border: none; appearance: none; color: var(--color-foreground-secondary); - width: fit-content; + inline-size: fit-content; display: grid; grid-template-columns: auto auto; - gap: deprecated.$s-4; + gap: var(--sp-xs); align-items: center; padding: 0; @@ -66,9 +84,9 @@ .button-footer { display: flex; - margin-left: auto; + margin-inline-start: auto; justify-content: flex-end; - gap: deprecated.$s-6; + gap: px2rem(6); } .edit-theme-footer { @@ -97,18 +115,19 @@ .create-theme-wrapper { display: flex; flex-direction: column; - gap: deprecated.$s-24; + gap: var(--sp-xxl); } .close-btn { position: absolute; - top: deprecated.$s-8; - right: deprecated.$s-6; + inset-block-start: var(--sp-s); + inset-inline-end: px2rem(6); } .theme-group-label { color: var(--color-foreground-secondary); - margin: 0 0 deprecated.$s-12 0; + margin-block: 0 var(--sp-m); + margin-inline: 0; padding: 0; } @@ -116,7 +135,7 @@ display: flex; align-items: center; justify-content: flex-start; - gap: deprecated.$s-4; + gap: var(--sp-xs); } .group-title-icon { @@ -124,35 +143,36 @@ } .group-title-name { - flex-grow: 1; + @include text-ellipsis; - @include deprecated.text-ellipsis; + flex-grow: 1; } .theme-group-rows-wrapper { display: flex; flex-direction: column; - gap: deprecated.$s-6; + gap: px2rem(6); margin: 0; } .theme-group-wrapper { display: flex; flex-direction: column; - margin-block-start: deprecated.$s-6; + margin-block-start: px2rem(6); overflow-y: auto; - gap: deprecated.$s-32; + gap: var(--sp-xxxl); + max-block-size: calc(100dvh - #{px2rem(448)}); } .theme-row { align-items: center; display: flex; justify-content: space-between; - gap: deprecated.$s-16; + gap: var(--sp-l); } .theme-name-row { - @include deprecated.text-ellipsis; + @include text-ellipsis; flex-grow: 1; } @@ -164,13 +184,13 @@ .theme-actions-row { align-items: center; display: flex; - gap: deprecated.$s-6; + gap: px2rem(6); flex-shrink: 0; } .sets-count-button { - padding: deprecated.$s-6; - padding-left: deprecated.$s-12; + padding: px2rem(6); + padding-inline-start: var(--sp-m); } .label-wrapper { @@ -183,32 +203,32 @@ .edit-theme-wrapper { display: flex; flex-direction: column; - gap: deprecated.$s-24; + gap: var(--sp-xxl); inline-size: 100%; } .sets-list-wrapper { - border: deprecated.$s-1 solid color-mix(in hsl, var(--color-foreground-secondary) 30%, transparent); - border-radius: deprecated.$s-8; + border: $b-1 solid color-mix(in hsl, var(--color-foreground-secondary) 30%, transparent); + border-radius: $br-8; overflow-y: auto; - max-height: deprecated.$s-452; + max-block-size: px2rem(452); } .sets-count-empty-button { text-transform: lowercase; - padding: deprecated.$s-6; - padding-left: deprecated.$s-12; + padding: px2rem(6); + padding-inline-start: var(--sp-m); } .group-input-wrapper { position: relative; display: flex; flex-direction: column; - gap: deprecated.$s-4; + gap: var(--sp-xs); } .edit-theme-inputs-wrapper { display: grid; grid-template-columns: 0.6fr 1fr; - gap: deprecated.$s-12; + gap: var(--sp-m); } diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.cljs b/frontend/src/app/main/ui/workspace/top_toolbar.cljs index 5635365543..2df664ec9a 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.cljs +++ b/frontend/src/app/main/ui/workspace/top_toolbar.cljs @@ -24,7 +24,6 @@ [app.util.i18n :as i18n :refer [tr]] [app.util.timers :as ts] [okulary.core :as l] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (mf/defc image-upload* @@ -209,8 +208,8 @@ :aria-label (tr "workspace.toolbar.plugins" (sc/get-tooltip :plugins)) :class (stl/css :main-toolbar-options-button) :on-click #(st/emit! - (ptk/data-event ::ev/event {::ev/name "open-plugins-manager" - ::ev/origin "workspace:toolbar"}) + (ev/event {::ev/name "open-plugins-manager" + ::ev/origin "workspace:toolbar"}) (modal/show :plugin-management {})) :data-tool "plugins" :data-testid "plugins-btn"} diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index a7bb343fb4..10c0b08b3c 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -97,6 +97,7 @@ {:keys [options-mode tooltip + preview-id show-distances? picking-color?]} wglobal @@ -199,7 +200,7 @@ on-click (actions/on-click hover selected edition path-drawing? drawing-tool space? selrect z?) on-context-menu (actions/on-context-menu hover hover-ids read-only?) - on-double-click (actions/on-double-click hover hover-ids hover-top-frame-id path-drawing? base-objects edition drawing-tool z? read-only?) + on-double-click (actions/on-double-click hover hover-ids selected hover-top-frame-id path-drawing? base-objects edition drawing-tool z? read-only?) comp-inst-ref (mf/use-ref false) on-drag-enter (actions/on-drag-enter comp-inst-ref) @@ -308,29 +309,34 @@ (hooks/setup-viewport-size vport viewport-ref) (hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?) (hooks/setup-keyboard alt? mod? space? z? shift?) - (hooks/setup-hover-shapes page-id move-stream base-objects transform selected mod? hover measure-hover - hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures? read-only?) + (hooks/setup-hover-shapes page-id move-stream base-objects selected mod? hover measure-hover + hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures? read-only? transform) (hooks/setup-viewport-modifiers modifiers base-objects) (hooks/setup-shortcuts path-editing? path-drawing? text-editing? grid-editing?) (hooks/setup-active-frames base-objects hover-ids selected active-frames zoom transform vbox) - [:div {:class (stl/css :viewport) :style #js {"--zoom" zoom} :data-testid "viewport"} - (when (:can-edit permissions) - (if read-only? - [:> view-only-bar* {}] - [:* - (when-not hide-ui? - [:> top-toolbar* {:layout layout}]) + [:div {:class (stl/css :viewport) :style {"--zoom" zoom} :data-testid "viewport"} + (cond + (some? preview-id) + nil - (when (and ^boolean path-editing? - ^boolean single-select?) - [:> path-edition-bar* {:shape editing-shape - :edit-path-state edit-path-state - :layout layout}]) + (and read-only? (:can-edit permissions)) + [:> view-only-bar* {}] - (when (and ^boolean grid-editing? - ^boolean single-select?) - [:> grid-edition-bar* {:shape editing-shape}])])) + :else + [:* + (when-not hide-ui? + [:> top-toolbar* {:layout layout}]) + + (when (and ^boolean path-editing? + ^boolean single-select?) + [:> path-edition-bar* {:shape editing-shape + :edit-path-state edit-path-state + :layout layout}]) + + (when (and ^boolean grid-editing? + ^boolean single-select?) + [:> grid-edition-bar* {:shape editing-shape}])]) [:div {:class (stl/css :viewport-overlays)} ;; The behaviour inside a foreign object is a bit different that in plain HTML so we wrap @@ -356,10 +362,8 @@ :zoom zoom}]) (when picking-color? - [:& pixel-overlay/pixel-overlay {:vport vport - :vbox vbox - :layout layout - :viewport-ref viewport-ref}])] + [:> pixel-overlay/pixel-overlay* {:vport vport + :viewport-ref viewport-ref}])] [:svg {:id "render" @@ -404,7 +408,9 @@ ;; Render root shape [:& shapes/root-shape {:key (str page-id) :objects base-objects - :active-frames @active-frames}]]]] + :active-frames @active-frames + ;; disable thumbnails when previewing a version + :disable-thumbnails (some? preview-id)}]]]] [:svg.viewport-controls {:xmlns "http://www.w3.org/2000/svg" @@ -455,7 +461,7 @@ (last)) outlined-frame (get objects outlined-frame-id)] [:* - [:& outline/shape-outlines + [:> outline/shape-outlines* {:objects base-objects :hover #{outlined-frame-id} :zoom zoom @@ -463,13 +469,13 @@ (when (ctl/any-layout? outlined-frame) [:g.ghost-outline - [:& outline/shape-outlines + [:> outline/shape-outlines* {:objects base-objects :selected selected :zoom zoom}]])])) (when show-outlines? - [:& outline/shape-outlines + [:> outline/shape-outlines* {:objects base-objects :selected selected :hover #{(:id @hover) @frame-hover} @@ -494,8 +500,16 @@ :zoom zoom :modifiers modifiers}]) + (when (and (seq selected-shapes) + (not transform) + (not text-editing?) + (not edition)) + [:> msr/selection-size-badge* + {:selrect (gsh/shapes->rect selected-shapes) + :zoom zoom}]) + (when show-measures? - [:& msr/measurement + [:> msr/measurement* {:bounds vbox :selected-shapes selected-shapes :frame selected-frame @@ -504,7 +518,7 @@ ;; Show distances during movement with ALT (when (and (= transform :move) @alt? (seq selected-shapes)) - [:& msr/measurement + [:> msr/measurement* {:bounds vbox :selected-shapes selected-shapes :frame selected-frame @@ -516,7 +530,7 @@ duplicated-info (get-in @(deref state-var) [:workspace-local :duplicated])] (when (and (= transform :move) @alt? duplicated-info) [:g.duplicated-distance - [:& msr/distance-display + [:> msr/distance-display* {:from (get duplicated-info :selrect-original) :to (get duplicated-info :selrect-duplicated) :zoom zoom @@ -601,7 +615,7 @@ :focus focus}]) (when show-snap-distance? - [:& snap-distances/snap-distances + [:> snap-distances/snap-distances* {:layout layout :zoom zoom :transform transform @@ -647,34 +661,34 @@ ;; DEBUG LAYOUT DROP-ZONES (when (dbg/enabled? :layout-drop-zones) - [:& wvd/debug-drop-zones {:selected-shapes selected-shapes - :objects base-objects - :hover-top-frame-id @hover-top-frame-id - :zoom zoom}]) + [:> wvd/debug-drop-zones* {:selected-shapes selected-shapes + :objects base-objects + :hover-top-frame-id @hover-top-frame-id + :zoom zoom}]) (when (dbg/enabled? :layout-content-bounds) - [:& wvd/debug-content-bounds {:selected-shapes selected-shapes - :objects base-objects - :hover-top-frame-id @hover-top-frame-id - :zoom zoom}]) + [:> wvd/debug-content-bounds* {:selected-shapes selected-shapes + :objects base-objects + :hover-top-frame-id @hover-top-frame-id + :zoom zoom}]) (when (dbg/enabled? :layout-lines) - [:& wvd/debug-layout-lines {:selected-shapes selected-shapes - :objects base-objects - :hover-top-frame-id @hover-top-frame-id - :zoom zoom}]) - - (when (dbg/enabled? :parent-bounds) - [:& wvd/debug-parent-bounds {:selected-shapes selected-shapes + [:> wvd/debug-layout-lines* {:selected-shapes selected-shapes :objects base-objects :hover-top-frame-id @hover-top-frame-id :zoom zoom}]) + (when (dbg/enabled? :parent-bounds) + [:> wvd/debug-parent-bounds* {:selected-shapes selected-shapes + :objects base-objects + :hover-top-frame-id @hover-top-frame-id + :zoom zoom}]) + (when (dbg/enabled? :grid-layout) - [:& wvd/debug-grid-layout {:selected-shapes selected-shapes - :objects base-objects - :hover-top-frame-id @hover-top-frame-id - :zoom zoom}]) + [:> wvd/debug-grid-layout* {:selected-shapes selected-shapes + :objects base-objects + :hover-top-frame-id @hover-top-frame-id + :zoom zoom}]) (when show-selection-handlers? [:g.selection-handlers {:clipPath "url(#clip-handlers)"} diff --git a/frontend/src/app/main/ui/workspace/viewport/actions.cljs b/frontend/src/app/main/ui/workspace/viewport/actions.cljs index c3c26ab6a5..fb6837b70c 100644 --- a/frontend/src/app/main/ui/workspace/viewport/actions.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/actions.cljs @@ -196,10 +196,10 @@ (st/emit! (dw/increase-zoom pt))))))))) (defn on-double-click - [hover hover-ids hover-top-frame-id drawing-path? objects edition drawing-tool z? read-only?] + [hover hover-ids selected hover-top-frame-id drawing-path? objects edition drawing-tool z? read-only?] (mf/use-callback - (mf/deps @hover @hover-ids @hover-top-frame-id drawing-path? edition drawing-tool @z? read-only?) + (mf/deps @hover @hover-ids selected @hover-top-frame-id drawing-path? edition drawing-tool @z? read-only?) (fn [event] (dom/stop-propagation event) (when-not @z? @@ -208,7 +208,16 @@ alt? (kbd/alt? event) meta? (kbd/meta? event) - {:keys [id type] :as shape} (or @hover (get objects (first @hover-ids))) + selected-id-under-cursor + (->> @hover-ids + (filter selected) + last) + + {:keys [id type] :as shape} + (or (when selected-id-under-cursor + (get objects selected-id-under-cursor)) + @hover + (get objects (first @hover-ids))) editable? (contains? #{:text :rect :path :image :circle} type) @@ -360,16 +369,14 @@ (rx/push! move-stream pt) (reset! last-position raw-pt) - (st/emit! (mse/->PointerEvent :delta delta - (kbd/ctrl? event) - (kbd/shift? event) - (kbd/alt? event) - (kbd/meta? event))) + ;; Single store emit per move: viewport `pt` + `movement` (old :delta `pt`) avoids + ;; doubling Potok + `st/stream` work on every pointermove. (st/emit! (mse/->PointerEvent :viewport pt (kbd/ctrl? event) (kbd/shift? event) (kbd/alt? event) - (kbd/meta? event)))))))) + (kbd/meta? event) + delta))))))) (defn- schedule-zoom! "Accumulate a compound zoom scale and a cursor point into `state`, scheduling diff --git a/frontend/src/app/main/ui/workspace/viewport/debug.cljs b/frontend/src/app/main/ui/workspace/viewport/debug.cljs index 2b1587533b..d213533852 100644 --- a/frontend/src/app/main/ui/workspace/viewport/debug.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/debug.cljs @@ -21,17 +21,11 @@ [rumext.v2 :as mf])) ;; Helper to debug the bounds when set the "hug" content property -(mf/defc debug-content-bounds +(mf/defc debug-content-bounds* "Debug component to show the auto-layout drop areas" - {::mf/wrap-props false} - [props] + [{:keys [objects zoom selected-shapes hover-top-frame-id]}] - (let [objects (unchecked-get props "objects") - zoom (unchecked-get props "zoom") - selected-shapes (unchecked-get props "selected-shapes") - hover-top-frame-id (unchecked-get props "hover-top-frame-id") - - selected-frame + (let [selected-frame (when (and (= (count selected-shapes) 1) (= :frame (-> selected-shapes first :type))) (first selected-shapes)) @@ -72,17 +66,11 @@ :r (/ 4 zoom) :style {:fill "red"}}])]])))) -(mf/defc debug-layout-lines +(mf/defc debug-layout-lines* "Debug component to show the auto-layout drop areas" - {::mf/wrap-props false} - [props] + [{:keys [objects zoom selected-shapes hover-top-frame-id]}] - (let [objects (unchecked-get props "objects") - zoom (unchecked-get props "zoom") - selected-shapes (unchecked-get props "selected-shapes") - hover-top-frame-id (unchecked-get props "hover-top-frame-id") - - selected-frame + (let [selected-frame (when (and (= (count selected-shapes) 1) (= :frame (-> selected-shapes first :type))) (first selected-shapes)) @@ -117,18 +105,12 @@ [:polygon {:points (->> points (map #(dm/fmt "%, %" (:x %) (:y %))) (str/join " ")) :style {:stroke "red" :stroke-width (/ 2 zoom) :stroke-dasharray (dm/str (/ 10 zoom) " " (/ 5 zoom))}}]]))])))) -(mf/defc debug-drop-zones +(mf/defc debug-drop-zones* "Debug component to show the auto-layout drop areas" - {::mf/wrap [#(mf/memo' % (mf/check-props ["objects" "selected-shapes" "hover-top-frame-id"]))] - ::mf/wrap-props false} - [props] + {::mf/wrap [#(mf/memo' % (mf/check-props ["objects" "selected-shapes" "hover-top-frame-id"]))]} + [{:keys [objects zoom selected-shapes hover-top-frame-id]}] - (let [objects (unchecked-get props "objects") - zoom (unchecked-get props "objects") - selected-shapes (unchecked-get props "selected-shapes") - hover-top-frame-id (unchecked-get props "hover-top-frame-id") - - selected-frame + (let [selected-frame (when (and (= (count selected-shapes) 1) (= :frame (-> selected-shapes first :type))) (first selected-shapes)) @@ -159,15 +141,11 @@ :fill "black"} (:index drop-area)]])])))) -(mf/defc shape-parent-bound - {::mf/wrap [#(mf/memo' % (mf/check-props ["shape" "parent"]))] - ::mf/wrap-props false} - [props] +(mf/defc shape-parent-bound* + {::mf/wrap [#(mf/memo' % (mf/check-props ["shape" "parent"]))]} + [{:keys [shape parent zoom]}] - (let [shape (unchecked-get props "shape") - parent (unchecked-get props "parent") - zoom (unchecked-get props "zoom") - [i1 i2 i3 i4] (gpo/parent-coords-bounds (:points shape) (:points parent))] + (let [[i1 i2 i3 i4] (gpo/parent-coords-bounds (:points shape) (:points parent))] [:* [:polygon {:points (->> [i1 i2 i3 i4] (map #(dm/fmt "%,%" (:x %) (:y %))) (str/join ",")) :style {:fill "none" :stroke "red" :stroke-width (/ 1 zoom)}}] @@ -183,16 +161,10 @@ :y2 (:y i4) :style {:stroke "blue" :stroke-width (/ 1 zoom)}}]])) -(mf/defc debug-parent-bounds - {::mf/wrap-props false} - [props] +(mf/defc debug-parent-bounds* + [{:keys [objects zoom selected-shapes hover-top-frame-id]}] - (let [objects (unchecked-get props "objects") - zoom (unchecked-get props "zoom") - selected-shapes (unchecked-get props "selected-shapes") - hover-top-frame-id (unchecked-get props "hover-top-frame-id") - - selected-frame + (let [selected-frame (when (and (= (count selected-shapes) 1) (= :frame (-> selected-shapes first :type))) (first selected-shapes)) @@ -207,10 +179,10 @@ [:g.debug-parent-bounds {:pointer-events "none"} (for [[idx child] (d/enumerate children)] [:* - [:> shape-parent-bound {:key (dm/str "bound-" idx) - :zoom zoom - :shape child - :parent parent}] + [:> shape-parent-bound* {:key (dm/str "bound-" idx) + :zoom zoom + :shape child + :parent parent}] (let [child-bounds (:points child) points @@ -223,16 +195,10 @@ :r (/ 2 zoom) :style {:fill "red"}}]))])])))) -(mf/defc debug-grid-layout - {::mf/wrap-props false} - [props] +(mf/defc debug-grid-layout* + [{:keys [objects zoom selected-shapes hover-top-frame-id]}] - (let [objects (unchecked-get props "objects") - zoom (unchecked-get props "zoom") - selected-shapes (unchecked-get props "selected-shapes") - hover-top-frame-id (unchecked-get props "hover-top-frame-id") - - selected-frame + (let [selected-frame (when (and (= (count selected-shapes) 1) (= :frame (-> selected-shapes first :type))) (first selected-shapes)) @@ -277,13 +243,9 @@ :style {:stroke "red" :stroke-width (/ 1 zoom)}}]))])))) -(mf/defc debug-text-wasm-position-data - {::mf/wrap-props false} - [props] - (let [zoom (unchecked-get props "zoom") - selected-shapes (unchecked-get props "selected-shapes") - - selected-text +(mf/defc debug-text-wasm-position-data* + [{:keys [zoom selected-shapes]}] + (let [selected-text (when (and (= (count selected-shapes) 1) (= :text (-> selected-shapes first :type))) (first selected-shapes)) diff --git a/frontend/src/app/main/ui/workspace/viewport/gradients.cljs b/frontend/src/app/main/ui/workspace/viewport/gradients.cljs index cd2623fd47..525a4fd7bb 100644 --- a/frontend/src/app/main/ui/workspace/viewport/gradients.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/gradients.cljs @@ -448,7 +448,6 @@ :fill "var(--app-white)"}]))])) (mf/defc gradient-handlers-impl* - {::mf/props :obj} [{:keys [zoom stops gradient editing shape]}] (let [transform (gsh/transform-matrix shape) transform-inverse (gsh/inverse-transform-matrix shape) @@ -517,8 +516,7 @@ :on-change-width on-change-width}])) (mf/defc gradient-handlers* - {::mf/wrap [mf/memo] - ::mf/props :obj} + {::mf/wrap [mf/memo]} [{:keys [id zoom]}] (let [shape-ref (mf/use-memo (mf/deps id) #(refs/object-by-id id)) shape (mf/deref shape-ref) diff --git a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs index 18e4045c18..f0d5237205 100644 --- a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs @@ -177,14 +177,14 @@ (dw/increase-zoom))))))) (defn setup-hover-shapes - [page-id move-stream objects transform selected mod? hover measure-hover hover-ids hover-top-frame-id hover-disabled? focus zoom show-measures? read-only?] + [page-id move-stream objects selected mod? hover measure-hover hover-ids hover-top-frame-id hover-disabled? focus zoom show-measures? read-only? transform] (let [;; We use ref so we don't recreate the stream on a change zoom-ref (mf/use-ref zoom) mod-ref (mf/use-ref @mod?) - transform-ref (mf/use-ref nil) selected-ref (mf/use-ref selected) hover-disabled-ref (mf/use-ref hover-disabled?) focus-ref (mf/use-ref focus) + transform-ref (mf/use-ref transform) last-point-ref (mf/use-var nil) mod-str (mf/use-memo #(rx/subject)) @@ -197,7 +197,7 @@ rect (grc/center->rect point (/ 5 zoom))] (if (mf/ref-val hover-disabled-ref) - (rx/of nil) + (rx/of []) (->> (mw/ask-buffered! {:cmd :index/query-selection :page-id page-id @@ -222,7 +222,6 @@ (->> move-stream (rx/tap #(reset! last-point-ref %)) - ;; When transforming shapes we stop querying the worker (rx/merge-map query-point))) (rx/share))) @@ -231,10 +230,6 @@ (->> over-shapes-stream (rx/debounce 50))] ;; Refresh the refs on a value change - (mf/use-effect - (mf/deps transform) - #(mf/set-ref-val! transform-ref transform)) - (mf/use-effect (mf/deps zoom) #(mf/set-ref-val! zoom-ref zoom)) @@ -257,6 +252,10 @@ (mf/deps focus) #(mf/set-ref-val! focus-ref focus)) + (mf/use-effect + (mf/deps transform) + #(mf/set-ref-val! transform-ref transform)) + (hooks/use-stream over-shapes-stream-debounced (mf/deps objects) @@ -367,7 +366,9 @@ (get objects)))] (reset! hover hover-shape) (reset! measure-hover measure-hover-shape) - (reset! hover-ids ids))) + ;; Skip hover-ids update during drag + (when (not= :move (mf/ref-val transform-ref)) + (reset! hover-ids ids)))) (fn [] ;; Clean the cache @@ -467,6 +468,11 @@ [path-editing? drawing-path? text-editing? grid-editing?] (hooks/use-shortcuts ::workspace wsc/shortcuts) + (mf/with-effect [] + (.addEventListener js/window "keydown" wsc/on-display-guides-keydown) + (fn [] + (.removeEventListener js/window "keydown" wsc/on-display-guides-keydown))) + (mf/with-effect [path-editing? drawing-path? grid-editing?] (cond grid-editing? diff --git a/frontend/src/app/main/ui/workspace/viewport/interactions.cljs b/frontend/src/app/main/ui/workspace/viewport/interactions.cljs index 8b1ae15552..9490cd1785 100644 --- a/frontend/src/app/main/ui/workspace/viewport/interactions.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/interactions.cljs @@ -19,7 +19,7 @@ [app.main.store :as st] [app.main.ui.context :as muc] [app.main.ui.shapes.embed :as embed] - [app.main.ui.workspace.viewport.outline :refer [outline]] + [app.main.ui.workspace.viewport.outline :refer [outline*]] [app.util.dom :as dom] [cuerdas.core :as str] [okulary.core :as l] @@ -196,9 +196,9 @@ :d pdata}] (when dest-shape - [:& outline {:zoom zoom - :shape dest-shape - :color stroke-color}]) + [:> outline* {:zoom zoom + :shape dest-shape + :color stroke-color}]) [:> interaction-marker* {:index index :x orig-x diff --git a/frontend/src/app/main/ui/workspace/viewport/outline.cljs b/frontend/src/app/main/ui/workspace/viewport/outline.cljs index 853addb761..6b5d52798a 100644 --- a/frontend/src/app/main/ui/workspace/viewport/outline.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/outline.cljs @@ -19,13 +19,9 @@ [clojure.set :as set] [rumext.v2 :as mf])) -(mf/defc outline - {::mf/wrap-props false} - [props] - (let [shape (unchecked-get props "shape") - modifier (unchecked-get props "modifier") - - zoom (d/nilv (unchecked-get props "zoom") 1) +(mf/defc outline* + [{:keys [shape modifier zoom]}] + (let [zoom (d/nilv zoom 1) shape (gsh/transform-shape shape (:modifiers modifier)) transform (gsh/transform-str shape) @@ -95,33 +91,22 @@ [:> outline-type props])) -(mf/defc shape-outlines-render - {::mf/wrap-props false - ::mf/wrap [#(mf/memo' % (mf/check-props ["shapes" "zoom" "modifiers"]))]} - [props] - (let [shapes (unchecked-get props "shapes") - zoom (unchecked-get props "zoom") - modifiers (unchecked-get props "modifiers")] +(mf/defc shape-outlines-render* + {::mf/wrap [#(mf/memo' % (mf/check-props ["shapes" "zoom" "modifiers"]))]} + [{:keys [shapes zoom modifiers]}] + (for [shape shapes] + (let [shape-id (dm/get-prop shape :id) + modifier (get modifiers shape-id)] + [:> outline* {:key (dm/str "outline-" shape-id) + :shape shape + :modifier modifier + :zoom zoom}]))) - (for [shape shapes] - (let [shape-id (dm/get-prop shape :id) - modifier (get modifiers shape-id)] - [:& outline {:key (dm/str "outline-" shape-id) - :shape shape - :modifier modifier - :zoom zoom}])))) - -(mf/defc shape-outlines - {::mf/wrap-props false} - [props] - (let [selected (or (obj/get props "selected") #{}) - hover (or (obj/get props "hover") #{}) - highlighted (or (obj/get props "highlighted") #{}) - - objects (obj/get props "objects") - edition (obj/get props "edition") - zoom (obj/get props "zoom") - modifiers (obj/get props "modifiers") +(mf/defc shape-outlines* + [{:keys [selected hover highlighted objects edition zoom modifiers]}] + (let [selected (or selected #{}) + hover (or hover #{}) + highlighted (or highlighted #{}) lookup (d/getf objects) edition? (fn [o] (= edition o)) @@ -139,6 +124,6 @@ shapes (hooks/use-equal-memo shapes)] [:g.outlines.blurrable - [:& shape-outlines-render {:shapes shapes - :zoom zoom - :modifiers modifiers}]])) + [:> shape-outlines-render* {:shapes shapes + :zoom zoom + :modifiers modifiers}]])) diff --git a/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.cljs b/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.cljs index 73c6428a5f..587f6cfc7e 100644 --- a/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.cljs @@ -22,6 +22,7 @@ [app.util.globals :as ug] [app.util.keyboard :as kbd] [app.util.object :as obj] + [app.util.timers :as timers] [beicon.v2.core :as rx] [goog.events :as events] [rumext.v2 :as mf])) @@ -53,26 +54,22 @@ new-canvas)))))))) (defn process-pointer-move - [viewport-node canvas canvas-image-data zoom-view-context client-x client-y] + [viewport-node canvas canvas-image-data zoom-view-context last-picked-color client-x client-y] (when-let [image-data (mf/ref-val canvas-image-data)] (when-let [zoom-view-node (dom/get-element "picker-detail")] (when-not (mf/ref-val zoom-view-context) (mf/set-ref-val! zoom-view-context (.getContext zoom-view-node "2d"))) - (let [canvas-width 260 + (let [canvas-width 260 canvas-height 140 {brx :left bry :top} (dom/get-bounding-rect viewport-node) x (mth/floor (- client-x brx)) y (mth/floor (- client-y bry)) - zoom-context (mf/ref-val zoom-view-context) - offset (* (+ (* y (unchecked-get image-data "width")) x) 4) - rgba (unchecked-get image-data "data") + img-width (unchecked-get image-data "width") + img-height (unchecked-get image-data "height") - r (obj/get rgba (+ 0 offset)) - g (obj/get rgba (+ 1 offset)) - b (obj/get rgba (+ 2 offset)) - a (obj/get rgba (+ 3 offset)) + zoom-context (mf/ref-val zoom-view-context) sx (- x 32) sy (if (cfg/check-browser? :safari) y (- y 17)) @@ -82,29 +79,45 @@ dy 0 dw canvas-width dh canvas-height] + (when (obj/get zoom-context "imageSmoothingEnabled") (obj/set! zoom-context "imageSmoothingEnabled" false)) (.clearRect zoom-context 0 0 canvas-width canvas-height) (.drawImage zoom-context canvas sx sy sw sh dx dy dw dh) - (js/requestAnimationFrame - (fn [] - (st/emit! (dwc/pick-color [r g b a])))))))) + + ;; Only pick color when cursor is within canvas bounds to avoid garbage pixels + (when (and (>= x 0) (< x img-width) (>= y 0) (< y img-height)) + (let [offset (* (+ (* y img-width) x) 4) + rgba (unchecked-get image-data "data") + r (obj/get rgba (+ 0 offset)) + g (obj/get rgba (+ 1 offset)) + b (obj/get rgba (+ 2 offset)) + a (obj/get rgba (+ 3 offset)) + color [r g b a]] + ;; Store latest color synchronously so the click handler always reads + ;; the correct pixel even before the rAF fires (fixes race condition) + (mf/set-ref-val! last-picked-color color) + (timers/raf + (fn [] + (st/emit! (dwc/pick-color color)))))))))) -(mf/defc pixel-overlay - {::mf/wrap-props false} - [props] - (let [vport (unchecked-get props "vport") - - viewport-ref (unchecked-get props "viewport-ref") - viewport-node (mf/ref-val viewport-ref) +(mf/defc pixel-overlay* + [{:keys [vport viewport-ref]}] + (let [viewport-node (mf/ref-val viewport-ref) canvas (get-offscreen-canvas (:width vport) (:height vport)) canvas-context (.getContext canvas "2d" #js {:willReadFrequently true}) canvas-image-data (mf/use-ref nil) zoom-view-context (mf/use-ref nil) - canvas-ready (mf/use-state false) - initial-mouse-pos (mf/use-state {:x 0 :y 0}) + ;; Holds the last successfully picked [r g b a] synchronously so that + ;; the pointer-down handler always has the current pixel, regardless of + ;; whether the rAF-deferred store update has fired yet. + last-picked-color (mf/use-ref nil) + ;; Use a ref (not state) so tracking the cursor doesn't cause re-renders. + ;; Updated by both on-mouse-enter and a document-level pointermove listener + ;; so that the position is always current when the canvas first becomes ready. + initial-mouse-pos (mf/use-ref {:x 0 :y 0}) update-str (rx/subject) handle-keydown @@ -121,8 +134,15 @@ (fn [event] (dom/prevent-default event) (dom/stop-propagation event) - (st/emit! (dwu/start-undo-transaction :mouse-down-picker) - (dwc/pick-color-select true (kbd/shift? event))))) + ;; Emit pick-color synchronously with the latest pixel colour before + ;; pick-color-select, so the colorpicker effect never sees a stale value. + (let [color (mf/ref-val last-picked-color)] + (if (some? color) + (st/emit! (dwu/start-undo-transaction :mouse-down-picker) + (dwc/pick-color color) + (dwc/pick-color-select true (kbd/shift? event))) + (st/emit! (dwu/start-undo-transaction :mouse-down-picker) + (dwc/pick-color-select true (kbd/shift? event))))))) handle-pointer-up-picker (mf/use-callback @@ -143,16 +163,20 @@ :result "image-bitmap"}] (->> (fonts/render-font-styles-cached fonts) (rx/map (fn [styles] - (assoc result - :styles styles))) + (assoc result :styles styles))) (rx/mapcat thr/render-node) (rx/subs! (fn [image-bitmap] (.drawImage canvas-context image-bitmap 0 0) (let [width (unchecked-get canvas "width") height (unchecked-get canvas "height") - image-data (.getImageData canvas-context 0 0 width height)] + image-data (.getImageData canvas-context 0 0 width height) + ;; Read current mouse position from ref so the zoom + ;; is populated immediately even without a mouse-move. + {mx :x my :y} (mf/ref-val initial-mouse-pos)] (mf/set-ref-val! canvas-image-data image-data) - (reset! canvas-ready true)))))))) + (process-pointer-move viewport-node canvas canvas-image-data + zoom-view-context last-picked-color + mx my)))))))) handle-svg-change (mf/use-callback @@ -163,19 +187,28 @@ (mf/use-callback (mf/deps viewport-node) (fn [event] - (let [x (.-clientX event) - y (.-clientY event)] - (reset! initial-mouse-pos {:x x - :y y})))) + (mf/set-ref-val! initial-mouse-pos + {:x (.-clientX event) + :y (.-clientY event)}))) + handle-pointer-move-picker (mf/use-callback (mf/deps viewport-node) (fn [event] - (process-pointer-move viewport-node canvas canvas-image-data zoom-view-context (.-clientX event) (.-clientY event))))] + (process-pointer-move viewport-node canvas canvas-image-data zoom-view-context + last-picked-color (.-clientX event) (.-clientY event))))] (when (obj/get canvas-context "imageSmoothingEnabled") (obj/set! canvas-context "imageSmoothingEnabled" false)) + ;; Move focus to the overlay div on mount so the eyedropper button loses + ;; :focus styling immediately. Without this, prevent-default on pointer-down + ;; keeps focus on the button and it looks "selected" even after picking. + (mf/use-effect + (fn [] + (when-let [node (dom/get-element "pixel-overlay")] + (.focus node)))) + (mf/use-effect (fn [] (let [listener (events/listen ug/document "keydown" handle-keydown)] @@ -202,12 +235,17 @@ ;; Disconnect on unmount #(.disconnect observer)))) + ;; Track the cursor position at document level so initial-mouse-pos is always + ;; current when the canvas first becomes ready — even when the picker is opened + ;; via the "i" shortcut and the cursor hasn't entered/moved over the overlay yet. (mf/use-effect - (mf/deps viewport-node @canvas-ready) (fn [] - (when canvas-ready - (let [{:keys [x y]} @initial-mouse-pos] - (process-pointer-move viewport-node canvas canvas-image-data zoom-view-context x y))))) + (let [listener (events/listen ug/document "pointermove" + (fn [e] + (mf/set-ref-val! initial-mouse-pos + {:x (.-clientX e) + :y (.-clientY e)})))] + #(events/unlistenByKey listener)))) [:div {:id "pixel-overlay" :tab-index 0 @@ -218,12 +256,13 @@ :on-mouse-enter handle-mouse-enter}])) -(defn process-pointer-move-wasm [viewport-node canvas canvas-image-data zoom-view-context client-x client-y] +(defn process-pointer-move-wasm + [viewport-node canvas canvas-image-data zoom-view-context last-picked-color client-x client-y] (when-let [image-data (mf/ref-val canvas-image-data)] (when-let [zoom-view-node (dom/get-element "picker-detail")] (when-not (mf/ref-val zoom-view-context) (mf/set-ref-val! zoom-view-context (.getContext zoom-view-node "2d"))) - (let [zoom-view-width 260 + (let [zoom-view-width 260 zoom-view-height 140 {brx :left bry :top} (dom/get-bounding-rect viewport-node) x (mth/floor (- client-x brx)) @@ -232,41 +271,55 @@ canvas-x (* x wasm.api/dpr) canvas-y (* y wasm.api/dpr) - zoom-context (mf/ref-val zoom-view-context) - ;; the image-data we have is an array of pixels, starting from the - ;; bottom-left corner; so we need to calculate the offset accordingly - inverted-y (- (.-height image-data) canvas-y) - offset (* (+ (* inverted-y (.-width image-data)) canvas-x) 4) - rgba (.-data image-data) + img-width (.-width image-data) + img-height (.-height image-data) - r (obj/get rgba (+ 0 offset)) - g (obj/get rgba (+ 1 offset)) - b (obj/get rgba (+ 2 offset)) - a (obj/get rgba (+ 3 offset)) + zoom-context (mf/ref-val zoom-view-context) sx (- canvas-x 32) sy (if (cfg/check-browser? :safari) canvas-y (- canvas-y 17)) sw 65 sh 35] + (when (obj/get zoom-context "imageSmoothingEnabled") (obj/set! zoom-context "imageSmoothingEnabled" false)) (.clearRect zoom-context 0 0 zoom-view-width zoom-view-height) (.drawImage zoom-context canvas sx sy sw sh 0 0 zoom-view-width zoom-view-height) - ;; FIXME: this is throttled to avoid getting stuck in an inifinite react - ;; update loop. We should fix the global state instead. - (js/requestAnimationFrame - (fn [] - (st/emit! (dwc/pick-color [r g b a])))))))) + + ;; Only pick color when cursor is within canvas bounds to avoid garbage pixels + (when (and (>= canvas-x 0) (< canvas-x img-width) (>= canvas-y 0) (< canvas-y img-height)) + (let [;; image-data pixels start from the bottom-left corner; invert y accordingly + inverted-y (- img-height canvas-y) + offset (* (+ (* inverted-y img-width) canvas-x) 4) + rgba (.-data image-data) + r (obj/get rgba (+ 0 offset)) + g (obj/get rgba (+ 1 offset)) + b (obj/get rgba (+ 2 offset)) + a (obj/get rgba (+ 3 offset)) + color [r g b a]] + ;; Store latest color synchronously so the click handler always reads + ;; the correct pixel even before the rAF fires (fixes race condition) + (mf/set-ref-val! last-picked-color color) + ;; rAF throttles state updates to avoid an infinite React re-render loop + (timers/raf + (fn [] + (st/emit! (dwc/pick-color color)))))))))) (mf/defc pixel-overlay-wasm* - {::mf/wrap-props false} [{:keys [viewport-ref canvas-ref]}] (let [viewport-node (mf/ref-val viewport-ref) canvas (mf/ref-val canvas-ref) canvas-context (mf/use-ref nil) canvas-image-data (mf/use-ref nil) zoom-view-context (mf/use-ref nil) - initial-mouse-pos (mf/use-state {:x 0 :y 0}) + ;; Holds the last successfully picked [r g b a] synchronously so that + ;; the pointer-down handler always has the current pixel, regardless of + ;; whether the rAF-deferred store update has fired yet. + last-picked-color (mf/use-ref nil) + ;; Use a ref (not state) so tracking the cursor doesn't cause re-renders. + ;; Updated by both on-mouse-enter and a document-level pointermove listener + ;; so that the position is always current when the canvas first becomes ready. + initial-mouse-pos (mf/use-ref {:x 0 :y 0}) update-str (rx/subject) handle-keydown @@ -283,8 +336,15 @@ (fn [event] (dom/prevent-default event) (dom/stop-propagation event) - (st/emit! (dwu/start-undo-transaction :mouse-down-picker) - (dwc/pick-color-select true (kbd/shift? event))))) + ;; Emit pick-color synchronously with the latest pixel colour before + ;; pick-color-select, so the colorpicker effect never sees a stale value. + (let [color (mf/ref-val last-picked-color)] + (if (some? color) + (st/emit! (dwu/start-undo-transaction :mouse-down-picker) + (dwc/pick-color color) + (dwc/pick-color-select true (kbd/shift? event))) + (st/emit! (dwu/start-undo-transaction :mouse-down-picker) + (dwc/pick-color-select true (kbd/shift? event))))))) handle-pointer-up-picker (mf/use-callback @@ -300,12 +360,18 @@ (mf/deps canvas-context) (fn [] (when-let [canvas-context (mf/ref-val canvas-context)] - (let [width (.-width canvas) + (let [width (.-width canvas) height (.-height canvas) - buffer (js/Uint8ClampedArray. (* width height 4)) - _ (.readPixels canvas-context 0 0 width height (.-RGBA canvas-context) (.-UNSIGNED_BYTE canvas-context) buffer) - image-data (js/ImageData. buffer width height)] - (mf/set-ref-val! canvas-image-data image-data))))) + buffer (js/Uint8ClampedArray. (* width height 4)) + _ (.readPixels canvas-context 0 0 width height (.-RGBA canvas-context) (.-UNSIGNED_BYTE canvas-context) buffer) + image-data (js/ImageData. buffer width height) + ;; Read current mouse position from ref so the zoom + ;; is populated immediately even without a mouse-move. + {mx :x my :y} (mf/ref-val initial-mouse-pos)] + (mf/set-ref-val! canvas-image-data image-data) + (process-pointer-move-wasm viewport-node canvas canvas-image-data + zoom-view-context last-picked-color + mx my))))) handle-canvas-changed (mf/use-callback @@ -316,25 +382,33 @@ (mf/use-callback (mf/deps viewport-node) (fn [event] - (let [x (.-clientX event) - y (.-clientY event)] - (reset! initial-mouse-pos {:x x - :y y})))) + (mf/set-ref-val! initial-mouse-pos + {:x (.-clientX event) + :y (.-clientY event)}))) + handle-pointer-move-picker (mf/use-callback (mf/deps viewport-node) (fn [event] - (process-pointer-move-wasm viewport-node canvas canvas-image-data zoom-view-context (.-clientX event) (.-clientY event))))] + (process-pointer-move-wasm viewport-node canvas canvas-image-data zoom-view-context last-picked-color (.-clientX event) (.-clientY event))))] (mf/use-effect (mf/deps canvas) (fn [] - (let [context (.getContext canvas "webgl2" #js {:willReadFrequently true, :preserveDrawingBuffer true})] + (let [context (.getContext canvas "webgl2" #js {:willReadFrequently true :preserveDrawingBuffer true})] (mf/set-ref-val! canvas-context context)))) + ;; Move focus to the overlay div on mount so the eyedropper button loses + ;; :focus styling immediately. Without this, prevent-default on pointer-down + ;; keeps focus on the button and it looks "selected" even after picking. + (mf/use-effect + (fn [] + (when-let [node (dom/get-element "pixel-overlay")] + (.focus node)))) + (mf/use-effect (fn [] - (let [listener (events/listen ug/document "keydown" handle-keydown)] + (let [listener (events/listen ug/document "keydown" handle-keydown)] #(events/unlistenByKey listener)))) (mf/use-effect @@ -350,12 +424,17 @@ (fn [] (.removeEventListener ug/document "penpot:wasm:render" handle-canvas-changed))) + ;; Track the cursor position at document level so initial-mouse-pos is always + ;; current when the canvas first becomes ready — even when the picker is opened + ;; via the "i" shortcut and the cursor hasn't entered/moved over the overlay yet. (mf/use-effect - (mf/deps viewport-node canvas canvas-image-data zoom-view-context) (fn [] - (when (some? canvas) - (let [{:keys [x y]} @initial-mouse-pos] - (process-pointer-move-wasm viewport-node canvas canvas-image-data zoom-view-context x y))))) + (let [listener (events/listen ug/document "pointermove" + (fn [e] + (mf/set-ref-val! initial-mouse-pos + {:x (.-clientX e) + :y (.-clientY e)})))] + #(events/unlistenByKey listener)))) [:div {:id "pixel-overlay" :tab-index 0 diff --git a/frontend/src/app/main/ui/workspace/viewport/snap_distances.cljs b/frontend/src/app/main/ui/workspace/viewport/snap_distances.cljs index 97b8c905f8..5e87fed91d 100644 --- a/frontend/src/app/main/ui/workspace/viewport/snap_distances.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/snap_distances.cljs @@ -227,17 +227,9 @@ (rx/combine-latest (query-side lt-side) (query-side gt-side)))) -(mf/defc shape-distance - {::mf/wrap-props false} - [props] - (let [frame (unchecked-get props "frame") - selrect (unchecked-get props "selrect") - page-id (unchecked-get props "page-id") - zoom (unchecked-get props "zoom") - coord (unchecked-get props "coord") - selected (unchecked-get props "selected") - - subject (mf/use-memo #(rx/subject)) +(mf/defc shape-distance* + [{:keys [frame selrect page-id zoom coord selected]}] + (let [subject (mf/use-memo #(rx/subject)) lt-shapes* (mf/use-state nil) @@ -279,27 +271,22 @@ :coord coord :zoom zoom}]))) -(mf/defc snap-distances - {::mf/wrap-props false} - [props] - (let [page-id (unchecked-get props "page-id") - zoom (unchecked-get props "zoom") - selected (unchecked-get props "selected") - selected-shapes (unchecked-get props "selected-shapes") - frame-id (-> selected-shapes first :frame-id) +(mf/defc snap-distances* + [{:keys [page-id zoom selected selected-shapes]}] + (let [frame-id (-> selected-shapes first :frame-id) frame (mf/deref (refs/object-by-id frame-id)) selrect (gsh/shapes->rect selected-shapes)] (when-not (ctl/any-layout? frame) [:g.distance - [:& shape-distance + [:> shape-distance* {:selrect selrect :page-id page-id :frame frame :zoom zoom :coord :x :selected selected}] - [:& shape-distance + [:> shape-distance* {:selrect selrect :page-id page-id :frame frame diff --git a/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs b/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs index b9f4f69cb4..8d6f836b16 100644 --- a/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs @@ -9,7 +9,9 @@ (:require [app.main.data.workspace :as dw] [app.main.data.workspace.common :as dwc] + [app.main.refs :as refs] [app.main.store :as st] + [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.workspace.viewport.grid-layout-editor :refer [grid-edition-actions]] [app.main.ui.workspace.viewport.path-actions :refer [path-actions*]] [app.util.i18n :as i18n :refer [tr]] @@ -20,23 +22,27 @@ ;; branch. (mf/defc view-only-bar* - {::mf/private true} [] - (let [handle-close-view-mode + (let [on-close (mf/use-fn (fn [] (st/emit! :interrupt (dw/set-options-mode :design) - (dwc/set-workspace-read-only false))))] + (dwc/set-workspace-read-only false)))) + render-context-lost? (mf/deref refs/render-context-lost?)] [:div {:class (stl/css :viewport-actions)} [:div {:class (stl/css :viewport-actions-container)} [:div {:class (stl/css :viewport-actions-title)} [:> i18n/tr-html* {:tag-name "span" - :content (tr "workspace.top-bar.view-only")}]] - [:button {:class (stl/css :done-btn) - :on-click handle-close-view-mode} - (tr "workspace.top-bar.read-only.done")]]])) + :content (tr (if render-context-lost? + "workspace.top-bar.webgl-context-lost" + "workspace.top-bar.view-only"))}]] + (if render-context-lost? + [:> button* {:variant "primary" :on-click (fn [] (js/location.reload))} + (tr "workspace.top-bar.webgl-context-lost.reload")] + [:> button* {:on-click on-close} + (tr "workspace.top-bar.read-only.done")])]])) (mf/defc path-edition-bar* [{:keys [layout edit-path-state shape]}] diff --git a/frontend/src/app/main/ui/workspace/viewport/top_bar.scss b/frontend/src/app/main/ui/workspace/viewport/top_bar.scss index 5ee297d756..7ce47df666 100644 --- a/frontend/src/app/main/ui/workspace/viewport/top_bar.scss +++ b/frontend/src/app/main/ui/workspace/viewport/top_bar.scss @@ -16,6 +16,7 @@ top: calc(var(--actions-toolbar-position-y) + var(--actions-toolbar-offset-y)); left: 50%; + transform: translateX(-50%); z-index: deprecated.$z-index-20; } @@ -31,11 +32,10 @@ box-shadow: 0 0 deprecated.$s-12 0 var(--menu-shadow-color); gap: deprecated.$s-8; height: deprecated.$s-48; - margin-left: -50%; padding: deprecated.$s-8; cursor: initial; pointer-events: initial; - width: deprecated.$s-400; + min-width: deprecated.$s-400; border: deprecated.$s-2 solid var(--panel-border-color); } @@ -44,6 +44,7 @@ font-size: deprecated.$fs-12; color: var(--color-foreground-secondary); padding-left: deprecated.$s-8; + width: max-content; } .done-btn { diff --git a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs index a47897d2d6..dac8657ad2 100644 --- a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs @@ -33,24 +33,36 @@ (mf/defc pixel-grid* [{:keys [vbox zoom]}] - [:g.pixel-grid - [:defs - [:pattern {:id "pixel-grid" - :viewBox "0 0 1 1" - :width 1 - :height 1 - :pattern-units "userSpaceOnUse"} - [:path {:d "M 1 0 L 0 0 0 1" - :style {:fill "none" - :stroke (if (dbg/enabled? :pixel-grid) "red" "var(--status-color-info-500)") - :stroke-opacity (if (dbg/enabled? :pixel-grid) 1 "0.2") - :stroke-width (str (/ 1 zoom))}}]]] - [:rect {:x (:x vbox) - :y (:y vbox) - :width (:width vbox) - :height (:height vbox) - :fill (str "url(#pixel-grid)") - :style {:pointer-events "none"}}]]) + (let [page (mf/deref refs/workspace-page) + custom-color (:pixel-grid-color page) + custom-alpha (:pixel-grid-opacity page) + debug? (dbg/enabled? :pixel-grid) + stroke (cond + debug? "red" + custom-color custom-color + :else "var(--status-color-info-500)") + opacity (cond + debug? 1 + (some? custom-alpha) custom-alpha + :else 0.2)] + [:g.pixel-grid + [:defs + [:pattern {:id "pixel-grid" + :viewBox "0 0 1 1" + :width 1 + :height 1 + :pattern-units "userSpaceOnUse"} + [:path {:d "M 1 0 L 0 0 0 1" + :style {:fill "none" + :stroke stroke + :stroke-opacity opacity + :stroke-width (str (/ 1 zoom))}}]]] + [:rect {:x (:x vbox) + :y (:y vbox) + :width (:width vbox) + :height (:height vbox) + :fill (str "url(#pixel-grid)") + :style {:pointer-events "none"}}]])) (mf/defc cursor-tooltip* [{:keys [zoom tooltip]}] diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index eb61350ba7..69ef789c27 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -17,6 +17,7 @@ [app.common.types.shape :as cts] [app.common.types.shape.layout :as ctl] [app.main.data.modal :as modal] + [app.main.data.workspace :as dw] [app.main.data.workspace.transforms :as dwt] [app.main.data.workspace.variants :as dwv] [app.main.features :as features] @@ -76,7 +77,42 @@ (defn apply-modifiers-to-selected [selected objects modifiers] - (apply-modifiers-to-objects objects (select-keys (into {} modifiers) selected))) + (apply-modifiers-to-objects objects (select-keys modifiers selected))) + +(defn- apply-wasm-modifiers-to-ids + "Like `apply-modifiers-to-objects`, but only updates ids in `id-set`. During WASM + drag, `wasm-modifiers` can list every propagated descendant (large variants); SVG + outlines only need geometry for `selected` / hover / highlight — not the whole page." + [objects wasm-modifiers id-set] + (if (or (empty? wasm-modifiers) (empty? id-set)) + objects + (reduce + (fn [objs id] + (if-let [t (get wasm-modifiers id)] + (if (contains? objs id) + (update objs id gsh/apply-transform t) + objs) + objs)) + objects + id-set))) + + +(defn- outline-wasm-source-ids + "Superset of shape ids that `shape-outlines` may look up (all outline usages here)." + [base-objects selected highlighted edition hover-ids hover frame-hover] + (let [outlined-frame-id (->> hover-ids + (filter #(cfh/frame-shape? (get base-objects %))) + (remove selected) + (last)) + ids (-> #{} + (into (or selected #{})) + (into (or highlighted #{})) + (into (or hover-ids #{})))] + (cond-> ids + (uuid? (:id hover)) (conj (:id hover)) + (uuid? frame-hover) (conj frame-hover) + (uuid? outlined-frame-id) (conj outlined-frame-id) + (uuid? edition) (disj edition)))) (mf/defc viewport* [{:keys [selected wglobal layout file page palete-size]}] @@ -98,6 +134,7 @@ {:keys [options-mode tooltip show-distances? + preview-id picking-color?]} wglobal @@ -107,7 +144,6 @@ drawing (mf/deref refs/workspace-drawing) focus (mf/deref refs/workspace-focus-selected) wasm-modifiers (mf/deref refs/workspace-wasm-modifiers) - workspace-editor-state (mf/deref refs/workspace-editor-state) file-id (get file :id) @@ -127,11 +163,6 @@ selected-shapes (->> selected (into [] (keep (d/getf objects-modified))) (not-empty)) - - objects-for-outlines - (mf/with-memo [base-objects wasm-modifiers] - (apply-modifiers-to-objects base-objects wasm-modifiers)) - ;; STATE alt? (mf/use-state false) shift? (mf/use-state false) @@ -177,6 +208,18 @@ (when (some? parent-id) (get base-objects parent-id))))) + outline-wasm-ids + (mf/with-memo + [base-objects selected highlighted edition @hover-ids @hover @frame-hover] + (outline-wasm-source-ids base-objects selected highlighted edition @hover-ids @hover @frame-hover)) + + objects-for-outlines + (mf/with-memo + [base-objects wasm-modifiers outline-wasm-ids] + (if (seq wasm-modifiers) + (apply-wasm-modifiers-to-ids base-objects wasm-modifiers outline-wasm-ids) + base-objects)) + zoom (d/check-num zoom 1) drawing-tool (:tool drawing) @@ -212,10 +255,11 @@ ;; True when we are opening a new file or switching to a new page page-transition? (mf/deref wasm.api/page-transition?) + context-loss-overlay? (mf/deref wasm.api/context-loss-overlay?) on-click (actions/on-click hover selected edition path-drawing? drawing-tool space? selrect z?) on-context-menu (actions/on-context-menu hover hover-ids read-only?) - on-double-click (actions/on-double-click hover hover-ids hover-top-frame-id path-drawing? base-objects edition drawing-tool z? read-only?) + on-double-click (actions/on-double-click hover hover-ids selected hover-top-frame-id path-drawing? base-objects edition drawing-tool z? read-only?) comp-inst-ref (mf/use-ref false) on-drag-enter (actions/on-drag-enter comp-inst-ref) @@ -277,7 +321,7 @@ show-selrect? (and selrect (empty? drawing) (not text-editing?) (not page-transition?)) show-measures? (and (not transform) (not path-editing?) - (or show-distances? mode-inspect?) + (or show-distances? mode-inspect? read-only?) (not page-transition?)) show-artboard-names? (and (contains? layout :display-artboard-names) (not page-transition?)) hide-ui? (contains? layout :hide-ui) @@ -326,6 +370,7 @@ offset-y (if selecting-first-level-frame? (:y first-shape) (:y selected-frame)) + rule-area-size (/ rulers/ruler-area-size zoom) preview-blend (-> refs/workspace-preview-blend (mf/deref)) @@ -369,7 +414,7 @@ :else (show-unavailable)))))] (reset! canvas-init? false) - (->> wasm.api/module + (->> @wasm.api/module (p/fmap (fn [ready?] (when ready? (try-init 3))))) @@ -393,7 +438,8 @@ (mf/with-effect [vport] (when (and @canvas-init? @initialized?) - (wasm.api/resize-viewbox (:width vport) (:height vport)))) + (wasm.api/resize-viewbox (:width vport) (:height vport)) + (wasm.api/set-view-box zoom vbox))) (mf/with-effect [@canvas-init? preview-blend] (when (and @canvas-init? preview-blend) @@ -411,7 +457,10 @@ ;; blank canvas (first load) visible while shapes load. ;; The loading overlay is suppressed because on-shapes-ready ;; is set. - (wasm.api/initialize-viewport base-objects zoom vbox :background background) + (wasm.api/initialize-viewport base-objects zoom vbox + :background background + :on-shapes-ready + #(st/emit! (dw/check-file-position-data file-id))) (reset! initialized? true)) (when (and (some? vern) (not= vern (mf/ref-val last-vern-ref))) @@ -424,10 +473,6 @@ (wasm.api/clear-focus-mode) (wasm.api/set-focus-mode focus))))) - (mf/with-effect [vbox zoom] - (when (and @canvas-init? @initialized?) - (wasm.api/set-view-box zoom vbox))) - (mf/with-effect [background] (when (and @canvas-init? @initialized?) (wasm.api/set-canvas-background background))) @@ -450,28 +495,34 @@ (hooks/setup-viewport-size vport viewport-ref) (hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?) (hooks/setup-keyboard alt? mod? space? z? shift?) - (hooks/setup-hover-shapes page-id move-stream base-objects transform selected mod? hover measure-hover - hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures? read-only?) + (hooks/setup-hover-shapes page-id move-stream base-objects selected mod? hover measure-hover + hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures? read-only? transform) (hooks/setup-shortcuts path-editing? path-drawing? text-editing? grid-editing?) (hooks/setup-active-frames base-objects hover-ids selected active-frames zoom transform vbox) [:div {:class (stl/css :viewport) :style #js {"--zoom" zoom} :data-testid "viewport"} - (when (:can-edit permissions) - (if read-only? - [:> view-only-bar* {}] - [:* - (when-not hide-ui? - [:> top-toolbar* {:layout layout}]) - (when (and ^boolean path-editing? - ^boolean single-select?) - [:> path-edition-bar* {:shape editing-shape - :edit-path-state edit-path-state - :layout layout}]) + (cond + (some? preview-id) + nil - (when (and ^boolean grid-editing? - ^boolean single-select?) - [:> grid-edition-bar* {:shape editing-shape}])])) + (and read-only? (:can-edit permissions)) + [:> view-only-bar* {}] + + :else + [:* + (when-not hide-ui? + [:> top-toolbar* {:layout layout}]) + + (when (and ^boolean path-editing? + ^boolean single-select?) + [:> path-edition-bar* {:shape editing-shape + :edit-path-state edit-path-state + :layout layout}]) + + (when (and ^boolean grid-editing? + ^boolean single-select?) + [:> grid-edition-bar* {:shape editing-shape}])]) [:div {:class (stl/css :viewport-overlays)} (when show-comments? @@ -495,8 +546,9 @@ :style {:background-color background :pointer-events "none"}}] - ;; Show the transition image when we are opening a new file or switching to a new page - (when (and page-transition? (some? transition-image-url)) + ;; Show the transition image when switching pages or recovering from WebGL context loss. + (when (and (or page-transition? context-loss-overlay?) + (some? transition-image-url)) (let [src transition-image-url] [:img {:data-testid "canvas-wasm-transition" :src src @@ -507,6 +559,7 @@ :height "100%" :object-fit "cover" :pointer-events "none" + ;; use (when page-transition? "blur(4px)") if we don't want the blur on context loss :filter "blur(4px)"}}])) @@ -561,27 +614,26 @@ :ref text-editor-ref}])) (when show-frame-outline? - (let [outlined-frame-id - (->> @hover-ids - (filter #(cfh/frame-shape? (get base-objects %))) - (remove selected) - (last)) + (let [outlined-frame-id (->> @hover-ids + (filter #(cfh/frame-shape? (get base-objects %))) + (remove selected) + (last)) outlined-frame (get objects outlined-frame-id)] [:* - [:& outline/shape-outlines + [:> outline/shape-outlines* {:objects objects-for-outlines :hover #{outlined-frame-id} :zoom zoom}] (when (ctl/any-layout? outlined-frame) [:g.ghost-outline.blurrable - [:& outline/shape-outlines + [:> outline/shape-outlines* {:objects objects-for-outlines :selected selected :zoom zoom}]])])) (when show-outlines? - [:& outline/shape-outlines + [:> outline/shape-outlines* {:objects objects-for-outlines :selected selected :hover #{(:id @hover) @frame-hover} @@ -604,8 +656,17 @@ {:shape (get base-objects edition) :zoom zoom}]) + (when (and (seq selected-shapes) + (not transform) + (not text-editing?) + (not edition) + (not page-transition?)) + [:> msr/selection-size-badge* + {:selrect (gsh/shapes->rect selected-shapes) + :zoom zoom}]) + (when show-measures? - [:& msr/measurement + [:> msr/measurement* {:bounds vbox :selected-shapes selected-shapes :frame selected-frame @@ -692,7 +753,7 @@ :focus focus}]) (when show-snap-distance? - [:& snap-distances/snap-distances + [:> snap-distances/snap-distances* {:layout layout :zoom zoom :transform transform @@ -733,37 +794,37 @@ ;; DEBUG LAYOUT DROP-ZONES (when (dbg/enabled? :layout-drop-zones) - [:& wvd/debug-drop-zones {:selected-shapes selected-shapes - :objects base-objects - :hover-top-frame-id @hover-top-frame-id - :zoom zoom}]) - - (when (dbg/enabled? :layout-content-bounds) - [:& wvd/debug-content-bounds {:selected-shapes selected-shapes - :objects base-objects - :hover-top-frame-id @hover-top-frame-id - :zoom zoom}]) - - (when (dbg/enabled? :layout-lines) - [:& wvd/debug-layout-lines {:selected-shapes selected-shapes - :objects base-objects - :hover-top-frame-id @hover-top-frame-id - :zoom zoom}]) - - (when (dbg/enabled? :parent-bounds) - [:& wvd/debug-parent-bounds {:selected-shapes selected-shapes - :objects base-objects - :hover-top-frame-id @hover-top-frame-id - :zoom zoom}]) - - (when (dbg/enabled? :grid-layout) - [:& wvd/debug-grid-layout {:selected-shapes selected-shapes + [:> wvd/debug-drop-zones* {:selected-shapes selected-shapes :objects base-objects :hover-top-frame-id @hover-top-frame-id :zoom zoom}]) + (when (dbg/enabled? :layout-content-bounds) + [:> wvd/debug-content-bounds* {:selected-shapes selected-shapes + :objects base-objects + :hover-top-frame-id @hover-top-frame-id + :zoom zoom}]) + + (when (dbg/enabled? :layout-lines) + [:> wvd/debug-layout-lines* {:selected-shapes selected-shapes + :objects base-objects + :hover-top-frame-id @hover-top-frame-id + :zoom zoom}]) + + (when (dbg/enabled? :parent-bounds) + [:> wvd/debug-parent-bounds* {:selected-shapes selected-shapes + :objects base-objects + :hover-top-frame-id @hover-top-frame-id + :zoom zoom}]) + + (when (dbg/enabled? :grid-layout) + [:> wvd/debug-grid-layout* {:selected-shapes selected-shapes + :objects base-objects + :hover-top-frame-id @hover-top-frame-id + :zoom zoom}]) + (when (dbg/enabled? :text-outline) - [:& wvd/debug-text-wasm-position-data + [:> wvd/debug-text-wasm-position-data* {:selected-shapes selected-shapes :objects base-objects :zoom zoom}]) diff --git a/frontend/src/app/plugins/api.cljs b/frontend/src/app/plugins/api.cljs index 68526ae8a4..df27c4b79d 100644 --- a/frontend/src/app/plugins/api.cljs +++ b/frontend/src/app/plugins/api.cljs @@ -273,6 +273,7 @@ (->> (dwm/upload-media-url name file-id url) (rx/take 1) (rx/map format/format-image) + (rx/tap #(st/emit! (se/event plugin-id "add-media"))) (rx/subs! resolve reject))))))) :uploadMediaData @@ -284,11 +285,28 @@ {:file-id file-id :local? false :name name - :blobs [(js/Blob. #js [data] #js {:type mime-type})] + :blobs [(js/Blob. + #js [(cond + (instance? js/Uint8Array data) + data + + (instance? js/ArrayBuffer data) + (js/Uint8Array. data) + + (array? data) + (js/Uint8Array.from data) + + (and (some? data) (= (type data) js/Object)) + (js/Uint8Array.from (js/Object.values data)) + + :else + data)] + #js {:type mime-type})] :on-image identity :on-svg identity}) (rx/take 1) (rx/map format/format-image) + (rx/tap #(st/emit! (se/event plugin-id "add-media"))) (rx/subs! resolve reject)))))) :group @@ -462,6 +480,7 @@ (conj acc (cg/generate-formatted-markup-code objects type resolved-shapes)))) []))] + (st/emit! (se/event plugin-id "copy-inspect-code")) (->> resolved-code (str/join "\n")))))) :generateStyle @@ -508,6 +527,7 @@ (cg/generate-style-code objects type shapes resolved-shapes {:with-prelude? prelude?})))) []))] + (st/emit! (se/event plugin-id "copy-inspect-style")) (dm/str (if prelude? (cg/prelude type) "") (->> resolved-styles @@ -542,7 +562,8 @@ (fn [] (let [file-id (:current-file-id @st/state) id (uuid/next)] - (st/emit! (dw/create-page {:page-id id :file-id file-id})) + (st/emit! (-> (dw/create-page {:page-id id :file-id file-id}) + (se/add-event plugin-id))) (page/page-proxy plugin-id file-id id))) :openPage @@ -648,9 +669,10 @@ ids)] (if valid? (let [variant-id (uuid/next)] - (st/emit! (dwv/combine-as-variants - ids - {:trigger "plugin:combine-as-variants" :variant-id variant-id})) + (st/emit! (-> (dwv/combine-as-variants + ids + {:trigger "plugin:combine-as-variants" :variant-id variant-id}) + (se/add-event plugin-id))) (shape/shape-proxy plugin-id variant-id)) (u/not-valid plugin-id :shapes "One of the components is not on the same page or is already a variant"))))))) diff --git a/frontend/src/app/plugins/comments.cljs b/frontend/src/app/plugins/comments.cljs index 236074142d..bb1361ee71 100644 --- a/frontend/src/app/plugins/comments.cljs +++ b/frontend/src/app/plugins/comments.cljs @@ -17,6 +17,7 @@ [app.plugins.parser :as parser] [app.plugins.register :as r] [app.plugins.shape :as shape] + [app.plugins.system-events :as se] [app.plugins.user :as user] [app.plugins.utils :as u] [app.util.object :as obj] @@ -71,7 +72,13 @@ :else (->> (rp/cmd! :update-comment {:id (:id data) :content content}) (rx/tap #(st/emit! (dc/retrieve-comment-threads file-id))) - (rx/subs! #(swap! data* assoc :content content))))))} + (rx/subs! + (fn [] + (st/emit! (se/event plugin-id "update-comment" + :thread-id thread-id + :id (:id data) + :content-size (count content))) + (swap! data* assoc :content content)))))))} ;; Public methods :remove @@ -87,7 +94,11 @@ :else (->> (rp/cmd! :delete-comment {:id (:id data)}) (rx/tap #(st/emit! (dc/retrieve-comment-threads file-id))) - (rx/subs! #(resolve) reject))))))))) + (rx/subs! + (fn [] + (st/emit! (se/event plugin-id "update-comment" :thread-id thread-id)) + (resolve)) + reject))))))))) (defn comment-thread-proxy? [p] (obj/type-of? p "CommentThreadProxy")) @@ -143,7 +154,8 @@ (u/not-valid plugin-id :resolved "Plugin doesn't have 'comment:write' permission") :else - (do (st/emit! (dc/update-comment-thread (assoc @data* :is-resolved is-resolved))) + (do (st/emit! (-> (dc/update-comment-thread (assoc @data* :is-resolved is-resolved)) + (se/add-event plugin-id))) (swap! data* assoc :is-resolved is-resolved))))} :findComments @@ -178,7 +190,13 @@ (js/Promise. (fn [resolve reject] (->> (rp/cmd! :create-comment {:thread-id (:id data) :content content}) - (rx/subs! #(resolve (comment-proxy plugin-id file-id page-id (:id data) %)) reject)))))) + (rx/subs! + (fn [result] + (st/emit! (se/event plugin-id "create-comment" + :thread-id (:id data) + :file-id file-id + :content-size (count content))) + (resolve (comment-proxy plugin-id file-id page-id (:id data) result))) reject)))))) :remove (fn [] @@ -194,4 +212,5 @@ :else (js/Promise. (fn [resolve] - (st/emit! (dc/delete-comment-thread-on-workspace {:id (:id data)} #(resolve))))))))))) + (st/emit! (-> (dc/delete-comment-thread-on-workspace {:id (:id data)} #(resolve)) + (se/add-event plugin-id))))))))))) diff --git a/frontend/src/app/plugins/file.cljs b/frontend/src/app/plugins/file.cljs index 15c0bf7188..b53f4a5940 100644 --- a/frontend/src/app/plugins/file.cljs +++ b/frontend/src/app/plugins/file.cljs @@ -22,6 +22,7 @@ [app.plugins.page :as page] [app.plugins.parser :as parser] [app.plugins.register :as r] + [app.plugins.system-events :as se] [app.plugins.user :as user] [app.plugins.utils :as u] [app.util.http :as http] @@ -54,7 +55,7 @@ (do (swap! data assoc :label value :created-by "user") (->> (rp/cmd! :update-file-snapshot {:id (:id @data) :label value}) (rx/take 1) - (rx/subs! identity)))))} + (rx/subs! #(st/emit! (se/event "rename-version" :file-id file-id)))))))} :createdBy {:get @@ -78,7 +79,9 @@ :else (let [version-id (get @data :id)] - (st/emit! (dwv/restore-version-from-plugin file-id version-id resolve reject))))))) + (st/emit! + (dwv/restore-version-from-plugin file-id version-id resolve reject) + (se/event plugin-id "restore-version" :file-id file-id))))))) :remove (fn [] @@ -110,10 +113,12 @@ :label (ct/format-inst (:created-at @data) :localized-date)}] (->> (rx/zip (rp/cmd! :get-team-users {:file-id file-id}) (rp/cmd! :update-file-snapshot params)) - (rx/subs! (fn [[users data]] - (let [users (d/index-by :id users)] - (resolve (file-version-proxy plugin-id file-id users @data)))) - reject)))))))))) + (rx/subs! + (fn [[users data]] + (let [users (d/index-by :id users)] + (st/emit! (se/event plugin-id "pin-version" :file-id file-id)) + (resolve (file-version-proxy plugin-id file-id users @data)))) + reject)))))))))) (defn file-proxy? [p] (obj/type-of? p "FileProxy")) @@ -220,7 +225,8 @@ :else (let [page-id (uuid/next)] - (st/emit! (dw/create-page {:page-id page-id :file-id id})) + (st/emit! (-> (dw/create-page {:page-id page-id :file-id id}) + (se/add-event plugin-id))) (page/page-proxy plugin-id id page-id)))) :export @@ -269,6 +275,7 @@ :response-type :buffer})))) (rx/take 1) (rx/map #(js/Uint8Array. (:body %))) + (rx/tap #(st/emit! (se/event plugin-id "export-binary-files" :format format :type type))) (rx/subs! resolve reject)))))))) :findVersions (fn [criteria] @@ -315,7 +322,9 @@ (u/reject-not-valid reject :findVersions "Plugin doesn't have 'content:write' permission") :else - (st/emit! (dwv/create-version-from-plugins id label resolve reject)))))] + (st/emit! + (dwv/create-version-from-plugins id label resolve reject) + (se/event plugin-id "create-version" :file-id id)))))] (-> (js/Promise.all #js [users-promise create-version-promise]) (.then (fn [[users data]] diff --git a/frontend/src/app/plugins/flex.cljs b/frontend/src/app/plugins/flex.cljs index ff6de68488..2b7cd0802f 100644 --- a/frontend/src/app/plugins/flex.cljs +++ b/frontend/src/app/plugins/flex.cljs @@ -13,6 +13,7 @@ [app.main.data.workspace.shapes :as dwsh] [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])) @@ -186,7 +187,6 @@ :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value :p4 value}}))))} - :topPadding {:this true :get #(-> % u/proxy->shape :layout-padding :p1 (d/nilv 0)) @@ -260,11 +260,16 @@ :else (let [child-id (obj/get child "$id") shape (u/locate-shape file-id page-id id) + child-shape (u/locate-shape file-id page-id child-id) index (if (and (u/natural-child-ordering? plugin-id) (not (ctl/reverse? shape))) 0 (count (:shapes shape)))] - (st/emit! (dwsh/relocate-shapes #{child-id} id index))))) + (st/emit! + (dwsh/relocate-shapes #{child-id} id index) + (se/event plugin-id "add-layout-element" + :type (:type child-shape) + :parent-type (:type shape)))))) :horizontalSizing {:this true diff --git a/frontend/src/app/plugins/format.cljs b/frontend/src/app/plugins/format.cljs index f0ff928fc8..9a488a5e71 100644 --- a/frontend/src/app/plugins/format.cljs +++ b/frontend/src/app/plugins/format.cljs @@ -26,6 +26,97 @@ (when (some? coll) (apply array (keep format-fn coll)))) +(defn- numeric-index? + [prop] + (and (string? prop) (boolean (re-matches #"\d+" prop)))) + +(defn- normalize-exclusive-color-props! + [target prop] + (case prop + "fillColor" + (do + (js-delete target "fillColorGradient") + (js-delete target "fillImage")) + + "fillColorGradient" + (do + (js-delete target "fillColor") + (js-delete target "fillImage")) + + "fillImage" + (do + (js-delete target "fillColor") + (js-delete target "fillColorGradient")) + + "strokeColor" + (js-delete target "strokeColorGradient") + + "strokeColorGradient" + (js-delete target "strokeColor") + + nil)) + +(declare wrap-mutable-value) + +(defn- wrap-mutable-object + [^js js-obj commit!] + (doseq [prop (js/Object.keys js-obj)] + (obj/set! js-obj prop (wrap-mutable-value (obj/get js-obj prop) commit!))) + (js/Proxy. js-obj + #js {:set (fn [target prop value] + (obj/set! target prop (wrap-mutable-value value commit!)) + (normalize-exclusive-color-props! target prop) + (commit!) + true) + :deleteProperty (fn [target prop] + (js-delete target prop) + (commit!) + true)})) + +(defn- wrap-mutable-array + [^js js-arr commit!] + (doseq [index (range (.-length js-arr))] + (obj/set! js-arr index (wrap-mutable-value (obj/get js-arr index) commit!))) + (js/Proxy. js-arr + #js {:set (fn [target prop value] + (if (or (numeric-index? prop) (= prop "length")) + (do + (if (numeric-index? prop) + (obj/set! target prop (wrap-mutable-value value commit!)) + (obj/set! target prop value)) + (commit!) + true) + false)) + :deleteProperty (fn [target prop] + (if (numeric-index? prop) + (do + (js-delete target prop) + true) + false))})) + +(defn- wrap-mutable-value + [value commit!] + (cond + (obj/array? value) + (wrap-mutable-array value commit!) + + (obj/plain-object? value) + (wrap-mutable-object value commit!) + + :else + value)) + +(defn wrap-mutable-element + [^js js-obj commit!] + (when (some? js-obj) + (wrap-mutable-value js-obj commit!))) + +(defn mutable-proxy-array + [coll format-fn commit-fn] + (let [raw-arr (format-array format-fn coll) + commit! (fn [] (commit-fn raw-arr))] + (wrap-mutable-array raw-arr commit!))) + (defn format-mixed [value] (if (= value :multiple) @@ -198,16 +289,17 @@ :fillImage (format-image fill-image)}))) (defn format-fills - [fills] - (cond - (= fills :multiple) - "mixed" + ([fills] (format-fills fills nil)) + ([fills commit-fn] + (cond + (= fills :multiple) "mixed" + (= fills "mixed") "mixed" - (= fills "mixed") - "mixed" + (and (some? fills) (fn? commit-fn)) + (mutable-proxy-array fills format-fill commit-fn) - (some? fills) - (format-array format-fill fills))) + :else + (format-array format-fill fills)))) ;; export interface Stroke { ;; strokeColor?: string; @@ -240,9 +332,11 @@ :strokeColorGradient (format-gradient stroke-color-gradient)}))) (defn format-strokes - [strokes] - (when (some? strokes) - (format-array format-stroke strokes))) + ([strokes] (format-strokes strokes nil)) + ([strokes commit-fn] + (if (and (some? strokes) (fn? commit-fn)) + (mutable-proxy-array strokes format-stroke commit-fn) + (format-array format-stroke strokes)))) ;; export interface Blur { ;; id?: string; diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index 1022a1a65c..f34d544495 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -16,7 +16,6 @@ [app.common.types.file :as ctf] [app.common.types.typography :as ctt] [app.common.uuid :as uuid] - [app.main.data.event :as ev] [app.main.data.plugins :as dp] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.texts :as dwt] @@ -27,6 +26,7 @@ [app.plugins.parser :as parser] [app.plugins.register :as r] [app.plugins.shape :as shape] + [app.plugins.system-events :as se] [app.plugins.text :as text] [app.plugins.tokens :as tokens] [app.plugins.utils :as u] @@ -164,7 +164,8 @@ (u/not-valid plugin-id :remove "Plugin doesn't have 'library:write' permission") :else - (st/emit! (dwl/delete-color {:id id})))) + (st/emit! (-> (dwl/delete-color {:id id}) + (se/add-event plugin-id))))) :clone (fn [] @@ -176,7 +177,8 @@ (let [color-id (uuid/next) color (-> (u/locate-library-color file-id id) (assoc :id color-id))] - (st/emit! (dwl/add-color color {:rename? false})) + (st/emit! (-> (dwl/add-color color {:rename? false}) + (se/add-event plugin-id))) (lib-color-proxy plugin-id id color-id)))) :asFill @@ -309,7 +311,8 @@ :else (let [typo (u/proxy->library-typography self) value (dm/str (d/nilv (:path typo) "") " / " value)] - (st/emit! (dwl/rename-typography file-id id value)))))} + (st/emit! (-> (dwl/rename-typography file-id id value) + (se/add-event plugin-id))))))} :path {:this true @@ -488,7 +491,8 @@ (u/not-valid plugin-id :remove "Plugin doesn't have 'library:write' permission") :else - (st/emit! (dwl/delete-typography {:id id})))) + (st/emit! (-> (dwl/delete-typography id) + (se/add-event plugin-id))))) :clone (fn [] @@ -500,7 +504,8 @@ (let [typo-id (uuid/next) typo (-> (u/locate-library-typography file-id id) (assoc :id typo-id))] - (st/emit! (dwl/add-typography typo false)) + (st/emit! (-> (dwl/add-typography typo false) + (se/add-event plugin-id))) (lib-typography-proxy plugin-id id typo-id)))) :applyToText @@ -662,13 +667,13 @@ :addVariant (fn [] (st/emit! - (ev/event {::ev/name "add-new-variant" ::ev/origin "plugin:add-variant"}) + (se/event plugin-id "add-new-variant") (dwv/add-new-variant id))) :addProperty (fn [] (st/emit! - (ev/event {::ev/name "add-new-property" ::ev/origin "plugin:add-property"}) + (se/event plugin-id "add-new-property") (dwv/add-new-property id {:property-value "Value 1"}))) :removeProperty @@ -676,7 +681,7 @@ (if (not (nat-int? pos)) (u/not-valid plugin-id :pos pos) (st/emit! - (ev/event {::ev/name "remove-property" ::ev/origin "plugin:remove-property"}) + (se/event plugin-id "remove-property") (dwv/remove-property id pos)))) :renameProperty @@ -759,7 +764,8 @@ :else (let [id-ref (atom nil)] - (st/emit! (dwl/instantiate-component file-id id (gpt/point 0 0) {:id-ref id-ref :origin "plugin"})) + (st/emit! (-> (dwl/instantiate-component file-id id (gpt/point 0 0) {:id-ref id-ref :origin "plugin"}) + (se/add-event plugin-id))) (shape/shape-proxy plugin-id @id-ref)))) :getPluginData @@ -885,7 +891,7 @@ (when (and component (not (ctk/is-variant? component))) (st/emit! - (ev/event {::ev/name "transform-in-variant" ::ev/origin "plugin:transform-in-variant"}) + (se/event plugin-id "transform-in-variant") (dwv/transform-in-variant (:main-instance-id component)))))) :addVariant @@ -894,7 +900,7 @@ (when (and component (ctk/is-variant? component)) (st/emit! - (ev/event {::ev/name "add-new-variant" ::ev/origin "plugin:add-variant-from-component"}) + (se/event plugin-id "add-new-variant") (dwv/add-new-variant (:main-instance-id component)))))) :setVariantProperty @@ -908,7 +914,7 @@ :else (st/emit! - (ev/event {::ev/name "variant-edit-property-value" ::ev/origin "plugin:edit-property-value"}) + (se/event plugin-id "variant-edit-property-value") (dwv/update-property-value id pos value)))))) (defn library-proxy? [p] @@ -974,7 +980,8 @@ :else (let [color-id (uuid/next)] - (st/emit! (dwl/add-color {:id color-id :name "Color" :color "#000000" :opacity 1} {:rename? false})) + (st/emit! (-> (dwl/add-color {:id color-id :name "Color" :color "#000000" :opacity 1} {:rename? false}) + (se/add-event plugin-id))) (lib-color-proxy plugin-id file-id color-id)))) :createTypography @@ -985,7 +992,8 @@ :else (let [typography-id (uuid/next)] - (st/emit! (dwl/add-typography (ctt/make-typography {:id typography-id :name "Typography"}) false)) + (st/emit! (-> (dwl/add-typography (ctt/make-typography {:id typography-id :name "Typography"}) false) + (se/add-event plugin-id))) (lib-typography-proxy plugin-id file-id typography-id)))) :createComponent @@ -997,7 +1005,8 @@ :else (let [id-ref (atom nil) ids (into #{} (map #(obj/get % "$id")) shapes)] - (st/emit! (dwl/add-component id-ref ids)) + (st/emit! (-> (dwl/add-component id-ref ids) + (se/add-event plugin-id))) (lib-component-proxy plugin-id file-id @id-ref)))) ;; Plugin data @@ -1108,23 +1117,21 @@ :connectLibrary (fn [library-id] - (cond - (not (r/check-permission plugin-id "library:write")) - (u/not-valid plugin-id :connectLibrary "Plugin doesn't have 'library:write' permission") + (js/Promise. + (fn [resolve reject] + (cond + (not (r/check-permission plugin-id "library:write")) + (u/reject-not-valid reject :connectLibrary "Plugin doesn't have 'library:write' permission") - :else - (js/Promise. - (fn [resolve reject] - (cond - (not (string? library-id)) - (do (u/not-valid plugin-id :connectLibrary library-id) - (reject nil)) + (not (string? library-id)) + (u/reject-not-valid reject :connectLibrary library-id) - :else - (let [file-id (:current-file-id @st/state) - library-id (uuid/parse library-id)] - (->> st/stream - (rx/filter (ptk/type? ::dwl/attach-library-finished)) - (rx/take 1) - (rx/subs! #(resolve (library-proxy plugin-id library-id)) reject)) - (st/emit! (dwl/link-file-to-library file-id library-id)))))))))) + :else + (let [file-id (:current-file-id @st/state) + library-id (uuid/parse library-id)] + (->> st/stream + (rx/filter (ptk/type? ::dwl/attach-library-finished)) + (rx/take 1) + (rx/subs! #(resolve (library-proxy plugin-id library-id)) reject)) + (st/emit! (-> (dwl/link-file-to-library file-id library-id) + (se/add-event plugin-id)))))))))) diff --git a/frontend/src/app/plugins/page.cljs b/frontend/src/app/plugins/page.cljs index 7bc5726a17..46266ed456 100644 --- a/frontend/src/app/plugins/page.cljs +++ b/frontend/src/app/plugins/page.cljs @@ -28,6 +28,7 @@ [app.plugins.register :as r] [app.plugins.ruler-guides :as rg] [app.plugins.shape :as shape] + [app.plugins.system-events :as se] [app.plugins.utils :as u] [app.util.object :as obj] [beicon.v2.core :as rx] @@ -283,7 +284,9 @@ :else (let [flow-id (uuid/next)] - (st/emit! (dwi/add-flow flow-id id name (obj/get frame "$id"))) + (st/emit! + (dwi/add-flow flow-id id name (obj/get frame "$id")) + (se/event plugin-id "add-flow")) (flow-proxy plugin-id file-id id flow-id)))) :removeFlow @@ -293,7 +296,9 @@ (u/not-valid plugin-id :removeFlow-flow flow) :else - (st/emit! (dwi/remove-flow id (obj/get flow "$id"))))) + (st/emit! + (dwi/remove-flow id (obj/get flow "$id")) + (se/event plugin-id "remove-flow")))) :addRulerGuide (fn [orientation value board] @@ -316,12 +321,13 @@ :else (let [ruler-id (uuid/next)] (st/emit! - (dwgu/update-guides - (d/without-nils - {:id ruler-id - :axis (parser/orientation->axis orientation) - :position value - :frame-id (when board (obj/get board "$id"))}))) + (-> (dwgu/update-guides + (d/without-nils + {:id ruler-id + :axis (parser/orientation->axis orientation) + :position value + :frame-id (when board (obj/get board "$id"))})) + (se/add-event plugin-id))) (rg/ruler-guide-proxy plugin-id file-id id ruler-id))))) :removeRulerGuide @@ -335,7 +341,8 @@ :else (let [guide (u/proxy->ruler-guide value)] - (st/emit! (dwgu/remove-guide guide))))) + (st/emit! (-> (dwgu/remove-guide guide) + (se/add-event plugin-id)))))) :addCommentThread (fn [content position board] @@ -364,15 +371,15 @@ (js/Promise. (fn [resolve] (st/emit! - (dc/create-thread-on-workspace - {:file-id file-id - :page-id id - :position (gpt/point position) - :content content} - - (fn [data] - (resolve (pc/comment-thread-proxy plugin-id file-id id data))) - false)))))))) + (-> (dc/create-thread-on-workspace + {:file-id file-id + :page-id id + :position (gpt/point position) + :content content} + (fn [data] + (resolve (pc/comment-thread-proxy plugin-id file-id id data))) + false) + (se/add-event plugin-id))))))))) :removeCommentThread (fn [thread] diff --git a/frontend/src/app/plugins/parser.cljs b/frontend/src/app/plugins/parser.cljs index 5d4148662d..29ec5c2bf7 100644 --- a/frontend/src/app/plugins/parser.cljs +++ b/frontend/src/app/plugins/parser.cljs @@ -7,6 +7,7 @@ (ns app.plugins.parser (:require [app.common.data :as d] + [app.common.geom.point :as gpt] [app.common.json :as json] [app.common.types.path :as path] [app.common.uuid :as uuid] @@ -26,10 +27,16 @@ (if (string? color) (-> color str/lower) color)) (defn parse-point + "Parses a point-like JS object into a `gpt/point` record. + + The schema for shape interactions (`schema:open-overlay-interaction`, + `::gpt/point`) requires a Point record — returning a plain map caused + plugin `addInteraction` calls with an `open-overlay` action and a + `manualPositionLocation` to be silently rejected. See issue #8409." [^js point] (when point - {:x (obj/get point "x") - :y (obj/get point "y")})) + (gpt/point (obj/get point "x") + (obj/get point "y")))) (defn parse-shape-type [type] diff --git a/frontend/src/app/plugins/register.cljs b/frontend/src/app/plugins/register.cljs index e3792f3fc9..ab069b1c95 100644 --- a/frontend/src/app/plugins/register.cljs +++ b/frontend/src/app/plugins/register.cljs @@ -54,7 +54,10 @@ (conj "library:read") (contains? permissions "comment:write") - (conj "comment:read")) + (conj "comment:read") + + (contains? permissions "clipboard:write") + (conj "clipboard:read")) plugin-url (u/uri plugin-url) @@ -135,3 +138,7 @@ (= plugin-id mcp-plugin-id) (let [{:keys [permissions]} (dm/get-in @registry [:data plugin-id])] (contains? permissions permission)))) + +(defn get-plugin-data + [state plugin-id] + (get-in state [:profile :props :plugins :data plugin-id])) diff --git a/frontend/src/app/plugins/ruler_guides.cljs b/frontend/src/app/plugins/ruler_guides.cljs index 8c90b74ca9..d99904af3b 100644 --- a/frontend/src/app/plugins/ruler_guides.cljs +++ b/frontend/src/app/plugins/ruler_guides.cljs @@ -13,6 +13,7 @@ [app.main.store :as st] [app.plugins.format :as format] [app.plugins.register :as r] + [app.plugins.system-events :as se] [app.plugins.utils :as u] [app.util.object :as obj])) @@ -55,7 +56,8 @@ :else (let [board-id (when value (obj/get value "$id")) guide (-> self u/proxy->ruler-guide)] - (st/emit! (dwgu/update-guides (assoc guide :frame-id board-id)))))))} + (st/emit! (-> (dwgu/update-guides (assoc guide :frame-id board-id)) + (se/add-event plugin-id)))))))} :orientation {:this true @@ -92,7 +94,8 @@ (+ board-pos value)) value)] - (st/emit! (dwgu/update-guides (assoc guide :position position))))))} + (st/emit! (-> (dwgu/update-guides (assoc guide :position position)) + (se/add-event plugin-id))))))} :color {:this true @@ -113,4 +116,5 @@ :remove (fn [] (let [guide (u/locate-ruler-guide file-id page-id id)] - (st/emit! (dwgu/remove-guide guide)))))) + (st/emit! (-> (dwgu/remove-guide guide) + (se/add-event plugin-id))))))) diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 6e37bc2e03..133b282237 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -54,6 +54,7 @@ [app.plugins.parser :as parser] [app.plugins.register :as r] [app.plugins.ruler-guides :as rg] + [app.plugins.system-events :as se] [app.plugins.text :as text] [app.plugins.tokens :refer [applied-tokens-plugin->applied-tokens token-attr-plugin->token-attr token-attr?]] [app.plugins.utils :as u] @@ -173,6 +174,38 @@ :hidden false} blur)) +(defn commit-fills! + [plugin-id ^js self value] + (let [shape (u/proxy->shape self) + id (:id shape) + value (parser/parse-fills value)] + (cond + (not (sm/validate [:vector types.fills/schema:fill] value)) + (u/not-valid plugin-id :fills value) + + (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)))))) + +(defn commit-strokes! + [plugin-id ^js self value] + (let [id (obj/get self "$id") + value (parser/parse-strokes value)] + (cond + (not (sm/validate [:vector cts/schema:stroke] value)) + (u/not-valid plugin-id :strokes value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :strokes "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (dwsh/update-shapes [id] #(assoc % :strokes value)))))) + (defn shape-proxy? [p] (obj/type-of? p "ShapeProxy")) @@ -726,43 +759,19 @@ ;; Strokes and fills :fills {:this true - :get #(if (cfh/text-shape? data) - (-> % u/proxy->shape text-props :fills format/format-fills) - (-> % u/proxy->shape :fills format/format-fills)) - :set - (fn [self value] - (let [shape (u/proxy->shape self) - id (:id shape) - value (parser/parse-fills value)] - (cond - (not (sm/validate [:vector types.fills/schema:fill] value)) - (u/not-valid plugin-id :fills value) - - (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))))))} + :get (fn [^js self] + (let [fills (if (cfh/text-shape? data) + (-> self u/proxy->shape text-props :fills) + (-> self u/proxy->shape :fills))] + (format/format-fills fills #(commit-fills! plugin-id self %)))) + :set (fn [self value] (commit-fills! plugin-id self value))} :strokes {:this true - :get #(-> % u/proxy->shape :strokes format/format-strokes) - :set - (fn [self value] - (let [id (obj/get self "$id") - value (parser/parse-strokes value)] - (cond - (not (sm/validate [:vector cts/schema:stroke] value)) - (u/not-valid plugin-id :strokes value) - - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :strokes "Plugin doesn't have 'content:write' permission") - - :else - (st/emit! (dwsh/update-shapes [id] #(assoc % :strokes value))))))} + :get (fn [^js self] + (format/format-strokes (-> self u/proxy->shape :strokes) + #(commit-strokes! plugin-id self %))) + :set (fn [self value] (commit-strokes! plugin-id self value))} :layoutChild {:this true @@ -786,10 +795,8 @@ (when (ctl/grid-layout-immediate-child-id? objects id) (grid/layout-cell-proxy plugin-id file-id page-id id))))} - ;; Interactions - :interactions {:this true :get @@ -963,12 +970,17 @@ :else (let [child-id (obj/get child "$id") + child-shape (u/locate-shape file-id page-id child-id) is-reversed? (ctl/flex-layout? shape) index (if (or (not (u/natural-child-ordering? plugin-id)) is-reversed?) 0 (count (:shapes shape)))] - (st/emit! (dwsh/relocate-shapes #{child-id} id index)))))) + (st/emit! + (dwsh/relocate-shapes #{child-id} id index) + (se/event plugin-id (if (ctl/any-layout? shape) "add-layout-element" "add-element") + :type (:type child-shape) + :parent-type (:type shape))))))) :insertChild (fn [index child] @@ -988,12 +1000,17 @@ :else (let [child-id (obj/get child "$id") + child-shape (u/locate-shape file-id page-id child-id) is-reversed? (ctl/flex-layout? shape) index (if (or (not (u/natural-child-ordering? plugin-id)) is-reversed?) (- (count (:shapes shape)) index) index)] - (st/emit! (dwsh/relocate-shapes #{child-id} id index)))))) + (st/emit! + (dwsh/relocate-shapes #{child-id} id index) + (se/event plugin-id (if (ctl/any-layout? shape) "add-layout-element" "add-element") + :type (:type child-shape) + :parent-type (:type shape))))))) ;; Only for frames :addFlexLayout @@ -1007,7 +1024,9 @@ (u/not-valid plugin-id :addFlexLayout "Plugin doesn't have 'content:write' permission") :else - (do (st/emit! (dwsl/create-layout-from-id id :flex :from-frame? true :calculate-params? false)) + (do (st/emit! + (dwsl/create-layout-from-id id :flex :from-frame? true :calculate-params? false) + (se/event plugin-id "create-shape-layout" :layout "flex")) (flex/flex-layout-proxy plugin-id file-id page-id id))))) :addGridLayout @@ -1022,6 +1041,7 @@ :else (do (st/emit! (dwsl/create-layout-from-id id :grid :from-frame? true :calculate-params? false)) + (se/event plugin-id "create-shape-layout" :layout "grid") (grid/grid-layout-proxy plugin-id file-id page-id id))))) ;; Make masks for groups @@ -1036,7 +1056,9 @@ (u/not-valid plugin-id :makeMask "Plugin doesn't have 'content:write' permission") :else - (st/emit! (dwg/mask-group #{id}))))) + (st/emit! + (dwg/mask-group #{id}) + (se/event plugin-id "create-shape" :type "mask"))))) :removeMask (fn [] @@ -1186,8 +1208,8 @@ (let [objects (u/locate-objects file-id page-id) shape (u/locate-shape file-id page-id id)] (when (ctn/in-any-component? objects shape) - (let [[root component] (u/locate-component objects shape)] - (lib-component-proxy plugin-id (:component-file root) (:id component)))))) + (when-let [[head component] (u/locate-head-component objects shape)] + (lib-component-proxy plugin-id (:component-file head) (:id component)))))) :detach (fn [] @@ -1221,9 +1243,9 @@ (rx/map :body) (rx/mapcat #(.arrayBuffer %)) (rx/map #(js/Uint8Array. %)) + (rx/tap #(st/emit! (se/event plugin-id "export-shapes" :method "wasm"))) (rx/subs! resolve reject))))) - ;; Old export through exporter (let [shape (u/locate-shape file-id page-id id) payload @@ -1249,8 +1271,10 @@ (rx/map :body)))) (rx/mapcat #(.arrayBuffer %)) (rx/map #(js/Uint8Array. %)) + (rx/tap #(st/emit! (se/event plugin-id "export-shapes" :method "exporter"))) (rx/subs! resolve reject))))))))) + ;; Interactions :addInteraction (fn [trigger action delay] @@ -1263,7 +1287,9 @@ :else (let [index (-> (u/locate-shape file-id page-id id) (:interactions []) count)] - (st/emit! (dwi/add-interaction page-id id interaction)) + (st/emit! + (dwi/add-interaction page-id id interaction) + (se/event plugin-id "add-interaction")) (interaction-proxy plugin-id file-id page-id id index))))) :removeInteraction @@ -1273,7 +1299,9 @@ (u/not-valid plugin-id :removeInteraction interaction) :else - (st/emit! (dwi/remove-interaction {:id id} (obj/get interaction "$index"))))) + (st/emit! + (dwi/remove-interaction {:id id} (obj/get interaction "$index")) + (se/event plugin-id "remove-interaction")))) ;; Ruler guides :addRulerGuide @@ -1300,11 +1328,12 @@ board-pos (get frame axis) position (+ board-pos value)] (st/emit! - (dwgu/update-guides - {:id ruler-id - :axis axis - :position position - :frame-id id})) + (-> (dwgu/update-guides + {:id ruler-id + :axis axis + :position position + :frame-id id}) + (se/add-event plugin-id))) (rg/ruler-guide-proxy plugin-id file-id page-id ruler-id))))) :removeRulerGuide @@ -1318,7 +1347,8 @@ :else (let [guide (u/proxy->ruler-guide value)] - (st/emit! (dwgu/remove-guide guide))))) + (st/emit! (-> (dwgu/remove-guide guide) + (se/add-event plugin-id)))))) :tokens {:this true @@ -1338,17 +1368,18 @@ {:enumerable false :schema [:tuple [:fn token-proxy?] - [:maybe [:set [:and ::sm/keyword [:fn token-attr?]]]]] + [:maybe [::sm/set [:and ::sm/keyword [:fn token-attr?]]]]] :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) (u/not-valid plugin-id :applyToken attrs) (st/emit! - (dwta/toggle-token {:token token - :attrs kw-attrs - :shape-ids [id] - :expand-with-children false})))))} + (-> (dwta/toggle-token {:token token + :attrs kw-attrs + :shape-ids [id] + :expand-with-children false}) + (se/add-event plugin-id))))))} :isVariantHead (fn [] @@ -1374,7 +1405,8 @@ (let [shape (u/locate-shape file-id page-id id) component (u/locate-library-component file-id (:component-id shape))] (when (and component (ctk/is-variant? component)) - (st/emit! (dwv/variants-switch {:shapes [shape] :pos pos :val value})))))) + (st/emit! (-> (dwv/variants-switch {:shapes [shape] :pos pos :val value}) + (se/add-event plugin-id))))))) :combineAsVariants (fn [ids] @@ -1383,9 +1415,9 @@ (u/not-valid plugin-id :ids ids) :else - (let [ids (->> ids - (map uuid/uuid) - (into #{id})) + (let [ids + (into #{id} (keep uuid/parse*) id) + valid? (every? (fn [id] @@ -1396,9 +1428,10 @@ (if valid? (let [variant-id (uuid/next)] - (st/emit! (dwv/combine-as-variants - ids - {:trigger "plugin:combine-as-variants" :variant-id variant-id})) + (st/emit! (-> (dwv/combine-as-variants + ids + {:trigger "plugin:combine-as-variants" :variant-id variant-id}) + (se/add-event plugin-id))) (shape-proxy plugin-id variant-id)) (u/not-valid plugin-id :ids "One of the components is not on the same page or is already a variant")))))) diff --git a/frontend/src/app/plugins/system_events.cljs b/frontend/src/app/plugins/system_events.cljs index e8816cdfc3..e8759284e2 100644 --- a/frontend/src/app/plugins/system_events.cljs +++ b/frontend/src/app/plugins/system_events.cljs @@ -7,17 +7,34 @@ (ns app.plugins.system-events (:require [app.main.data.event :as ev] - [app.main.store :as st])) + [app.main.store :as st] + [app.plugins.register :as r])) ;; Formats an event from the plugin system (defn event [plugin-id name & {:as props}] - (let [plugin-data (get-in @st/state [:profile :props :plugins :data plugin-id])] + (if (= plugin-id r/mcp-plugin-id) (-> props (assoc ::ev/name name) - (assoc ::ev/origin "plugin") - (assoc ::ev/context - {:plugin-name (:name plugin-data) - :plugin-url (:url plugin-data)}) - (ev/event)))) + (assoc ::ev/origin "mcp") + (ev/event)) + (let [plugin-data (r/get-plugin-data @st/state plugin-id)] + (-> props + (assoc ::ev/name name) + (assoc ::ev/origin "plugin") + (assoc ::ev/context + {:plugin-name (:name plugin-data) + :plugin-url (:url plugin-data)}) + (ev/event))))) + +(defn add-event + [event plugin-id] + (let [plugin-data (r/get-plugin-data @st/state plugin-id)] + (with-meta + event + (if (= plugin-id r/mcp-plugin-id) + {::ev/origin "mcp"} + {::ev/origin "plugin" + ::ev/context {:plugin-name (:name plugin-data) + :plugin-url (:url plugin-data)}})))) diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index ad338ca32b..28ec5ed129 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.system-events :as se] [app.plugins.utils :as u] [app.util.object :as obj] [clojure.datafy :refer [datafy]] @@ -49,8 +50,19 @@ (get map:token-attr->token-attr-plugin k k)) (defn token-attr-plugin->token-attr + "Resolve a plugin-side token attribute reference to its canonical + internal keyword. + + Accepts either a Clojure keyword (the canonical form, e.g. `:r1`, + `:fill`) or a string (the natural shape that arrives from a JS plugin + call such as `shape.applyToken(token, [\"fill\"])`). Converts strings + to keywords first, then maps verbose plugin-side aliases (e.g. + `:border-radius-top-left`) to their internal short form (e.g. `:r1`). + Inputs that are already in canonical form (`:r1`, `:fill`, `\"fill\"`, + …) pass through unchanged." [k] - (get map:token-attr-plugin->token-attr k k)) + (let [k (cond-> k (string? k) keyword)] + (get map:token-attr-plugin->token-attr k k))) (defn applied-tokens-plugin->applied-tokens [value] @@ -69,10 +81,11 @@ (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}))))) + (-> (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- get-resolved-value [token tokens-tree] @@ -109,10 +122,13 @@ (ctob/get-name token))) :schema (cfo/make-token-name-schema (some-> (u/locate-tokens-lib file-id) - (ctob/get-tokens set-id))) + (ctob/get-tokens set-id)) + set-id + id) :set (fn [_ value] - (st/emit! (dwtl/update-token set-id id {:name value})))} + (st/emit! (-> (dwtl/update-token set-id id {:name value}) + (se/add-event plugin-id))))} :type {:this true @@ -162,7 +178,8 @@ :schema cfo/schema:token-description :set (fn [_ value] - (st/emit! (dwtl/update-token set-id id {:description value})))} + (st/emit! (-> (dwtl/update-token set-id id {:description value}) + (se/add-event :plugin-id))))} :duplicate (fn [] @@ -175,24 +192,26 @@ token' (ctob/make-token (-> (datafy token) (dissoc :id :modified-at)))] - (st/emit! (dwtl/create-token set-id token')) + (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))) + (st/emit! (-> (dwtl/delete-token set-id id) + (se/add-event plugin-id)))) :applyToShapes {:enumerable false :schema [:tuple [:vector [:fn shape-proxy?]] - [:maybe [:set [:and ::sm/keyword [:fn token-attr?]]]]] + [:maybe [::sm/set [:and ::sm/keyword [:fn token-attr?]]]]] :fn (fn [shapes attrs] (apply-token-to-shapes plugin-id file-id set-id id (map #(obj/get % "$id") shapes) attrs))} :applyToSelected {:enumerable false - :schema [:tuple [:maybe [:set [:and ::sm/keyword [:fn token-attr?]]]]] + :schema [:tuple [:maybe [::sm/set [:and ::sm/keyword [:fn token-attr?]]]]] :fn (fn [attrs] (let [selected (get-in @st/state [:workspace-local :selected])] (apply-token-to-shapes plugin-id file-id set-id id selected attrs)))})) @@ -294,19 +313,17 @@ :addToken {:enumerable false :schema (fn [args] - (let [tokens-tree (-> (u/locate-tokens-lib file-id) - (ctob/get-tokens id) - ;; Convert to the adecuate format for schema - (ctob/tokens-tree))] - [:tuple (-> (cfo/make-token-schema - tokens-tree - (cto/dtcg-token-type->token-type (-> args (first) (get "type")))) - ;; Don't allow plugins to set the id - (sm/dissoc-key :id) - ;; Instruct the json decoder in obj/reify not to process map keys (:key-fn below) - ;; and set a converter that changes DTCG types to internal types (:decode/json). - ;; E.g. "FontFamilies" -> :font-family or "BorderWidth" -> :stroke-width - (sm/update-properties assoc :decode/json cfo/convert-dtcg-token))])) + [:tuple (-> (cfo/make-token-schema + (u/locate-tokens-lib file-id) + (cto/dtcg-token-type->token-type (-> args (first) (get "type"))) + id + (-> args (first) (get "id"))) + ;; Don't allow plugins to set the id + (sm/dissoc-key :id) + ;; Instruct the json decoder in obj/reify not to process map keys (:key-fn below) + ;; and set a converter that changes DTCG types to internal types (:decode/json). + ;; E.g. "FontFamilies" -> :font-family or "BorderWidth" -> :stroke-width + (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) @@ -319,7 +336,8 @@ (get resolved-tokens (:name token))] (if resolved-value - (do (st/emit! (dwtl/create-token id token)) + (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))))} @@ -483,12 +501,12 @@ :addTheme {:enumerable false - :schema (fn [attrs] - [:tuple (-> (sm/schema (cfo/make-token-theme-schema - (u/locate-tokens-lib file-id) - (or (obj/get attrs "group") "") - (or (obj/get attrs "name") "") - nil)) + :schema (fn [args] + [:tuple (-> (cfo/make-token-theme-schema + (u/locate-tokens-lib file-id) + (get (first args) :group "") + (get (first args) :name "") + nil) (sm/dissoc-key :id))]) ;; We don't allow plugins to set the id :fn (fn [attrs] (let [theme (ctob/make-token-theme attrs)] diff --git a/frontend/src/app/plugins/utils.cljs b/frontend/src/app/plugins/utils.cljs index 19de73fcde..863ad37e2f 100644 --- a/frontend/src/app/plugins/utils.cljs +++ b/frontend/src/app/plugins/utils.cljs @@ -100,6 +100,17 @@ root (ctn/get-instance-root objects shape)] [root (ctf/resolve-component root file libraries {:include-deleted? true})])) +(defn locate-head-component + "Like locate-component but resolves via the nearest component head + instead of the outermost instance root." + [objects shape] + (let [state (deref st/state) + file (dsh/lookup-file state) + libraries (dsh/lookup-libraries state) + head (ctn/get-head-shape objects shape)] + (when head + [head (ctf/resolve-component head file libraries {:include-deleted? true})]))) + (defn proxy->file [proxy] (let [id (obj/get proxy "$id")] @@ -265,13 +276,39 @@ (let [s (set values)] (if (= (count s) 1) (first s) "mixed"))) +(defn- flatten-error-map + "Walk an error map produced by `csm/interpret-schema-problem` and yield + `[path message]` pairs, where `path` is the dot-joined field path + (e.g. `:group` -> \"group\", `[:sets 0 :name]` -> \"sets.0.name\"). + + `interpret-schema-problem` calls `(assoc-in acc field {:message …})`, so + when the malli error path has more than one element the resulting map is + nested (e.g. `{:sets {0 {:name {:message \"…\"}}}}`); when the path has + a single element it is flat (`{:group {:message \"…\"}}`). The plugin + error-message renderer needs both cases reduced to per-leaf + `[path message]` pairs so it can produce one `plugins.validation.message` + string per actual validation problem." + ([m] (flatten-error-map [] m)) + ([prefix m] + (mapcat + (fn [[k v]] + (let [segment (cond + (keyword? k) (name k) + (string? k) k + :else (str k)) + path (conj prefix segment)] + (if (and (map? v) (not (contains? v :message))) + (flatten-error-map path v) + [[(str/join "." path) (:message v)]]))) + m))) + (defn error-messages [explain] (->> (:errors explain) (reduce csm/interpret-schema-problem {}) - (mapcat (comp seq val)) - (map (fn [[field {:keys [message]}]] - (tr "plugins.validation.message" (name field) message))) + (flatten-error-map) + (map (fn [[field message]] + (tr "plugins.validation.message" field message))) (str/join ". "))) (defn handle-error diff --git a/frontend/src/app/render.cljs b/frontend/src/app/render.cljs index a7fef90ca9..33aa291385 100644 --- a/frontend/src/app/render.cljs +++ b/frontend/src/app/render.cljs @@ -309,9 +309,6 @@ (let [el (dom/get-element "app")] (mf/create-root el))) -(declare ^:private render-single-object) -(declare ^:private render-components) -(declare ^:private render-objects) (defn- parse-params [loc] diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 1f16c29779..6b20f1c9d3 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -11,6 +11,7 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.exceptions :as ex] + [app.common.files.focus :as cpf] [app.common.files.helpers :as cfh] [app.common.logging :as log] [app.common.math :as mth] @@ -22,6 +23,9 @@ [app.common.types.text :as txt] [app.common.uuid :as uuid] [app.config :as cf] + [app.main.data.helpers :as dsh] + [app.main.data.notifications :as ntf] + [app.main.data.render-wasm :as drw] [app.main.data.workspace.texts-v3 :as texts] [app.main.refs :as refs] [app.main.router :as rt] @@ -32,6 +36,7 @@ [app.render-wasm.api.texts :as t] [app.render-wasm.api.webgl :as webgl] [app.render-wasm.deserializers :as dr] + [app.render-wasm.gesture :as wasm-gesture] [app.render-wasm.helpers :as h] [app.render-wasm.mem :as mem] [app.render-wasm.mem.heap32 :as mem.h32] @@ -45,8 +50,10 @@ [app.util.dom :as dom] [app.util.functions :as fns] [app.util.globals :as ug] + [app.util.i18n :refer [tr]] [app.util.modules :as mod] [app.util.text.content :as tc] + [app.util.timers :as timers] [beicon.v2.core :as rx] [cuerdas.core :as str] [promesa.core :as p] @@ -69,27 +76,13 @@ ;; - `transition-tiles-handler*`: the currently installed DOM event handler for ;; `penpot:wasm:tiles-complete`, so we can remove/replace it safely. (defonce page-transition? (atom false)) +(defonce context-loss-overlay? (atom false)) (defonce transition-image-url* (atom nil)) (defonce transition-epoch* (atom 0)) (defonce transition-tiles-handler* (atom nil)) +(defonce snapshot-tiles-handler* (atom nil)) -(def ^:private transition-blur-css "blur(4px)") - -(defn- set-transition-blur! - [] - (when-let [canvas ^js wasm/canvas] - (dom/set-style! canvas "filter" transition-blur-css)) - (when-let [nodes (.querySelectorAll ^js ug/document ".blurrable")] - (doseq [^js node (array-seq nodes)] - (dom/set-style! node "filter" transition-blur-css)))) - -(defn- clear-transition-blur! - [] - (when-let [canvas ^js wasm/canvas] - (dom/set-style! canvas "filter" "")) - (when-let [nodes (.querySelectorAll ^js ug/document ".blurrable")] - (doseq [^js node (array-seq nodes)] - (dom/set-style! node "filter" "")))) +(def ^:private snapshot-capture-debounce-ms 250) (defn set-transition-image-from-background! "Sets `transition-image-url*` to a data URL representing a solid background color." @@ -112,10 +105,7 @@ (when-let [prev @transition-tiles-handler*] (.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev)) (reset! transition-tiles-handler* nil) - (reset! transition-image-url* nil) - (clear-transition-blur!) - ;; Clear captured pixels so future transitions must explicitly capture again. - (set! wasm/canvas-snapshot-url nil)) + (reset! transition-image-url* nil)) (defn- set-transition-tiles-complete-handler! "Installs a tiles-complete handler bound to the current transition epoch. @@ -147,6 +137,16 @@ (let [epoch (begin-page-transition!)] (set-transition-tiles-complete-handler! epoch end-page-transition!)))) +(defn- start-context-loss-overlay! + [] + (reset! context-loss-overlay? true)) + +(defn- end-context-loss-overlay! + [] + (reset! context-loss-overlay? false) + (when-not @page-transition? + (reset! transition-image-url* nil))) + (defn listen-tiles-render-complete-once! "Registers a one-shot listener for `penpot:wasm:tiles-complete`, dispatched from WASM when a full tile pass finishes." @@ -157,6 +157,32 @@ (f)) #js {:once true})) +(defonce ^:private schedule-canvas-snapshot-capture! + (fns/debounce + (fn [] + (when (and wasm/context-initialized? + (not @wasm/context-lost?) + (some? wasm/canvas)) + (-> (webgl/capture-canvas-snapshot-url) + (p/catch (fn [_] nil))))) + snapshot-capture-debounce-ms)) + +(defn- start-canvas-snapshot-listener! + [] + (when-let [prev @snapshot-tiles-handler*] + (.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev)) + (let [handler (fn [_] (schedule-canvas-snapshot-capture!))] + (reset! snapshot-tiles-handler* handler) + (.addEventListener ^js ug/document "penpot:wasm:tiles-complete" handler))) + +(defn- stop-canvas-snapshot-listener! + [] + (when-let [prev @snapshot-tiles-handler*] + (.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev)) + (reset! snapshot-tiles-handler* nil) + (when-let [cancel (unchecked-get schedule-canvas-snapshot-capture! "cancel")] + (cancel))) + (defn text-editor-wasm? [] (or (contains? cf/flags :feature-text-editor-wasm) @@ -183,6 +209,8 @@ (def ^:const DEBOUNCE_DELAY_MS 100) +(defonce ^:private view-interaction-active? (atom false)) + ;; Time budget (ms) per chunk of shape processing before yielding to browser (def ^:private ^:const CHUNK_TIME_BUDGET_MS 8) ;; Threshold below which we use synchronous processing (no chunking overhead) @@ -204,6 +232,7 @@ (def text-editor-blur text-editor/text-editor-blur) (def text-editor-set-cursor-from-offset text-editor/text-editor-set-cursor-from-offset) (def text-editor-set-cursor-from-point text-editor/text-editor-set-cursor-from-point) +(def text-editor-toggle-overtype-mode text-editor/text-editor-toggle-overtype-mode) (def text-editor-pointer-down text-editor/text-editor-pointer-down) (def text-editor-pointer-move text-editor/text-editor-pointer-move) (def text-editor-pointer-up text-editor/text-editor-pointer-up) @@ -266,6 +295,36 @@ ;; forward declare helpers so render can call them (declare request-render) (declare set-shape-vertical-align fonts-from-text-content) +(declare reload-renderer!) + +(defn- build-reload-payload + "Builds renderer reload payload from current application state. + Avoids keeping heavyweight object snapshots in memory." + [] + (let [state @st/state + file-id (:current-file-id state) + page-id (:current-page-id state) + page (dsh/lookup-page state file-id page-id) + objects (dsh/lookup-page-objects state file-id page-id) + focus (:workspace-focus-selected state) + local (:workspace-local state) + zoom (:zoom local) + vbox (:vbox local) + canvas wasm/canvas + background (get page :background)] + {:canvas canvas + :base-objects (cpf/focus-objects objects focus) + :zoom zoom + :vbox vbox + :background background})) + +(defn free-gpu-resources + [] + ;; check if the context has not been lost already or we will get warnings about + ;; removing objects from a non-current context + (when (and wasm/context-initialized? + (not @wasm/context-lost?)) + (h/call wasm/internal-module "_free_gpu_resources"))) ;; This should never be called from the outside. (defn- render @@ -340,7 +399,7 @@ (when-not @pending-render (reset! pending-render true) (let [frame-id - (js/requestAnimationFrame + (timers/raf (fn [ts] (reset! pending-render false) (set! wasm/internal-frame-id nil) @@ -627,6 +686,7 @@ (if (empty? fills) (h/call wasm/internal-module "_clear_shape_fills") (let [fills (types.fills/coerce fills) + image-ids (types.fills/get-image-ids fills) offset (mem/alloc->offset-32 (types.fills/get-byte-size fills)) heap (mem/get-heap-u32)] @@ -648,7 +708,7 @@ (when (zero? cached-image?) (fetch-image shape-id id thumbnail?)))) - (types.fills/get-image-ids fills))))) + image-ids)))) (defn set-shape-strokes [shape-id strokes thumbnail?] @@ -664,19 +724,25 @@ style (-> stroke :stroke-style sr/translate-stroke-style) cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap) cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap) + ;; Sentinel -1 means "unset" on the Rust side — keeps the + ;; FFI signature flat while letting the renderer fall back + ;; to its default dash pattern when no override is stored. + dash (or (:stroke-dash stroke) -1) + gap (or (:stroke-gap stroke) -1) offset (mem/alloc types.fills.impl/FILL-U8-SIZE) heap (mem/get-heap-u8) dview (js/DataView. (.-buffer heap))] (case align - :inner (h/call wasm/internal-module "_add_shape_inner_stroke" width style cap-start cap-end) - :outer (h/call wasm/internal-module "_add_shape_outer_stroke" width style cap-start cap-end) - (h/call wasm/internal-module "_add_shape_center_stroke" width style cap-start cap-end)) + :inner (h/call wasm/internal-module "_add_shape_inner_stroke" width style cap-start cap-end dash gap) + :outer (h/call wasm/internal-module "_add_shape_outer_stroke" width style cap-start cap-end dash gap) + (h/call wasm/internal-module "_add_shape_center_stroke" width style cap-start cap-end dash gap)) (cond (some? gradient) (do (types.fills.impl/write-gradient-fill offset dview opacity gradient) - (h/call wasm/internal-module "_add_shape_stroke_fill")) + (h/call wasm/internal-module "_add_shape_stroke_fill") + nil) (some? image) (let [image-id (get image :id) @@ -693,7 +759,9 @@ (some? color) (do (types.fills.impl/write-solid-fill offset dview opacity color) - (h/call wasm/internal-module "_add_shape_stroke_fill")))))) + (h/call wasm/internal-module "_add_shape_stroke_fill") + nil))))) + strokes)) (defn set-shape-svg-attrs @@ -1073,25 +1141,39 @@ (defn intersect-position-in-shape [id position] - (let [buffer (uuid/get-u32 id) - result - (h/call wasm/internal-module "_intersect_position_in_shape" - (aget buffer 0) - (aget buffer 1) - (aget buffer 2) - (aget buffer 3) - (:x position) - (:y position))] - (= result 1))) + (if (and wasm/context-initialized? (not @wasm/context-lost?)) + (let [buffer (uuid/get-u32 id) + result + (h/call wasm/internal-module "_intersect_position_in_shape" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3) + (:x position) + (:y position))] + (= result 1)) + false)) + +(defn view-interaction-start! + [] + (when-not @view-interaction-active? + (h/call wasm/internal-module "_set_view_start") + (reset! view-interaction-active? true))) + +(defn view-interaction-end! + [] + (when @view-interaction-active? + (perf/begin-measure "render-finish") + (h/call wasm/internal-module "_set_view_end") + (perf/end-measure "render-finish") + (reset! view-interaction-active? false))) (def render-finish (letfn [(do-render [] ;; Check if context is still initialized before executing ;; to prevent errors when navigating quickly (when (and wasm/context-initialized? (not @wasm/context-lost?)) - (perf/begin-measure "render-finish") - (h/call wasm/internal-module "_set_view_end") - (perf/end-measure "render-finish") + (view-interaction-end!) ;; Use async _render: visible tiles render synchronously ;; (no yield), interest-area tiles render progressively ;; via rAF. _set_view_end already rebuilt the tile @@ -1105,7 +1187,7 @@ (defn set-view-box [zoom vbox] (perf/begin-measure "set-view-box") - (h/call wasm/internal-module "_set_view_start") + (view-interaction-start!) (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) (perf/end-measure "set-view-box") @@ -1114,6 +1196,16 @@ (render-finish) (perf/end-measure "render-from-cache")) +(defn sync-workspace-local-viewport! + "Pushes `[:workspace-local :zoom]` and `:vbox` into WASM." + [state] + (when (and wasm/context-initialized? + (not @wasm/context-lost?)) + (let [zoom (get-in state [:workspace-local :zoom]) + vbox (get-in state [:workspace-local :vbox])] + (when (and zoom vbox) + (set-view-box zoom vbox))))) + (defn- ensure-text-content "Guarantee that the shape always sends a valid text tree to WASM. When the content is nil (freshly created text) we fall back to @@ -1282,6 +1374,7 @@ ;; Rebuild the tile index so _render knows which shapes ;; map to which tiles after a page switch. (h/call wasm/internal-module "_set_view_end") + (reset! view-interaction-active? false) ;; Text layouts must run after _end_loading (they ;; depend on state that is only correct when loading @@ -1303,16 +1396,17 @@ (when (or (seq pending-thumbnails) (seq pending-full)) (->> (rx/concat (->> (rx/from (vals pending-thumbnails)) - (rx/merge-map (fn [callback] (callback))) + (rx/merge-map + (fn [callback] + (if (fn? callback) (callback) (rx/empty)))) (rx/reduce conj [])) (->> (rx/from (vals pending-full)) - (rx/mapcat (fn [callback] (callback))) + (rx/mapcat + (fn [callback] + (if (fn? callback) (callback) (rx/empty)))) (rx/reduce conj []))) (rx/subs! (fn [_] - ;; Fonts are now loaded — recompute text - ;; layouts so Skia uses the real metrics - ;; instead of fallback-font estimates. (let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)] (when (seq text-ids) (update-text-layouts text-ids))) @@ -1339,6 +1433,7 @@ ;; Rebuild the tile index so _render knows which shapes ;; map to which tiles after a page switch. (h/call wasm/internal-module "_set_view_end") + (reset! view-interaction-active? false) (process-pending shapes thumbnails full (fn [] (if render-callback @@ -1570,6 +1665,57 @@ (h/call wasm/internal-module "_init_shapes_pool" total-shapes) (set-objects base-objects on-render on-shapes-ready force-sync))) +(defn- run-resource-callbacks! + [entries] + (if (seq entries) + (p/create + (fn [resolve _reject] + (->> (rx/from (vals (d/index-by :key :callback entries))) + (rx/merge-map (fn [callback] (if (fn? callback) (callback) (rx/empty)))) + (rx/reduce conj []) + (rx/subs! (fn [_] (resolve nil)) + (fn [_cause] (resolve nil)) + (fn [] (resolve nil)))))) + (p/resolved nil))) + +(defn- replay-font-resources! + [fonts] + (let [pending (into [] (f/store-fonts fonts))] + (run-resource-callbacks! pending))) + +(defn- derive-font-resources + [base-objects payload-fonts] + (let [object-fonts + (->> (vals base-objects) + (filter cfh/text-shape?) + (mapcat (fn [shape] + (let [content (ensure-text-content (:content shape)) + direct-fonts (f/get-content-fonts content) + ;; `true` would call `write-shape-text`, which requires + ;; an active current shape in WASM and can panic during + ;; reload pre-processing. We only need fallback font + ;; discovery here, so use side-effect free mode. + fallback-fonts (fonts-from-text-content content false)] + (concat direct-fonts fallback-fonts)))) + (into #{}))] + (into [] (set (concat payload-fonts object-fonts))))) + +(defn- replay-image-resources! + [image-resources] + (let [pending + (into [] + (keep (fn [{:keys [shape-id image-id thumbnail?]}] + (when (and (uuid? image-id) (or (nil? shape-id) (uuid? shape-id))) + (fetch-image (or shape-id uuid/zero) image-id (boolean thumbnail?))))) + image-resources)] + (run-resource-callbacks! pending))) + +(defn- wait-next-frame! + [] + (p/create + (fn [resolve _reject] + (timers/raf (fn [] (resolve nil)))))) + (def ^:private default-context-options #js {:antialias false :depth true @@ -1646,96 +1792,202 @@ (defn- on-webgl-context-lost [event] (dom/prevent-default event) + ;; Keep the last rendered pixels visible while context is lost/recovering. + (start-context-loss-overlay!) + (when-let [url wasm/canvas-snapshot-url] + (when (string? url) + (reset! transition-image-url* url))) (reset! wasm/context-lost? true) - (ex/raise :type :wasm-error - :code :webgl-context-lost - :hint "WASM Error: WebGL context lost")) + (st/async-emit! + (ntf/show {:content (tr "webgl.webgl-context-lost.toast") + :type :toast + :level :warning + :timeout 5000})) + (st/emit! (drw/context-lost))) + +(defn- on-webgl-context-restored + [event] + (dom/prevent-default event) + (reset! wasm/context-lost? false) + (st/emit! (drw/context-restored)) + (let [payload (build-reload-payload)] + (-> (reload-renderer! payload) + (p/then (fn [_] + (listen-tiles-render-complete-once! end-context-loss-overlay!) + (st/async-emit! + (ntf/show {:content (tr "webgl.webgl-context-recovered.toast") + :type :toast + :level :success + :timeout 3000})))) + (p/catch (fn [cause] + (end-context-loss-overlay!) + (log/error :hint "wasm reload after context restore failed" + :cause cause) + nil))))) (defn init-canvas-context [canvas] - (let [gl (unchecked-get wasm/internal-module "GL") - flags (debug-flags) - context-id (if (dbg/enabled? :wasm-gl-context-init-error) "fail" "webgl2") - context (.getContext ^js canvas context-id default-context-options) - context-init? (not (nil? context)) - browser (sr/translate-browser cf/browser)] - (when-not (nil? context) - (let [handle (.registerContext ^js gl context #js {"majorVersion" 2})] - (.makeContextCurrent ^js gl handle) - (set! wasm/gl-context-handle handle) - (set! wasm/gl-context context) + (if-not (wasm/module-ready?) + false + (let [gl (unchecked-get wasm/internal-module "GL") + flags (debug-flags) + context-id (if (dbg/enabled? :wasm-gl-context-init-error) "fail" "webgl2") + context (.getContext ^js canvas context-id default-context-options) + context-init? (not (nil? context)) + browser (sr/translate-browser cf/browser)] + (when-not (nil? context) + (let [handle (.registerContext ^js gl context #js {"majorVersion" 2})] + (.makeContextCurrent ^js gl handle) + (set! wasm/gl-context-handle handle) + (set! wasm/gl-context context) - ;; Force the WEBGL_debug_renderer_info extension as emscripten does not enable it - (.getExtension context "WEBGL_debug_renderer_info") + ;; Force the WEBGL_debug_renderer_info extension as emscripten does not enable it + (.getExtension context "WEBGL_debug_renderer_info") - ;; Initialize Wasm Render Engine - (h/call wasm/internal-module "_init" (/ (.-width ^js canvas) dpr) (/ (.-height ^js canvas) dpr)) - (h/call wasm/internal-module "_set_render_options" flags dpr) - (when-let [t (wasm-aa-threshold-from-route-params)] - (h/call wasm/internal-module "_set_antialias_threshold" t)) - (when-let [t (wasm-viewport-interest-area-threshold-from-route-params)] - (h/call wasm/internal-module "_set_viewport_interest_area_threshold" t)) - (when-let [t (wasm-max-blocking-time-ms-from-route-params)] - (h/call wasm/internal-module "_set_max_blocking_time_ms" t)) - (when-let [t (wasm-node-batch-threshold-from-route-params)] - (h/call wasm/internal-module "_set_node_batch_threshold" t)) - (when-let [t (wasm-blur-downscale-threshold-from-route-params)] - (h/call wasm/internal-module "_set_blur_downscale_threshold" t)) - (when-let [max-tex (webgl/max-texture-size context)] - (h/call wasm/internal-module "_set_max_atlas_texture_size" max-tex)) + ;; Initialize Wasm Render Engine + (h/call wasm/internal-module "_init" (/ (.-width ^js canvas) dpr) (/ (.-height ^js canvas) dpr)) + (h/call wasm/internal-module "_set_render_options" flags dpr) + (when-let [t (wasm-aa-threshold-from-route-params)] + (h/call wasm/internal-module "_set_antialias_threshold" t)) + (when-let [t (wasm-viewport-interest-area-threshold-from-route-params)] + (h/call wasm/internal-module "_set_viewport_interest_area_threshold" t)) + (when-let [t (wasm-max-blocking-time-ms-from-route-params)] + (h/call wasm/internal-module "_set_max_blocking_time_ms" t)) + (when-let [t (wasm-node-batch-threshold-from-route-params)] + (h/call wasm/internal-module "_set_node_batch_threshold" t)) + (when-let [t (wasm-blur-downscale-threshold-from-route-params)] + (h/call wasm/internal-module "_set_blur_downscale_threshold" t)) + (when-let [max-tex (webgl/max-texture-size context)] + (h/call wasm/internal-module "_set_max_atlas_texture_size" max-tex)) - ;; Set browser and canvas size only after initialization - (h/call wasm/internal-module "_set_browser" browser) - (set-canvas-size canvas) + ;; Set browser and canvas size only after initialization + (h/call wasm/internal-module "_set_browser" browser) + (set-canvas-size canvas) - ;; Add event listeners for WebGL context lost - (set! wasm/canvas canvas) - (.addEventListener canvas "webglcontextlost" on-webgl-context-lost) - (set! wasm/context-initialized? true))) + ;; Add event listeners for WebGL context lost + (set! wasm/canvas canvas) + (.addEventListener canvas "webglcontextlost" on-webgl-context-lost) + (.addEventListener canvas "webglcontextrestored" on-webgl-context-restored) + (start-canvas-snapshot-listener!) + (reset! wasm/context-lost? false) + (set! wasm/context-initialized? true))) - context-init?)) + context-init?))) (defn clear-canvas - [] - (when wasm/context-initialized? - (try - (set! wasm/context-initialized? false) + ([] + (clear-canvas {})) + ([{:keys [lose-browser-context?] + :or {lose-browser-context? true}}] + (try + (set! wasm/context-initialized? false) - ;; Cancel any pending animation frame to prevent race conditions - (when wasm/internal-frame-id - (js/cancelAnimationFrame wasm/internal-frame-id) - (set! wasm/internal-frame-id nil)) + ;; Cancel any pending animation frame to prevent race conditions. + (when wasm/internal-frame-id + (timers/cancel-af! wasm/internal-frame-id)) - ;; Reset render flags to prevent new renders from being scheduled - (reset! pending-render false) - (reset! shapes-loading? false) - (reset! deferred-render? false) + ;; Reset render flags to prevent new renders from being scheduled. + (reset! pending-render false) + (reset! shapes-loading? false) + (reset! deferred-render? false) - (h/call wasm/internal-module "_clean_up") + ;; Remove listener before losing/deleting context. + (when wasm/canvas + (.removeEventListener wasm/canvas "webglcontextlost" on-webgl-context-lost) + (.removeEventListener wasm/canvas "webglcontextrestored" on-webgl-context-restored)) + (stop-canvas-snapshot-listener!) - ;; Remove event listener for WebGL context lost - (when wasm/canvas - (.removeEventListener wasm/canvas "webglcontextlost" on-webgl-context-lost) - (set! wasm/canvas nil)) + (when (wasm/module-ready?) + (free-gpu-resources) + (h/call wasm/internal-module "_clean_up")) - ;; Ensure the WebGL context is properly disposed so browsers do not keep - ;; accumulating active contexts between page switches. - (when-let [gl (unchecked-get wasm/internal-module "GL")] - (when-let [handle wasm/gl-context-handle] - (try - ;; Ask the browser to release resources explicitly if available. - (when-let [ctx wasm/gl-context] - (when-let [lose-ext (.getExtension ^js ctx "WEBGL_lose_context")] - (.loseContext ^js lose-ext))) - (.deleteContext ^js gl handle) - (finally - (set! wasm/gl-context-handle nil) - (set! wasm/gl-context nil))))) + ;; Ensure the WebGL context is properly disposed so browsers do not keep + ;; accumulating active contexts between page switches. + (when-let [gl (unchecked-get wasm/internal-module "GL")] + (when-let [handle wasm/gl-context-handle] + (try + ;; For hard teardown we can explicitly lose browser context. + ;; For reload->reinit flows we skip this because immediate context + ;; recreation may fail on some browsers/GPUs while context is lost. + (when lose-browser-context? + (when-let [ctx wasm/gl-context] + (when-let [lose-ext (.getExtension ^js ctx "WEBGL_lose_context")] + (.loseContext ^js lose-ext)))) + (.deleteContext ^js gl handle) + (catch :default dispose-error + (.error js/console dispose-error))))) - ;; If this calls panics we don't want to crash. This happens sometimes - ;; with hot-reload in develop - (catch :default error - (.error js/console error))))) + (wasm-gesture/reset-after-wasm-reload!) + (wasm/reset-context-state!) + true + + ;; If this panics we don't want to crash. This happens sometimes with + ;; hot-reload in development. + (catch :default error + (.error js/console error) + (wasm-gesture/reset-after-wasm-reload!) + (wasm/reset-context-state!) + false)))) + +(defn reload-renderer! + [{:keys [canvas + base-objects + zoom + vbox + fonts + image-resources + background + background-opacity + on-render + on-shapes-ready + force-sync] + :or {fonts [] + image-resources [] + background-opacity 1 + force-sync false} + :as payload}] + (ug/dispatch! (ug/event "penpot:wasm:reload-start")) + (let [fonts (derive-font-resources base-objects fonts)] + (-> (p/resolved nil) + ;; Keep teardown strict (`_clean_up` + deleteContext) but do not + ;; force `loseContext` because we immediately create a new context. + (p/then (fn [_] + (let [was-cleared? (clear-canvas {:lose-browser-context? false})] + (when-not was-cleared? + (ex/raise :type :wasm-error + :code :wasm-reload-context-failure + :hint "WASM renderer cleanup failed"))))) + ;; Give browser a frame to settle context deletion before init. + (p/then (fn [_] (wait-next-frame!))) + (p/then (fn [_] + (let [context-ready? (init-canvas-context canvas)] + (when-not context-ready? + (ex/raise :type :wasm-error + :code :wasm-reload-context-failure + :hint "WASM renderer could not create a new WebGL context")) + ;; Gesture bookkeeping (`modifiers.cljs`) uses compare-and-set on an atom + ;; that survives WASM teardown; reset so it matches fresh `_init` state. + (wasm-gesture/reset-after-wasm-reload!)))) + ;; Ensure render surfaces are blank before replay to avoid overpainting. + (p/then (fn [_] (h/call wasm/internal-module "_reset_canvas"))) + (p/then (fn [_] (replay-font-resources! fonts))) + (p/then (fn [_] (replay-image-resources! image-resources))) + (p/then + (fn [] + (initialize-viewport base-objects zoom vbox + :background background + :background-opacity background-opacity + :on-render on-render + :on-shapes-ready on-shapes-ready + :force-sync force-sync) + (request-render "reload-renderer") + (ug/dispatch! (ug/event "penpot:wasm:reload-complete")) + payload)) + (p/catch + (fn [cause] + (ug/dispatch! (ug/event "penpot:wasm:reload-failed")) + (clear-canvas) + (p/rejected cause)))))) (defn show-grid [id] @@ -1923,33 +2175,15 @@ (let [already? @page-transition? epoch (begin-page-transition!)] (set-transition-tiles-complete-handler! epoch end-page-transition!) - ;; Two-phase transition: - ;; - Apply CSS blur to the live canvas immediately (no async wait), so the user - ;; sees the transition right away. - ;; - In parallel, capture a `blob:` snapshot URL; once ready, switch the overlay - ;; to that fixed image (and guard with `epoch` to avoid stale async updates). - (set-transition-blur!) ;; Lock the snapshot for the whole transition: if the user clicks to another page ;; while the transition is active, keep showing the original page snapshot until - ;; the final target page finishes rendering. - (if already? - (p/resolved nil) - (do - ;; If we already have a snapshot URL, use it immediately. - (when-let [url wasm/canvas-snapshot-url] - (when (string? url) - (reset! transition-image-url* url))) - - ;; Capture a fresh snapshot asynchronously and update the overlay as soon - ;; as it is ready (guarded by `epoch` to avoid stale async updates). - (-> (capture-canvas-snapshot-url) - (p/then (fn [url] - (when (and (string? url) - @page-transition? - (= epoch @transition-epoch*)) - (reset! transition-image-url* url)) - url)) - (p/catch (fn [_] nil))))))) + ;; the final target page finishes rendering. The caller (sitemap on-click) is + ;; responsible for ensuring `wasm/canvas-snapshot-url` was freshly captured + ;; before invoking us. + (when-not already? + (when-let [url wasm/canvas-snapshot-url] + (when (string? url) + (reset! transition-image-url* url)))))) (defn render-shape-pixels [shape-id scale] @@ -1970,6 +2204,24 @@ (mem/free) result)) +(defn get-shape-extrect + [shape-id] + (let [buffer (uuid/get-u32 shape-id) + offset (h/call wasm/internal-module "_get_shape_extrect" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3))] + (when (and (number? offset) (pos? offset)) + (let [heapf32 (mem/get-heap-f32) + base (mem/->offset-32 offset) + x (aget heapf32 base) + y (aget heapf32 (+ base 1)) + w (aget heapf32 (+ base 2)) + h (aget heapf32 (+ base 3))] + (mem/free) + {:x x :y y :width w :height h})))) + (defn init-wasm-module [module] (let [default-fn (unchecked-get module "default") diff --git a/frontend/src/app/render_wasm/gesture.cljs b/frontend/src/app/render_wasm/gesture.cljs new file mode 100644 index 0000000000..2e774e511c --- /dev/null +++ b/frontend/src/app/render_wasm/gesture.cljs @@ -0,0 +1,29 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.render-wasm.gesture + "WASM-linked pointer gestures (interactive transforms, like D&D)") + +(defonce ^:private interactive-transform-active? (atom false)) + +(defn reset-after-wasm-reload! + "Call after `_clean_up` + `_init` (new GL context). WASM interactive_transform / + fast_mode are reset to defaults; this atom must match or compare-and-set helpers in + modifiers.cljs will skip `_set_modifiers_start` / `_set_modifiers_end` incorrectly." + [] + (reset! interactive-transform-active? false)) + +(defn try-begin-interactive-transform! + "Returns true iff we transitioned inactive → active and native `_set_modifiers_start` + must run." + [] + (compare-and-set! interactive-transform-active? false true)) + +(defn try-end-interactive-transform! + "Returns true iff we transitioned active → inactive and native `_set_modifiers_end` + must run." + [] + (compare-and-set! interactive-transform-active? true false)) diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 1cfb5b834c..b547169ae2 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -143,6 +143,12 @@ (when wasm/context-initialized? (h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y))) +(defn text-editor-toggle-overtype-mode + "Toggles overtype mode" + [] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_toggle_overtype_mode"))) + (defn text-editor-pointer-down [{:keys [x y]}] (when wasm/context-initialized? diff --git a/frontend/src/app/render_wasm/wasm.cljs b/frontend/src/app/render_wasm/wasm.cljs index 25c2908575..5bcbfc7b2f 100644 --- a/frontend/src/app/render_wasm/wasm.cljs +++ b/frontend/src/app/render_wasm/wasm.cljs @@ -29,6 +29,20 @@ ;; When we're rendering in a sync way we want to stop the asynchrous `request-render` (defonce disable-request-render? (atom false)) +(defn module-ready? + [] + (and internal-module (fn? (unchecked-get internal-module "_init")))) + +(defn reset-context-state! + [] + (set! internal-frame-id nil) + (set! canvas nil) + (set! canvas-snapshot-url nil) + (set! gl-context-handle nil) + (set! gl-context nil) + (set! context-initialized? false) + (reset! context-lost? false)) + (defonce serializers #js {:blur-type shared/RawBlurType diff --git a/frontend/src/app/util/clipboard.cljs b/frontend/src/app/util/clipboard.cljs index d06aa5c22e..6f2d0046bf 100644 --- a/frontend/src/app/util/clipboard.cljs +++ b/frontend/src/app/util/clipboard.cljs @@ -70,40 +70,85 @@ ([event options] (from-data-transfer (.-dataTransfer ^js event) options))) -;; FIXME: rename to `write-text` +(def ^:private unavailable-error-message + "Clipboard API is unavailable. This usually happens when the page is served over plain HTTP; serve Penpot over HTTPS to enable copy-to-clipboard.") + +(defn- get-clipboard + "Return the active `navigator.clipboard` object, or nil when the + asynchronous Clipboard API is not exposed (e.g. on insecure origins + per the W3C spec, which is what triggered #4478)." + [] + (unchecked-get js/navigator "clipboard")) + +(defn- unavailable-error + "Build the error wrapped in a rejected Promise so callers can chain + `rx/from`/`.catch` and surface a meaningful message instead of the + opaque `TypeError: Cannot read properties of undefined (reading + 'writeText')` that leaked out of the previous implementation." + [] + (js/Promise.reject (js/Error. unavailable-error-message))) + (defn to-clipboard + "Write a plain-text string to the system clipboard. + + Always returns a Promise. Resolves on success; rejects with a clear + `Error` when `navigator.clipboard` is unavailable (insecure origin) + so callers can chain error handling instead of crashing the UI on a + synchronous `TypeError` like #4478." [data] (assert (string? data) "`data` should be string") - (let [clipboard (unchecked-get js/navigator "clipboard")] - (.writeText ^js clipboard data))) + (let [clipboard (get-clipboard)] + (if (and clipboard (unchecked-get clipboard "writeText")) + (.writeText ^js clipboard data) + (unavailable-error)))) (defn- create-clipboard-item [mimetype promise] (js/ClipboardItem. (js-obj mimetype promise))) -;; FIXME: this API is very confuse (defn to-clipboard-promise + "Write a single asynchronous payload to the clipboard under the given + MIME type. The `promise` is resolved by the browser when the + ClipboardItem is committed. + + Returns the Promise produced by `clipboard.write`, or a rejected + Promise carrying an `Error` when the asynchronous Clipboard API is + not available (insecure origin / unsupported browser). Mirrors + `to-clipboard`'s defensive contract." [mimetype promise] - (let [clipboard (unchecked-get js/navigator "clipboard") - data (create-clipboard-item mimetype promise)] - (.write ^js clipboard #js [data]))) + (let [clipboard (get-clipboard)] + (if (and clipboard (unchecked-get clipboard "write")) + (let [data (create-clipboard-item mimetype promise)] + (.write ^js clipboard #js [data])) + (unavailable-error)))) (defn to-clipboard-multi "Write multiple MIME representations as a single ClipboardItem. - `items` is a map of mime-type (string) -> string payload. - Falls back to `writeText` when the async Clipboard API is unavailable." + `items` is a map of mime-type (string) -> string payload. + + Falls back to `writeText` with the `text/plain` payload (or the + first available payload) when the asynchronous `clipboard.write` + API is unavailable. If neither path is reachable (e.g. insecure + origin), returns a rejected Promise mirroring `to-clipboard`'s + contract instead of throwing synchronously." [items] - (let [clipboard (unchecked-get js/navigator "clipboard")] - (if (and clipboard (unchecked-get clipboard "write")) - (let [obj (reduce-kv - (fn [acc mime payload] - (let [blob (js/Blob. #js [payload] #js {:type mime})] - (unchecked-set acc mime (js/Promise.resolve blob)) - acc)) - #js {} items) + (let [clipboard (get-clipboard)] + (cond + (and clipboard (unchecked-get clipboard "write")) + (let [obj (reduce-kv + (fn [acc mime payload] + (let [blob (js/Blob. #js [payload] #js {:type mime})] + (unchecked-set acc mime (js/Promise.resolve blob)) + acc)) + #js {} items) item (js/ClipboardItem. obj)] (.write ^js clipboard #js [item])) + + (and clipboard (unchecked-get clipboard "writeText")) (when-let [text (or (get items "text/plain") (first (vals items)))] - (.writeText ^js clipboard text))))) + (.writeText ^js clipboard text)) + + :else + (unavailable-error)))) diff --git a/frontend/src/app/util/clipboard.js b/frontend/src/app/util/clipboard.js index 294652666a..f86693dfdc 100644 --- a/frontend/src/app/util/clipboard.js +++ b/frontend/src/app/util/clipboard.js @@ -24,6 +24,13 @@ const exclusiveTypes = [ "text/plain" ]; +const svgTextPattern = + /^(\s*<\?xml[^?]*\?>\s*)?(\s*\s*)*]/i; + +function hasSvgItem(items) { + return items.some((item) => item?.type === "image/svg+xml"); +} + /** * @typedef {Object} ClipboardSettings * @property {Function} [decodeTransit] @@ -59,7 +66,7 @@ function parseText(text, options) { } } - if (/^]/i.test(text)) { + if (svgTextPattern.test(text)) { return new Blob([text], { type: "image/svg+xml" }); } else { return new Blob([text], { type: "text/plain" }); @@ -138,13 +145,27 @@ function sortItems(a, b) { } /** + * Read the active system clipboard via the asynchronous Clipboard API. + * + * Throws a descriptive `Error` when `navigator.clipboard` is unavailable + * (e.g. on insecure origins per the W3C Secure Contexts spec, mirroring + * the failure mode that crashed the copy/write path in #4478 / #6514). + * Without this guard, `navigator.clipboard.read()` raises an opaque + * `TypeError: Cannot read properties of undefined (reading 'read')` and + * the workspace surfaces a generic "Something wrong has happened" toast. * * @param {ClipboardSettings} [options] * @returns {Promise>} */ export async function fromNavigator(options) { options = options || {}; - const items = await navigator.clipboard.read(); + const clipboard = navigator.clipboard; + if (!clipboard || typeof clipboard.read !== "function") { + throw new Error( + "Clipboard API is unavailable. This usually happens when the page is served over plain HTTP; serve Penpot over HTTPS to enable paste-from-clipboard." + ); + } + const items = await clipboard.read(); const result = await Promise.all( Array.from(items).map(async (item) => { const itemAllowedTypes = Array.from(item.types) @@ -207,14 +228,20 @@ export async function fromDataTransfer(dataTransfer, options) { }), ); return items - .filter((item) => !!item) - .reduce((filtered, item) => { + .filter((item) => !!item && item.size > 0) + .reduce((filtered, item, _index, all) => { if ( exclusiveTypes.includes(item.type) && filtered.find((filteredItem) => exclusiveTypes.includes(filteredItem.type)) ) { return filtered; } + if ( + item.type !== "image/svg+xml" && item.type.startsWith("image/") && + hasSvgItem(all) + ) { + return filtered; + } filtered.push(item); return filtered; }, []); diff --git a/frontend/src/app/util/code_gen/markup_html.cljs b/frontend/src/app/util/code_gen/markup_html.cljs index b34b4d026b..01f65c3331 100644 --- a/frontend/src/app/util/code_gen/markup_html.cljs +++ b/frontend/src/app/util/code_gen/markup_html.cljs @@ -38,7 +38,7 @@ indent)) (cfh/text-shape? shape) - (let [text-shape-html (rds/renderToStaticMarkup (mf/element text/text-shape #js {:shape shape :code? true})) + (let [text-shape-html (rds/renderToStaticMarkup (mf/element text/text-shape* #js {:shape shape :is-code true})) text-shape-html (str/replace text-shape-html #"style\s*=\s*[\"'][^\"']*[\"']" "")] (dm/fmt "%
\n%\n%
" indent diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index aa46be4497..1727603670 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -122,6 +122,14 @@ (fn? (.-preventDefault event))) (.preventDefault event))) +(defn prevent-default-context-menu + [^js event] + (let [target (some-> event .-target) + tag (some-> target .-tagName .toLowerCase)] + (when-not (or (#{"input" "textarea"} tag) + (some-> target .-isContentEditable)) + (.preventDefault event)))) + (defn get-target "Extract the target from event instance." [^js event] @@ -323,6 +331,18 @@ ([document ^js text] (.createTextNode document text))) +(defn escape-html + "Escapes special HTML characters in a string so that it can be safely used + as innerHTML without risk of XSS." + [^js text] + (when (some? text) + (-> text + (str/replace "&" "&") + (str/replace "<" "<") + (str/replace ">" ">") + (str/replace "\"" """) + (str/replace "'" "'")))) + (defn set-html! [^js el html] (when (some? el) diff --git a/frontend/src/app/util/mouse.cljs b/frontend/src/app/util/mouse.cljs index 8f3e7652a0..01065e98c5 100644 --- a/frontend/src/app/util/mouse.cljs +++ b/frontend/src/app/util/mouse.cljs @@ -9,7 +9,10 @@ [beicon.v2.core :as rx])) (defrecord MouseEvent [type ctrl shift alt meta]) -(defrecord PointerEvent [source pt ctrl shift alt meta]) +;; `movement` — optional delta (client/window space) for the same tick as `pt`. +;; Used so viewport `pointermove` can emit once (`source` :viewport) while pan/zoom +;; still observe displacement without a second Potok emit. +(defrecord PointerEvent [source pt ctrl shift alt meta movement]) (defrecord ScrollEvent [point]) (defrecord BlurEvent []) @@ -53,6 +56,10 @@ [^PointerEvent ev] (.-pt ev)) +(defn get-pointer-movement + [^PointerEvent ev] + (.-movement ev)) + (defn get-pointer-ctrl-mod [^PointerEvent ev] (.-ctrl ev)) diff --git a/frontend/src/app/util/sse.cljs b/frontend/src/app/util/sse.cljs index 8e1044ec37..40a02f4535 100644 --- a/frontend/src/app/util/sse.cljs +++ b/frontend/src/app/util/sse.cljs @@ -17,22 +17,30 @@ (defn read-stream [^js/ReadableStream stream decode-fn] - (letfn [(read-items [^js reader] - (->> (rx/from (.read reader)) - (rx/mapcat (fn [result] - (if (.-done result) - (rx/empty) - (rx/concat - (rx/of (.-value result)) - (read-items reader)))))))] - (->> (read-items (.getReader stream)) - (rx/mapcat (fn [^js event] - (let [type (.-event event) - data (.-data event) - data (decode-fn data)] - (if (= "error" type) - (rx/throw (ex-info "stream exception" data)) - (rx/of #js {:type type :data data})))))))) + (->> (rx/create + (fn [subs] + (let [reader (.getReader stream)] + (letfn [(pump [] + (-> (.read reader) + (.then (fn [result] + (if (.-done result) + (rx/end! subs) + (do + (rx/push! subs (.-value result)) + (pump))))) + (.catch (fn [cause] + (rx/error! subs cause)))))] + (pump) + ;; teardown: cancel the reader when unsubscribed + (fn [] (.cancel reader)))))) + (rx/mapcat (fn [^js event] + (let [type (.-event event) + data (.-data event) + data (decode-fn data)] + (if (= "error" type) + (rx/throw (ex-info "stream exception" data)) + (rx/of #js {:type type :data data}))))))) + (defn get-type [event] diff --git a/frontend/src/app/util/text/content/styles.cljs b/frontend/src/app/util/text/content/styles.cljs index c67ca4d629..a02e4e57df 100644 --- a/frontend/src/app/util/text/content/styles.cljs +++ b/frontend/src/app/util/text/content/styles.cljs @@ -132,7 +132,9 @@ "Maps attrs to styles" [styles] (let [mapped-styles - (into {} (map attr->style styles))] + (into {} (comp (filter (fn [[_ v]] (some? v))) + (map attr->style)) + styles)] (clj->js mapped-styles))) (defn style-needs-mapping? @@ -199,12 +201,14 @@ (let [style-name (get-style-name-as-css-variable k) [_ style-decode] (get mapping k) style-value (.getPropertyValue style-declaration style-name)] - (when (or (not removed-mixed) (not (contains? mixed-values style-value))) - (assoc acc k (style-decode style-value)))) + (if (or (not removed-mixed) (not (contains? mixed-values style-value))) + (assoc acc k (style-decode style-value)) + acc)) (let [style-name (get-style-name k) style-value (normalize-attr-value k (.getPropertyValue style-declaration style-name))] - (when (or (not removed-mixed) (not (contains? mixed-values style-value))) - (assoc acc k style-value))))) {} txt/text-style-attrs)) + (if (or (not removed-mixed) (not (contains? mixed-values style-value))) + (assoc acc k style-value) + acc)))) {} txt/text-style-attrs)) (defn get-styles-from-event "Returns a ClojureScript object compatible with text nodes" diff --git a/frontend/src/app/util/timers.cljs b/frontend/src/app/util/timers.cljs index f943d35457..afc7e79daf 100644 --- a/frontend/src/app/util/timers.cljs +++ b/frontend/src/app/util/timers.cljs @@ -65,11 +65,20 @@ #(.requestAnimationFrame js/globalThis %) #(js/setTimeout % 16))) +(def ^:private cancel-animation-frame + (if (and (exists? js/globalThis) + (exists? (.-cancelAnimationFrame js/globalThis))) + #(.cancelAnimationFrame js/globalThis %) + #(js/clearTimeout %))) + (defn raf [f] (^function request-animation-frame f)) +(defn cancel-af! + [frame-id] + (^function cancel-animation-frame frame-id)) + (defn idle-then-raf [f] (schedule-on-idle #(^function raf f))) - diff --git a/frontend/src/app/util/webapi.cljs b/frontend/src/app/util/webapi.cljs index 1b3a63b97a..b877108695 100644 --- a/frontend/src/app/util/webapi.cljs +++ b/frontend/src/app/util/webapi.cljs @@ -97,17 +97,22 @@ (defn data-uri->blob [data-uri] - (let [[mtype b64-data] (str/split data-uri ";base64," 2) - mtype (subs mtype (inc (str/index-of mtype ":"))) - decoded (.atob js/window b64-data) - size (.-length ^js decoded) - content (js/Uint8Array. size)] - - (loop [i 0] - (when (< i size) - (aset content i (.charCodeAt ^js decoded i)) - (recur (inc i)))) - + (let [[meta data] (str/split data-uri "," 2) + mtype-end (or (str/index-of meta ";") (count meta)) + mtype (subs meta (inc (str/index-of meta ":")) mtype-end) + base64? (str/includes? meta ";base64") + content (if base64? + (let [decoded (.atob js/globalThis data) + size (.-length ^js decoded) + bytes (js/Uint8Array. size)] + (loop [i 0] + (when (< i size) + (aset bytes i (.charCodeAt ^js decoded i)) + (recur (inc i)))) + bytes) + ;; Data URIs can be plain/URL-encoded (e.g. ;utf8,). + ;; Encode into UTF-8 bytes before creating the Blob. + (.encode (js/TextEncoder.) (.decodeURIComponent js/globalThis data)))] (create-blob content mtype))) (defn get-current-selected-text diff --git a/frontend/src/app/util/worker.cljs b/frontend/src/app/util/worker.cljs index b23bbbee92..8d87a76795 100644 --- a/frontend/src/app/util/worker.cljs +++ b/frontend/src/app/util/worker.cljs @@ -90,8 +90,8 @@ "Return a initialized webworker instance." [path on-error] (let [instance (js/Worker. path) - bus (rx/subject) - worker (Worker. instance (rx/to-observable bus)) + bus (rx/subject) + worker (Worker. instance (rx/to-observable bus)) handle-message (fn [event] diff --git a/frontend/src/app/worker/import.cljs b/frontend/src/app/worker/import.cljs index 20c314f012..402da4ad5c 100644 --- a/frontend/src/app/worker/import.cljs +++ b/frontend/src/app/worker/import.cljs @@ -23,6 +23,22 @@ (log/set-level! :warn) +(defn- import-cause-message + "Prefer the server `:hint` (full text, e.g. SSE error payload), then `:explain` + when present; avoid the generic `stream exception` wrapper when a payload exists." + [cause default-msg] + (let [data (ex-data cause) + hint (some-> data :hint str/trim) + explain (some-> data :explain str/trim)] + (cond + (not (str/blank? hint)) hint + (not (str/blank? explain)) explain + :else + (let [msg (some-> (ex-message cause) str/trim)] + (if (or (str/blank? msg) (= msg "stream exception")) + default-msg + msg))))) + ;; Upload changes batches size (def ^:const change-batch-size 100) @@ -122,7 +138,7 @@ :error (tr "dashboard.import.analyze-error")})))) (rx/catch (fn [cause] - (let [error (or (ex-message cause) (tr "dashboard.import.analyze-error"))] + (let [error (import-cause-message cause (tr "dashboard.import.analyze-error"))] (rx/of (assoc file :error error :status :error)))))))) (defmethod impl/handler :analyze-import @@ -178,7 +194,7 @@ :project-id project-id :cause cause) (rx/of {:status :error - :error (ex-message cause) + :error (import-cause-message cause (tr "labels.error")) :file-id (:file-id data)}))))))) (->> (rx/from binfile-v3) @@ -212,8 +228,9 @@ :project-id project-id ::log/sync? true :cause cause) - (->> (rx/from entries) - (rx/map (fn [entry] - {:status :error - :error (ex-message cause) - :file-id (:file-id entry)})))))))))))) + (let [err (import-cause-message cause (tr "labels.error"))] + (->> (rx/from entries) + (rx/map (fn [entry] + {:status :error + :error err + :file-id (:file-id entry)}))))))))))))) diff --git a/frontend/src/app/worker/index.cljs b/frontend/src/app/worker/index.cljs index 3ff1f37d19..20abaf00e7 100644 --- a/frontend/src/app/worker/index.cljs +++ b/frontend/src/app/worker/index.cljs @@ -63,23 +63,35 @@ (log/dbg :hint "page index updated" :id page-id :elapsed elapsed ::log/sync? true)))) nil)) +(defn- run-query-snap + [index page-id frame-id axis ranges bounds] + (let [match-bounds? + (fn [[_ data]] + (some #(or (= :guide (:type %)) + (= :layout (:type %)) + (grc/contains-point? bounds (:pt %))) data)) + + xform + (comp (mapcat #(snap/query index page-id frame-id axis %)) + (distinct) + (filter match-bounds?))] + (into [] xform ranges))) + ;; FIXME: schema (defmethod impl/handler :index/query-snap - [{:keys [page-id frame-id axis ranges bounds] :as message}] + [{:keys [page-id frame-id axis ranges bounds]}] (if-let [index (get @state ::snap)] - (let [match-bounds? - (fn [[_ data]] - (some #(or (= :guide (:type %)) - (= :layout (:type %)) - (grc/contains-point? bounds (:pt %))) data)) - - xform - (comp (mapcat #(snap/query index page-id frame-id axis %)) - (distinct) - (filter match-bounds?))] - (into [] xform ranges)) + (run-query-snap index page-id frame-id axis ranges bounds) [])) +;; Single round-trip for X+Y snap used by `app.main.snap/closest-snap` (e.g. shape drag). +(defmethod impl/handler :index/query-snap-xy + [{:keys [page-id frame-id bounds x-ranges y-ranges]}] + (if-let [index (get @state ::snap)] + {:x (run-query-snap index page-id frame-id :x x-ranges bounds) + :y (run-query-snap index page-id frame-id :y y-ranges bounds)} + {:x [] :y []})) + ;; FIXME: schema (defmethod impl/handler :index/query-selection diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs index 65ac296895..985a936bcc 100644 --- a/frontend/src/debug.cljs +++ b/frontend/src/debug.cljs @@ -135,6 +135,14 @@ (wasm.mem/free) text))) +(defn ^:export wasmRenderStats + [] + (let [module wasm/internal-module + f (when module (unchecked-get module "_render_stats"))] + (if (fn? f) + (wasm.h/call module "_render_stats") + (js/console.warn "[debug] render-wasm module not ready or missing _render_stats")))) + (defn ^:export wasmAtlasConsole "Logs the current render-wasm atlas as an image in the JS console (if present)." [] @@ -157,6 +165,15 @@ (js/console.warn "[debug] render-wasm module not ready or missing _debug_atlas_base64") "")))) +(defn ^:export wasmSurfaceConsole + "Logs the render-wasm surface id as an image in the JS console." + [id] + (let [module wasm/internal-module + f (when module (unchecked-get module "_debug_surface_console"))] + (if (fn? f) + (wasm.h/call module "_debug_surface_console" id) + (js/console.warn "[debug] render-wasm module not ready or missing _debug_surface_console")))) + (defn ^:export wasmCacheConsole "Logs the current render-wasm cache surface as an image in the JS console." [] diff --git a/frontend/stylelint.config.mjs b/frontend/stylelint.config.mjs index 99a9474a2b..415930018d 100644 --- a/frontend/stylelint.config.mjs +++ b/frontend/stylelint.config.mjs @@ -13,6 +13,7 @@ export default { rules: { "at-rule-no-unknown": null, "declaration-property-value-no-unknown": null, + "property-no-unknown": [true, { ignoreProperties: ["text-box"] }], "selector-pseudo-class-no-unknown": [ true, { ignorePseudoClasses: ["global"] }, // TODO: Avoid global selector usage and remove this exception diff --git a/frontend/test/frontend_tests/data/nitrate_test.cljs b/frontend/test/frontend_tests/data/nitrate_test.cljs new file mode 100644 index 0000000000..bd5175f277 --- /dev/null +++ b/frontend/test/frontend_tests/data/nitrate_test.cljs @@ -0,0 +1,45 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns frontend-tests.data.nitrate-test + (:require + [app.common.uri :as u] + [app.main.data.nitrate :as dnt] + [cljs.test :as t :include-macros true])) + +(t/deftest build-nitrate-callback-urls-preserves-hash-query + (t/testing "appends subscription to an existing query inside the hash route" + (let [callbacks (dnt/build-nitrate-callback-urls + "https://localhost:3449/#/dashboard/recent?team-id=e6666530-0216-81c8-8007-f17d6087b74f")] + (t/is (= "https://localhost:3449/#/dashboard/recent?team-id=e6666530-0216-81c8-8007-f17d6087b74f&subscription=subscribed-to-penpot-nitrate" + (:success-callback callbacks))) + (t/is (= "https://localhost:3449/#/dashboard/recent?team-id=e6666530-0216-81c8-8007-f17d6087b74f&subscription=nitrate-checkout-error" + (:error-callback callbacks))) + (t/is (= "https://localhost:3449/#/dashboard/recent?team-id=e6666530-0216-81c8-8007-f17d6087b74f&subscription=nitrate-checkout-finish-error" + (:finish-error-callback callbacks))) + (t/is (= "https://localhost:3449/#/dashboard/recent?team-id=e6666530-0216-81c8-8007-f17d6087b74f&subscription=nitrate-checkout-cancelled" + (:cancel-callback callbacks)))))) + +(t/deftest build-nitrate-callback-urls-adds-hash-query-when-missing + (t/testing "adds a hash query when the route has no query string yet" + (let [callbacks (dnt/build-nitrate-callback-urls + "https://localhost:3449/#/settings/subscriptions")] + (t/is (= "https://localhost:3449/#/settings/subscriptions?subscription=subscribed-to-penpot-nitrate" + (:success-callback callbacks)))))) + +(t/deftest build-nitrate-callback-urls-adds-regular-query-without-hash + (t/testing "falls back to the regular URL query when there is no hash route" + (let [callbacks (dnt/build-nitrate-callback-urls + "https://localhost:3449/admin-console/licenses/billing?foo=bar")] + (t/is (= "https://localhost:3449/admin-console/licenses/billing?foo=bar&subscription=subscribed-to-penpot-nitrate" + (:success-callback callbacks)))))) + +(t/deftest build-nitrate-callback-urls-accepts-uri-object + (t/testing "accepts a URI object as base url (used by the nitrate-form modal)" + (let [callbacks (dnt/build-nitrate-callback-urls + (u/uri "https://localhost:3449/#/settings/subscriptions"))] + (t/is (= "https://localhost:3449/#/settings/subscriptions?subscription=nitrate-checkout-error" + (:error-callback callbacks)))))) diff --git a/frontend/test/frontend_tests/data/workspace_colors_test.cljs b/frontend/test/frontend_tests/data/workspace_colors_test.cljs index 947bb6e820..7b2006eb22 100644 --- a/frontend/test/frontend_tests/data/workspace_colors_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_colors_test.cljs @@ -46,3 +46,39 @@ (ptk/event? (dwc/add-fill #{uuid/zero} color3 1)))) (t/is (thrown? js/Error (ptk/event? (dwc/add-fill #{uuid/zero} color4 1)))))) + + +(t/deftest update-colorpicker-color-skips-add-recent-on-incomplete-image-state + ;; Regression for https://github.com/penpot/penpot/issues/8443. + ;; + ;; Closing the fill dialog while the image upload is still in flight leaves + ;; the colorpicker's current-color with only :opacity (no :image, :gradient, + ;; or :color). Before the guard, ptk/watch eagerly built (add-recent-color + ;; partial), which calls (clr/check-color partial) and threw an "expected + ;; valid color" assertion that surfaced as an Internal Assertion Error toast. + (let [partial-image-state {:colorpicker {:type :image + :current-color {:opacity 1}}} + event (dwc/update-colorpicker-color {} true)] + (t/is (nil? (ptk/watch event partial-image-state nil))))) + + +(t/deftest update-colorpicker-color-skips-add-recent-when-only-opacity-on-color-type + ;; Same incomplete-state shape, but the colorpicker is on the plain-color tab + ;; (e.g. the user clicked elsewhere before the picker had a chance to commit + ;; a hex). The existing :type-and-:color-nil guard sits on the colorpicker + ;; map's :type — but get-color-from-colorpicker-state strips :type from its + ;; output, so that guard never fires. The schema-based guard catches it. + (let [colorless-state {:colorpicker {:type :color + :current-color {:opacity 1}}} + event (dwc/update-colorpicker-color {} true)] + (t/is (nil? (ptk/watch event colorless-state nil))))) + + +(t/deftest update-colorpicker-color-still-emits-recent-for-valid-plain-color + ;; Sanity check: a fully-populated plain color still produces a watch + ;; observable (the rx/of branch is reached) so we know the guard isn't + ;; over-eager and silently dropping legitimate colors. + (let [valid-state {:colorpicker {:type :color + :current-color {:color "#ff0000" :opacity 1}}} + event (dwc/update-colorpicker-color {} true)] + (t/is (some? (ptk/watch event valid-state nil))))) diff --git a/frontend/test/frontend_tests/data/workspace_shortcuts_test.cljs b/frontend/test/frontend_tests/data/workspace_shortcuts_test.cljs new file mode 100644 index 0000000000..efafa722ff --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_shortcuts_test.cljs @@ -0,0 +1,112 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns frontend-tests.data.workspace-shortcuts-test + (:require + [app.config :as cf] + [app.main.data.workspace.shortcuts :as shortcuts] + [app.main.store :as st] + [cljs.test :as t :include-macros true])) + +(defn- keyboard-event + [{:keys [ctrl? meta? shift? code prevented?]}] + #js {:ctrlKey (boolean ctrl?) + :metaKey (boolean meta?) + :shiftKey (boolean shift?) + :code code + :preventDefault #(reset! prevented? true)}) + +(defn- mock-emit! + [emitted] + (fn + ([] nil) + ([event] (swap! emitted conj event)) + ([event & _more] nil))) + +(t/deftest display-guides-handler-detects-physical-keys + (t/testing "US layout uses Ctrl+Quote" + (let [prevented? (atom false) + emitted (atom [])] + (with-redefs [cf/check-platform? (constantly false) + st/emit! (mock-emit! emitted)] + (shortcuts/on-display-guides-keydown + (keyboard-event {:ctrl? true :code "Quote" :prevented? prevented?}))) + (t/is (true? @prevented?)) + (t/is (= 1 (count @emitted))))) + + (t/testing "German layout uses Ctrl+Shift+Backslash" + (let [prevented? (atom false) + emitted (atom [])] + (with-redefs [cf/check-platform? (constantly false) + st/emit! (mock-emit! emitted)] + (shortcuts/on-display-guides-keydown + (keyboard-event {:ctrl? true :shift? true :code "Backslash" :prevented? prevented?}))) + (t/is (true? @prevented?)) + (t/is (= 1 (count @emitted))))) + + (t/testing "macOS uses Meta+Quote" + (let [prevented? (atom false) + emitted (atom [])] + (with-redefs [cf/check-platform? (constantly true) + st/emit! (mock-emit! emitted)] + (shortcuts/on-display-guides-keydown + (keyboard-event {:meta? true :code "Quote" :prevented? prevented?}))) + (t/is (true? @prevented?)) + (t/is (= 1 (count @emitted))))) + + (t/testing "macOS German layout uses Meta+Shift+Backslash" + (let [prevented? (atom false) + emitted (atom [])] + (with-redefs [cf/check-platform? (constantly true) + st/emit! (mock-emit! emitted)] + (shortcuts/on-display-guides-keydown + (keyboard-event {:meta? true :shift? true :code "Backslash" :prevented? prevented?}))) + (t/is (true? @prevented?)) + (t/is (= 1 (count @emitted))))) + + (t/testing "macOS ignores Ctrl+Quote" + (let [prevented? (atom false) + emitted (atom [])] + (with-redefs [cf/check-platform? (constantly true) + st/emit! (mock-emit! emitted)] + (shortcuts/on-display-guides-keydown + (keyboard-event {:ctrl? true :code "Quote" :prevented? prevented?}))) + (t/is (false? @prevented?)) + (t/is (empty? @emitted)))) + + (t/testing "macOS snap-guides shortcut stays out of this handler" + (let [prevented? (atom false) + emitted (atom [])] + (with-redefs [cf/check-platform? (constantly true) + st/emit! (mock-emit! emitted)] + (shortcuts/on-display-guides-keydown + (keyboard-event {:meta? true :shift? true :code "Quote" :prevented? prevented?}))) + (t/is (false? @prevented?)) + (t/is (empty? @emitted)))) + + (t/testing "matching physical keys without the platform modifier are ignored" + (let [prevented? (atom false) + emitted (atom [])] + (with-redefs [cf/check-platform? (constantly false) + st/emit! (mock-emit! emitted)] + (shortcuts/on-display-guides-keydown + (keyboard-event {:code "Quote" :prevented? prevented?})) + (shortcuts/on-display-guides-keydown + (keyboard-event {:shift? true :code "Backslash" :prevented? prevented?}))) + (t/is (false? @prevented?)) + (t/is (empty? @emitted)))) + + (t/testing "snap-guides and alignment shortcuts stay out of this handler" + (let [prevented? (atom false) + emitted (atom [])] + (with-redefs [cf/check-platform? (constantly false) + st/emit! (mock-emit! emitted)] + (shortcuts/on-display-guides-keydown + (keyboard-event {:ctrl? true :shift? true :code "Quote" :prevented? prevented?})) + (shortcuts/on-display-guides-keydown + (keyboard-event {:ctrl? true :code "Backslash" :prevented? prevented?}))) + (t/is (false? @prevented?)) + (t/is (empty? @emitted))))) diff --git a/frontend/test/frontend_tests/helpers/wasm.cljs b/frontend/test/frontend_tests/helpers/wasm.cljs index f64d7b7d59..4bfe953c0a 100644 --- a/frontend/test/frontend_tests/helpers/wasm.cljs +++ b/frontend/test/frontend_tests/helpers/wasm.cljs @@ -75,6 +75,11 @@ (track! :set-structure-modifiers) nil) +(defn- mock-set-modifiers + [_modifiers] + (track! :set-modifiers) + nil) + (defn- mock-set-shape-grow-type [_grow-type] (track! :set-shape-grow-type) @@ -141,6 +146,7 @@ {:clean-modifiers wasm.api/clean-modifiers :set-structure-modifiers wasm.api/set-structure-modifiers :propagate-modifiers wasm.api/propagate-modifiers + :set-modifiers wasm.api/set-modifiers :set-shape-grow-type wasm.api/set-shape-grow-type :set-shape-text-content wasm.api/set-shape-text-content :set-shape-text-images wasm.api/set-shape-text-images @@ -152,6 +158,7 @@ (set! wasm.api/clean-modifiers mock-clean-modifiers) (set! wasm.api/set-structure-modifiers mock-set-structure-modifiers) (set! wasm.api/propagate-modifiers mock-propagate-modifiers) + (set! wasm.api/set-modifiers mock-set-modifiers) (set! wasm.api/set-shape-grow-type mock-set-shape-grow-type) (set! wasm.api/set-shape-text-content mock-set-shape-text-content) (set! wasm.api/set-shape-text-images mock-set-shape-text-images) @@ -167,6 +174,7 @@ (set! wasm.api/clean-modifiers (:clean-modifiers orig)) (set! wasm.api/set-structure-modifiers (:set-structure-modifiers orig)) (set! wasm.api/propagate-modifiers (:propagate-modifiers orig)) + (set! wasm.api/set-modifiers (:set-modifiers orig)) (set! wasm.api/set-shape-grow-type (:set-shape-grow-type orig)) (set! wasm.api/set-shape-text-content (:set-shape-text-content orig)) (set! wasm.api/set-shape-text-images (:set-shape-text-images orig)) diff --git a/frontend/test/frontend_tests/logic/components_and_tokens.cljs b/frontend/test/frontend_tests/logic/components_and_tokens.cljs index 2ec37b8db1..3c331bd98e 100644 --- a/frontend/test/frontend_tests/logic/components_and_tokens.cljs +++ b/frontend/test/frontend_tests/logic/components_and_tokens.cljs @@ -430,10 +430,7 @@ (t/is (mth/close? (get c-frame1' :width) 200)) (t/is (mth/close? (get c-frame1' :height) 200)) - (t/is (empty? (:touched c-frame1'))) - - (t/testing "WASM mocks were exercised" - (t/is (pos? (thw/call-count :propagate-modifiers)))))))))] + (t/is (empty? (:touched c-frame1'))))))))] (tohs/run-store-async store step2 events identity)))) diff --git a/frontend/test/frontend_tests/logic/frame_guides_test.cljs b/frontend/test/frontend_tests/logic/frame_guides_test.cljs index e20bc99e26..3ed35d3a91 100644 --- a/frontend/test/frontend_tests/logic/frame_guides_test.cljs +++ b/frontend/test/frontend_tests/logic/frame_guides_test.cljs @@ -56,10 +56,13 @@ ;; guide has moved (t/is (= (:position guide') 100)) - ;; WASM mocks were exercised - (t/is (pos? (thw/call-count :clean-modifiers))) - (t/is (pos? (thw/call-count :set-structure-modifiers))) - (t/is (pos? (thw/call-count :propagate-modifiers))))))))))) + ;; WASM bridge was exercised. `dw/update-position` + ;; routes through `apply-wasm-modifiers`, which for + ;; translation-only updates calls only `clean-modifiers` + ;; and computes the per-descendant transforms in CLJS + ;; (skipping `set-structure-modifiers` and + ;; `propagate-modifiers`). + (t/is (pos? (thw/call-count :clean-modifiers))))))))))) diff --git a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs index 39b44e861f..80a3051ba0 100644 --- a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs +++ b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs @@ -11,6 +11,7 @@ [app.common.uuid :as uuid] [app.main.store :as st] [app.plugins.api :as api] + [app.util.object :as obj] [cljs.test :as t :include-macros true] [frontend-tests.helpers.state :as ths] [frontend-tests.helpers.wasm :as thw])) @@ -30,7 +31,28 @@ ^js shape (.createRectangle context) get-shape-path - #(vector :files (aget file "$id") :data :pages-index (aget page "$id") :objects (aget shape "$id") %)] + #(vector :files (aget file "$id") :data :pages-index (aget page "$id") :objects (aget shape "$id") %) + + gradient + (fn [] + #js {:type "linear" + :startX 0.5 + :startY 0 + :endX 0.5 + :endY 1 + :width 1 + :stops #js [#js {:color "#b400ff" :opacity 1 :offset 0} + #js {:color "#0c3fd5" :opacity 1 :offset 1}]}) + + parsed-gradient + {:type :linear + :start-x 0.5 + :start-y 0 + :end-x 0.5 + :end-y 1 + :width 1 + :stops [{:color "#b400ff" :opacity 1 :offset 0} + {:color "#0c3fd5" :opacity 1 :offset 1}]}] (t/testing "Basic shape properites" (t/testing " - name" @@ -218,7 +240,75 @@ (t/is (= (get-in @store (get-shape-path :strokes)) [{:stroke-color "#fabada" :stroke-opacity 1 :stroke-width 5}])) (t/is (= (-> (. ^js shape -strokes) (aget 0) (aget "strokeColor")) "#fabada")) (t/is (= (-> (. ^js shape -strokes) (aget 0) (aget "strokeOpacity")) 1)) - (t/is (= (-> (. ^js shape -strokes) (aget 0) (aget "strokeWidth")) 5)))) + (t/is (= (-> (. ^js shape -strokes) (aget 0) (aget "strokeWidth")) 5))) + + (t/testing " - fills per-element property mutation (bug #8357)" + (set! (.-fills shape) #js [#js {:fillColor "#fabada" :fillOpacity 1}]) + (obj/set! (aget (.-fills shape) 0) "fillColor" "#ff0000") + (t/is (= (get-in @store (get-shape-path :fills)) [{:fill-color "#ff0000" :fill-opacity 1}])) + (t/is (= (-> (. shape -fills) (aget 0) (aget "fillColor")) "#ff0000"))) + + (t/testing " - fills element replacement (bug #8357)" + (set! (.-fills shape) #js [#js {:fillColor "#fabada" :fillOpacity 1}]) + (aset (.-fills shape) 0 #js {:fillColor "#00ff00" :fillOpacity 0.5}) + (t/is (= (get-in @store (get-shape-path :fills)) [{:fill-color "#00ff00" :fill-opacity 0.5}]))) + + (t/testing " - fills push/pop (bug #8357)" + (set! (.-fills shape) #js [#js {:fillColor "#fabada" :fillOpacity 1}]) + (.push (.-fills shape) #js {:fillColor "#00ff00" :fillOpacity 1}) + (t/is (= (get-in @store (get-shape-path :fills)) + [{:fill-color "#fabada" :fill-opacity 1} + {:fill-color "#00ff00" :fill-opacity 1}])) + (.pop (.-fills shape)) + (t/is (= (get-in @store (get-shape-path :fills)) [{:fill-color "#fabada" :fill-opacity 1}]))) + + (t/testing " - fills gradient assignment replaces solid color (bug #8357)" + (set! (.-fills shape) #js [#js {:fillColor "#fabada" :fillOpacity 1}]) + (obj/set! (aget (.-fills shape) 0) "fillColorGradient" (gradient)) + (t/is (= (get-in @store (get-shape-path :fills)) + [{:fill-opacity 1 :fill-color-gradient parsed-gradient}])) + (t/is (nil? (-> (. shape -fills) (aget 0) (aget "fillColor"))))) + + (t/testing " - fills nested gradient mutation (bug #8357)" + (set! (.-fills shape) #js [#js {:fillColorGradient (gradient) :fillOpacity 1}]) + (let [fill-gradient (-> (. shape -fills) (aget 0) (aget "fillColorGradient")) + stop (-> fill-gradient (aget "stops") (aget 0))] + (obj/set! fill-gradient "startX" 0.25) + (obj/set! stop "color" "#ffffff") + (t/is (= (get-in @store (get-shape-path :fills)) + [{:fill-opacity 1 + :fill-color-gradient (-> parsed-gradient + (assoc :start-x 0.25) + (assoc-in [:stops 0 :color] "#ffffff"))}])))) + + (t/testing " - strokes per-element property mutation (bug #8357)" + (set! (.-strokes shape) #js [#js {:strokeColor "#fabada" :strokeOpacity 1 :strokeWidth 5}]) + (obj/set! (aget (.-strokes shape) 0) "strokeColor" "#0000ff") + (t/is (= (get-in @store (get-shape-path :strokes)) [{:stroke-color "#0000ff" :stroke-opacity 1 :stroke-width 5}]))) + + (t/testing " - strokes element replacement (bug #8357)" + (set! (.-strokes shape) #js [#js {:strokeColor "#fabada" :strokeOpacity 1 :strokeWidth 5}]) + (aset (.-strokes shape) 0 #js {:strokeColor "#00ff00" :strokeOpacity 0.5 :strokeWidth 2}) + (t/is (= (get-in @store (get-shape-path :strokes)) [{:stroke-color "#00ff00" :stroke-opacity 0.5 :stroke-width 2}]))) + + (t/testing " - strokes gradient assignment replaces solid color (bug #8357)" + (set! (.-strokes shape) #js [#js {:strokeColor "#fabada" :strokeOpacity 1 :strokeWidth 5}]) + (obj/set! (aget (.-strokes shape) 0) "strokeColorGradient" (gradient)) + (t/is (= (get-in @store (get-shape-path :strokes)) + [{:stroke-opacity 1 :stroke-width 5 :stroke-color-gradient parsed-gradient}]))) + + (t/testing " - strokes nested gradient mutation (bug #8357)" + (set! (.-strokes shape) #js [#js {:strokeColorGradient (gradient) :strokeOpacity 1 :strokeWidth 5}]) + (let [stroke-gradient (-> (. shape -strokes) (aget 0) (aget "strokeColorGradient")) + stop (-> stroke-gradient (aget "stops") (aget 1))] + (obj/set! stroke-gradient "endY" 0.75) + (obj/set! stop "opacity" 0.25) + (t/is (= (get-in @store (get-shape-path :strokes)) + [{:stroke-opacity 1 + :stroke-width 5 + :stroke-color-gradient (-> parsed-gradient + (assoc :end-y 0.75) + (assoc-in [:stops 1 :opacity] 0.25))}]))))) (t/testing "Relative properties" (let [board (.createBoard context)] diff --git a/frontend/test/frontend_tests/plugins/parser_test.cljs b/frontend/test/frontend_tests/plugins/parser_test.cljs new file mode 100644 index 0000000000..4b257b2023 --- /dev/null +++ b/frontend/test/frontend_tests/plugins/parser_test.cljs @@ -0,0 +1,33 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns frontend-tests.plugins.parser-test + (:require + [app.common.geom.point :as gpt] + [app.plugins.parser :as parser] + [cljs.test :as t :include-macros true])) + +(t/deftest test-parse-point-returns-gpt-point-record + ;; Regression test for issue #8409. + ;; + ;; The plugin parser used to return a plain map `{:x … :y …}`, but the + ;; shape-interaction schema expects `::gpt/point` (a Point record). + ;; Plugin `addInteraction` calls with an `open-overlay` action and + ;; `manualPositionLocation` were silently rejected by validation. + (t/testing "parse-point returns nil for nil input" + (t/is (nil? (parser/parse-point nil)))) + + (t/testing "parse-point returns a gpt/point record for valid input" + (let [result (parser/parse-point #js {:x 10 :y 20})] + (t/is (gpt/point? result)) + (t/is (= 10 (:x result))) + (t/is (= 20 (:y result))))) + + (t/testing "parse-point passes gpt/point? for a zero point" + (let [result (parser/parse-point #js {:x 0 :y 0})] + (t/is (gpt/point? result)) + (t/is (= 0 (:x result))) + (t/is (= 0 (:y result)))))) diff --git a/frontend/test/frontend_tests/plugins/tokens_test.cljs b/frontend/test/frontend_tests/plugins/tokens_test.cljs new file mode 100644 index 0000000000..3c0d1cda1a --- /dev/null +++ b/frontend/test/frontend_tests/plugins/tokens_test.cljs @@ -0,0 +1,82 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns frontend-tests.plugins.tokens-test + (:require + [app.plugins.tokens :as ptok] + [cljs.test :as t :include-macros true])) + +;; Regression coverage for issue #9162. +;; +;; Plugin code calling `shape.applyToken(token, ["fill"])` or +;; `token.applyToShapes([rect], ["fill"])` from JavaScript supplies a JS +;; array of strings. Penpot's plugin proxies expect a Clojure set of +;; keywords. Two coupled defects made these calls silently no-op (or, with +;; `throwValidationErrors` enabled, throw a "check error"): +;; +;; 1. `token-attr-plugin->token-attr` only consulted its alias map when +;; the input was already a keyword — string inputs like "fill" or +;; "border-radius-top-left" fell through to the identity branch +;; unchanged, so the downstream `cto/token-attr?` predicate (which +;; checks against a set of keywords) returned false. +;; 2. The `applyToken` / `applyToShapes` / `applyToSelected` schemas used +;; plain `[:set ...]`, which does not have a `:decode/json` +;; transformer for the JS array → Clojure set coercion. Penpot's +;; custom `[::sm/set ...]` does. Switching to the registered set type +;; lets the standard JSON decoder pipeline turn the JS argument into +;; a set of strings, after which the `[:and ::sm/keyword [:fn +;; token-attr?]]` element schema coerces each string to a keyword and +;; validates it. +;; +;; These helper-level tests pin the string-friendly conversion contract; +;; the schema-level fix is covered by the existing plugin integration +;; suite that exercises `applyToken` end-to-end. + +(t/deftest token-attr-plugin->token-attr-passes-canonical-form-through + ;; Both already-canonical short names and unaliased names pass through + ;; unchanged. + (t/is (= :fill (ptok/token-attr-plugin->token-attr :fill))) + (t/is (= :stroke-color (ptok/token-attr-plugin->token-attr :stroke-color))) + (t/is (= :r1 (ptok/token-attr-plugin->token-attr :r1))) + (t/is (= :p2 (ptok/token-attr-plugin->token-attr :p2)))) + +(t/deftest token-attr-plugin->token-attr-resolves-verbose-plugin-aliases + ;; Plugin-side verbose names (e.g. `:border-radius-top-left`) map to + ;; their canonical short internal form (`:r1`) so plugin authors can + ;; spell the corner explicitly without the engine having to know both. + (t/is (= :r1 (ptok/token-attr-plugin->token-attr :border-radius-top-left))) + (t/is (= :r2 (ptok/token-attr-plugin->token-attr :border-radius-top-right))) + (t/is (= :r3 (ptok/token-attr-plugin->token-attr :border-radius-bottom-right))) + (t/is (= :r4 (ptok/token-attr-plugin->token-attr :border-radius-bottom-left))) + (t/is (= :p1 (ptok/token-attr-plugin->token-attr :padding-top-left))) + (t/is (= :m3 (ptok/token-attr-plugin->token-attr :margin-bottom-right)))) + +(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"))) + (t/is (= :stroke-color (ptok/token-attr-plugin->token-attr "stroke-color"))) + ;; Verbose plugin aliases work via the string path too. + (t/is (= :r1 (ptok/token-attr-plugin->token-attr "border-radius-top-left"))) + (t/is (= :m3 (ptok/token-attr-plugin->token-attr "margin-bottom-right")))) + +(t/deftest token-attr?-accepts-keyword-input + (t/is (true? (boolean (ptok/token-attr? :fill)))) + (t/is (true? (boolean (ptok/token-attr? :stroke-color)))) + (t/is (true? (boolean (ptok/token-attr? :r1)))) + (t/is (true? (boolean (ptok/token-attr? :p2))))) + +(t/deftest token-attr?-accepts-string-input + ;; Same JS-array-of-strings reproducer as the issue, exercised at the + ;; predicate layer the plugin schemas call into. + (t/is (true? (boolean (ptok/token-attr? "fill")))) + (t/is (true? (boolean (ptok/token-attr? "stroke-color")))) + (t/is (true? (boolean (ptok/token-attr? "r1")))) + (t/is (true? (boolean (ptok/token-attr? "m3"))))) + +(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")))) + (t/is (false? (boolean (ptok/token-attr? nil))))) diff --git a/frontend/test/frontend_tests/plugins/utils_test.cljs b/frontend/test/frontend_tests/plugins/utils_test.cljs new file mode 100644 index 0000000000..8fbf4e4f40 --- /dev/null +++ b/frontend/test/frontend_tests/plugins/utils_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 + +(ns frontend-tests.plugins.utils-test + (:require + [app.plugins.utils :as plugins.utils] + [cljs.test :as t :include-macros true])) + +;; Access the private flattener for direct testing. +(def ^:private flatten-error-map @#'plugins.utils/flatten-error-map) + +(t/deftest test-flatten-error-map-flat-input + ;; Regression test for issue #9417. + ;; + ;; When a malli error path has a single element, `interpret-schema-problem` + ;; produces a flat map. The flattener must pass that through unchanged. + (let [result (flatten-error-map {:group {:message "must be string"}})] + (t/is (= [["group" "must be string"]] result)))) + +(t/deftest test-flatten-error-map-nested-input + ;; Regression test for issue #9417. + ;; + ;; When a malli error path has multiple elements, `interpret-schema-problem` + ;; produces a nested map via `(assoc-in acc path …)`. The old plugin + ;; consumer destructured assuming a flat shape, so the nested case rendered + ;; the message text as the field name (`Field message is invalid`) instead + ;; of the real validation reason. The flattener must descend until it finds + ;; a leaf carrying a `:message`. + (let [explain {:sets {0 {:name {:message "must not be empty"}}}} + result (set (flatten-error-map explain))] + (t/is (= #{["sets.0.name" "must not be empty"]} result)))) + +(t/deftest test-flatten-error-map-multiple-fields + ;; Multiple validation problems on the same explain produce multiple + ;; entries, none of which clobber each other. + (let [explain {:group {:message "must be string"} + :sets {0 {:message "set not found"}}} + result (set (flatten-error-map explain))] + (t/is (= #{["group" "must be string"] + ["sets.0" "set not found"]} result)))) + +(t/deftest test-flatten-error-map-mixed-key-types + ;; Numeric indices (from vector positions in the malli path) must render + ;; cleanly; string keys must also be accepted alongside keywords. + (let [explain {:items {2 {"label" {:message "invalid label"}}}} + [[path message]] (flatten-error-map explain)] + (t/is (= "items.2.label" path)) + (t/is (= "invalid label" message)))) + +(t/deftest test-flatten-error-map-empty + ;; No validation errors -> no output (callers join with ". " and would + ;; otherwise emit an empty string, which is fine). + (t/is (empty? (flatten-error-map {})))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 174ef34056..d7048409a8 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -1,13 +1,18 @@ (ns frontend-tests.runner (:require + [app.common.logging :as l] [cljs.test :as t] + [clojure.string :as str] + [clojure.tools.cli :refer [parse-opts]] [frontend-tests.basic-shapes-test] [frontend-tests.copy-as-svg-test] + [frontend-tests.data.nitrate-test] [frontend-tests.data.repo-test] [frontend-tests.data.uploads-test] [frontend-tests.data.viewer-test] [frontend-tests.data.workspace-colors-test] [frontend-tests.data.workspace-media-test] + [frontend-tests.data.workspace-shortcuts-test] [frontend-tests.data.workspace-texts-test] [frontend-tests.data.workspace-thumbnails-test] [frontend-tests.errors-test] @@ -20,6 +25,9 @@ [frontend-tests.logic.pasting-in-containers-test] [frontend-tests.main-errors-test] [frontend-tests.plugins.context-shapes-test] + [frontend-tests.plugins.parser-test] + [frontend-tests.plugins.tokens-test] + [frontend-tests.plugins.utils-test] [frontend-tests.svg-fills-test] [frontend-tests.tokens.import-export-test] [frontend-tests.tokens.logic.token-actions-test] @@ -32,7 +40,9 @@ [frontend-tests.util-object-test] [frontend-tests.util-range-tree-test] [frontend-tests.util-simple-math-test] - [frontend-tests.worker-snap-test])) + [frontend-tests.util-webapi-test] + [frontend-tests.worker-snap-test] + [goog.object :as gobj])) (enable-console-print!) @@ -41,38 +51,191 @@ (.exit js/process 0) (.exit js/process 1))) +(def test-namespaces + '[frontend-tests.basic-shapes-test + frontend-tests.copy-as-svg-test + frontend-tests.data.nitrate-test + frontend-tests.data.repo-test + frontend-tests.errors-test + frontend-tests.main-errors-test + frontend-tests.data.uploads-test + frontend-tests.data.viewer-test + frontend-tests.data.workspace-colors-test + frontend-tests.data.workspace-media-test + frontend-tests.data.workspace-shortcuts-test + frontend-tests.data.workspace-texts-test + frontend-tests.data.workspace-thumbnails-test + frontend-tests.helpers-shapes-test + frontend-tests.logic.comp-remove-swap-slots-test + frontend-tests.logic.components-and-tokens + frontend-tests.logic.copying-and-duplicating-test + frontend-tests.logic.frame-guides-test + frontend-tests.logic.groups-test + frontend-tests.logic.pasting-in-containers-test + frontend-tests.plugins.context-shapes-test + frontend-tests.plugins.parser-test + frontend-tests.plugins.tokens-test + frontend-tests.plugins.utils-test + frontend-tests.svg-fills-test + frontend-tests.tokens.import-export-test + frontend-tests.tokens.logic.token-actions-test + frontend-tests.tokens.logic.token-data-test + frontend-tests.tokens.logic.token-remapping-test + frontend-tests.tokens.style-dictionary-test + frontend-tests.tokens.token-errors-test + frontend-tests.tokens.workspace-tokens-remap-test + frontend-tests.ui.ds-controls-numeric-input-test + frontend-tests.util-object-test + frontend-tests.util-range-tree-test + frontend-tests.util-simple-math-test + frontend-tests.util-webapi-test + frontend-tests.worker-snap-test]) + +(assert (every? find-ns-obj test-namespaces) + "test-namespaces contains a namespace that isn't required in runner.cljs") + +;; This runner intentionally mirrors common-tests.runner. Both runners need +;; forwarded CLI args, focused namespace/var execution, fixture preservation, +;; and app log-level setup. A shared helper could own those mechanics, but we +;; keep the logic local while there are only two test targets because sharing +;; it would add cross-module test classpath coupling. +(def ^:private log-levels + #{:trace :debug :info :warn :error}) + +(def cli-options + [["-f" "--focus FOCUS" "Run one test namespace or one test var, e.g. frontend-tests.logic.components-and-tokens/change-token-in-main"] + ["-l" "--log-level LEVEL" "Set app logger level: trace|debug|info|warn|error" + :parse-fn keyword + :validate [log-levels "must be one of trace, debug, info, warn, error"]] + ["-h" "--help"]]) + +(defn- argv + [] + (let [args (->> (.-argv js/process) + (array-seq) + (drop 2))] + ;; `pnpm run test -- --focus ...` forwards the separator to the node + ;; process, so drop one leading `--` before handing args to tools.cli. + (cond-> args + (= "--" (first args)) rest))) + +(defn- usage + [summary] + (str "Usage: pnpm run test -- [options]\n\n" + "Options:\n" + summary "\n\n" + "Focus examples:\n" + " pnpm run test -- --focus frontend-tests.logic.components-and-tokens\n" + " pnpm run test -- --focus frontend-tests.logic.components-and-tokens/change-token-in-main\n\n" + "Log level example (quiets app logging during the run):\n" + " pnpm run test -- --focus frontend-tests.logic.groups-test --log-level warn")) + +(defn- fail! + [message] + (js/console.error message) + (.exit js/process 1)) + +(defn- parse-focus + [focus] + (let [[ns-name test-name & extra] (str/split focus #"/")] + (cond + (or (str/blank? ns-name) (seq extra)) + (fail! (str "Invalid --focus value: " focus)) + + (some? test-name) + {:ns (symbol ns-name) :test test-name} + + :else + {:ns (symbol ns-name)}))) + +(defn- fixture-value + [ns-obj fixture-name] + (let [value (gobj/get ns-obj (munge fixture-name))] + (when-not (undefined? value) + value))) + +(defn- ns-test-vars + [ns-sym] + (when-let [ns-obj (find-ns-obj ns-sym)] + (->> (js-keys ns-obj) + (keep (fn [key] + (some-> (gobj/get ns-obj key) + (.-cljs$lang$var)))) + (filter (comp :test meta)) + (sort-by (comp :line meta))))) + +(defn- ns-fixtures + [ns-sym vars] + (when-let [ns-obj (find-ns-obj ns-sym)] + (let [ns-key (or (some-> vars first meta :ns) ns-sym) + once-fixtures (fixture-value ns-obj "cljs-test-once-fixtures") + each-fixtures (fixture-value ns-obj "cljs-test-each-fixtures")] + {:once (when once-fixtures {ns-key once-fixtures}) + :each (when each-fixtures {ns-key each-fixtures})}))) + +(defn- selected-tests + [{:keys [ns test]}] + (when-not (some #{ns} test-namespaces) + (fail! (str "Unknown test namespace: " ns))) + (let [vars (vec (ns-test-vars ns))] + (when (empty? vars) + (fail! (str "No tests found in namespace: " ns))) + (if test + (let [test-sym (symbol test) + test-var (some #(when (= test-sym (:name (meta %))) %) vars)] + (if test-var + {:vars [test-var] + :fixtures (ns-fixtures ns [test-var])} + (fail! (str "Unknown test var: " ns "/" test)))) + {:vars vars + :fixtures (ns-fixtures ns vars)}))) + +(defn- merge-fixtures + [fixtures] + {:once (apply merge (keep :once fixtures)) + :each (apply merge (keep :each fixtures))}) + +(defn- run-test-vars! + [tests] + (let [vars (vec (mapcat :vars tests)) + fixtures (merge-fixtures (map :fixtures tests)) + env (assoc (t/empty-env) + :once-fixtures (:once fixtures) + :each-fixtures (:each fixtures)) + summary (volatile! {:test 0 :pass 0 :fail 0 :error 0 :type :summary})] + (t/run-block + (concat [(fn [] (t/set-env! env))] + (t/test-vars-block vars) + [(fn [] + (vswap! summary + (partial merge-with +) + (:report-counters (t/get-and-clear-env!)))) + (fn [] + (t/set-env! env) + (t/do-report @summary) + (t/report (assoc @summary :type :end-run-tests)) + (t/clear-env!))])))) + +(defn- run-focused-test! + [focus] + (run-test-vars! [(selected-tests (parse-focus focus))])) + (defn init [] - (t/run-tests - 'frontend-tests.basic-shapes-test - 'frontend-tests.copy-as-svg-test - 'frontend-tests.data.repo-test - 'frontend-tests.errors-test - 'frontend-tests.main-errors-test - 'frontend-tests.data.uploads-test - 'frontend-tests.data.viewer-test - 'frontend-tests.data.workspace-colors-test - 'frontend-tests.data.workspace-media-test - 'frontend-tests.data.workspace-texts-test - 'frontend-tests.data.workspace-thumbnails-test - 'frontend-tests.helpers-shapes-test - 'frontend-tests.logic.comp-remove-swap-slots-test - 'frontend-tests.logic.components-and-tokens - 'frontend-tests.logic.copying-and-duplicating-test - 'frontend-tests.logic.frame-guides-test - 'frontend-tests.logic.groups-test - 'frontend-tests.logic.pasting-in-containers-test - 'frontend-tests.plugins.context-shapes-test - 'frontend-tests.svg-fills-test - 'frontend-tests.tokens.import-export-test - 'frontend-tests.tokens.logic.token-actions-test - 'frontend-tests.tokens.logic.token-data-test - 'frontend-tests.tokens.logic.token-remapping-test - 'frontend-tests.tokens.style-dictionary-test - 'frontend-tests.tokens.token-errors-test - 'frontend-tests.tokens.workspace-tokens-remap-test - 'frontend-tests.ui.ds-controls-numeric-input-test - 'frontend-tests.util-object-test - 'frontend-tests.util-range-tree-test - 'frontend-tests.util-simple-math-test - 'frontend-tests.worker-snap-test)) + (let [{:keys [options errors summary]} (parse-opts (argv) cli-options)] + (cond + (seq errors) + (fail! (str/join "\n" errors)) + + (:help options) + (do + (println (usage summary)) + (.exit js/process 0)) + + :else + (do + (when-let [level (:log-level options)] + (l/setup! {:app level})) + (if (:focus options) + (run-focused-test! (:focus options)) + (run-test-vars! (map #(selected-tests {:ns %}) test-namespaces))))))) diff --git a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs index 956a2977a0..281573631a 100644 --- a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs +++ b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs @@ -281,9 +281,7 @@ (t/is (= (:height (:applied-tokens rect-1')) (:name token-target')))) (t/testing "shapes width and height got updated" (t/is (= (:width rect-1') 100)) - (t/is (= (:height rect-1') 100))) - (t/testing "WASM mocks were exercised" - (t/is (pos? (thw/call-count :propagate-modifiers))))))))))) + (t/is (= (:height rect-1') 100)))))))))) (t/deftest test-apply-padding (t/testing "applies padding token to shapes with layout" @@ -356,9 +354,7 @@ (t/is (= (:height (:applied-tokens rect-1')) (:name token-target')))) (t/testing "shapes width and height got updated" (t/is (= (:width rect-1') 100)) - (t/is (= (:height rect-1') 100))) - (t/testing "WASM mocks were exercised" - (t/is (pos? (thw/call-count :propagate-modifiers))))))))))) + (t/is (= (:height rect-1') 100)))))))))) (t/deftest test-apply-opacity (t/testing "applies opacity token and updates the shapes opacity" @@ -443,9 +439,7 @@ rect-1' (cths/get-shape file' :rect-1)] (t/is (some? (:applied-tokens rect-1'))) (t/is (= (:rotation (:applied-tokens rect-1')) (:name token-target'))) - (t/is (= (:rotation rect-1') 120)) - (t/testing "WASM mocks were exercised" - (t/is (pos? (thw/call-count :propagate-modifiers))))))))))) + (t/is (= (:rotation rect-1') 120))))))))) (t/deftest test-apply-stroke-width (t/testing "applies stroke-width token and updates the shapes with stroke" diff --git a/frontend/test/frontend_tests/util_webapi_test.cljs b/frontend/test/frontend_tests/util_webapi_test.cljs new file mode 100644 index 0000000000..1307526ffb --- /dev/null +++ b/frontend/test/frontend_tests/util_webapi_test.cljs @@ -0,0 +1,34 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns frontend-tests.util-webapi-test + (:require + [app.util.webapi :as wapi] + [cljs.test :as t :include-macros true])) + +(t/deftest data-uri->blob-supports-base64 + (t/async done + (let [blob (wapi/data-uri->blob "data:text/plain;base64,SGVsbG8=")] + (-> (.text blob) + (.then (fn [text] + (t/is (= "text/plain" (.-type blob))) + (t/is (= "Hello" text)) + (done))) + (.catch (fn [err] + (t/is false (str "unexpected error: " err)) + (done))))))) + +(t/deftest data-uri->blob-supports-utf8-data + (t/async done + (let [blob (wapi/data-uri->blob "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3C%2Fsvg%3E")] + (-> (.text blob) + (.then (fn [text] + (t/is (= "image/svg+xml" (.-type blob))) + (t/is (= "" text)) + (done))) + (.catch (fn [err] + (t/is false (str "unexpected error: " err)) + (done))))))) diff --git a/frontend/translations/de.po b/frontend/translations/de.po index 9d08659eef..e74244a898 100644 --- a/frontend/translations/de.po +++ b/frontend/translations/de.po @@ -1,15 +1,15 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-02-16 08:35+0000\n" -"Last-Translator: Anonymous \n" -"Language-Team: German " -"\n" +"PO-Revision-Date: 2026-04-23 10:09+0000\n" +"Last-Translator: Stas Haas \n" +"Language-Team: German \n" "Language: de\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 5.16-dev\n" +"X-Generator: Weblate 5.17.1-dev\n" #: src/app/main/ui/auth/register.cljs:215, src/app/main/ui/static.cljs:158, src/app/main/ui/viewer/login.cljs:100 msgid "auth.already-have-account" @@ -1408,11 +1408,11 @@ msgstr "" #: src/app/main/errors.cljs:305 msgid "errors.deprecated.contact.after" -msgstr "damit wir Ihnen helfen können." +msgstr ", damit wir Ihnen helfen können." #: src/app/main/errors.cljs:304 msgid "errors.deprecated.contact.text" -msgstr "kontaktieren Sie uns" +msgstr "kontaktieren" #: src/app/main/data/workspace/tokens/library_edit.cljs:338 msgid "errors.drop-token-set-parent-to-child" @@ -4822,11 +4822,11 @@ msgid "subscription.settings.subscribe" msgstr "Abonnieren" #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "Viel Spaß mit Ihrem Abonnement!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "Sie sind %s!" #: src/app/main/ui/settings/subscription.cljs:558, src/app/main/ui/settings/subscription.cljs:574 @@ -6704,6 +6704,7 @@ msgid "workspace.options.shadow-options.remove-shadow" msgstr "Schatten entfernen" #: src/app/main/ui/inspect/attributes/shadow.cljs:48, src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:191, src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:193 +#, fuzzy msgid "workspace.options.shadow-options.spread" msgstr "Streuung" @@ -7191,7 +7192,7 @@ msgstr "Anmerkung erstellen" #: src/app/main/ui/workspace/context_menu.cljs:382 msgid "workspace.shape.menu.create-artboard-from-selection" -msgstr "Auswahl auf Zeichenfläche" +msgstr "Auswahl als Zeichenfläche" #: src/app/main/ui/workspace/context_menu.cljs:592 msgid "workspace.shape.menu.create-component" @@ -7530,7 +7531,7 @@ msgid "workspace.tokens.edit-token" msgstr "%s Token bearbeiten" #: src/app/main/data/workspace/tokens/errors.cljs:41 -msgid "workspace.tokens.empty-input" +msgid "errors.tokens.empty-input" msgstr "Der Token-Wert darf nicht leer sein" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 @@ -7538,7 +7539,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "%s Token-Name eingeben" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "Fehler beim Importieren. JSON konnte nicht verarbeitet werden." #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -7602,7 +7603,7 @@ msgid "workspace.tokens.import-button-prefix" msgstr "%s importieren" #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "Fehler beim Import:" #: src/app/main/ui/workspace/tokens/import/modal.cljs:273 @@ -7659,57 +7660,57 @@ msgid "workspace.tokens.individual-tokens" msgstr "Einzelne Token verwenden" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "Ungültiger Farbwert: %s" #: src/app/main/data/workspace/tokens/errors.cljs:89 -msgid "workspace.tokens.invalid-font-weight-token-value" +msgid "errors.tokens.invalid-font-weight-token-value" msgstr "" "Ungültiger Wert für die Schriftstärke: Verwenden Sie numerische Werte " "(100–950) oder Standardbezeichnungen (dünn, leicht, normal, fett usw.), " "optional gefolgt von „kursiv”" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "Fehler beim Importieren: Ihre JSON-Datei enthält ungültige Token-Daten." #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "Fehler beim Importieren: Ihre JSON-Datei enthält ungültige Token-Namen." #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "„%s“ ist kein gültiger Namen für ein Token.\n" "Token-Namen dürfen nur Buchstaben und Ziffern enthalten, die durch Punkte " "getrennt sind und dürfen nicht mit einem Dollarzeichen beginnen." #: src/app/main/data/workspace/tokens/errors.cljs:105 -msgid "workspace.tokens.invalid-shadow-type-token-value" +msgid "errors.tokens.invalid-shadow-type-token-value" msgstr "" "Ungültiger Schattentyp: Es werden nur „innerShadow” oder „dropShadow” " "akzeptiert" #: src/app/main/data/workspace/tokens/errors.cljs:81 -msgid "workspace.tokens.invalid-text-case-token-value" +msgid "errors.tokens.invalid-text-case-token-value" msgstr "" "Ungültiger Token-Wert: Es werden nur „none“, „Uppercase“, „Lowercase“ oder " "„Capitalize“ akzeptiert" #: src/app/main/data/workspace/tokens/errors.cljs:85 -msgid "workspace.tokens.invalid-text-decoration-token-value" +msgid "errors.tokens.invalid-text-decoration-token-value" msgstr "" "Ungültiger Token-Wert: Es werden nur „none“, „underline“ oder " "„strike-through“ akzeptiert" #: src/app/main/data/workspace/tokens/errors.cljs:97 -msgid "workspace.tokens.invalid-token-value-typography" +msgid "errors.tokens.invalid-token-value-typography" msgstr "" "Ungültiger Wert: Der Wert muss auf ein zusammengesetztes Typografie-Token " "verweisen." #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "Ungültiger Token-Wert: %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -7749,7 +7750,7 @@ msgid "workspace.tokens.min-size" msgstr "Mindestgröße" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "Fehlende Token-Referenzen: " #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 @@ -7789,7 +7790,7 @@ msgid "workspace.tokens.no-themes-currently" msgstr "Sie haben derzeit keine Themes." #: src/app/main/data/workspace/tokens/errors.cljs:19 -msgid "workspace.tokens.no-token-files-found" +msgid "errors.tokens.no-token-files-found" msgstr "In dieser Datei wurden keine Tokens, Sets oder Themen gefunden." #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:134 @@ -7797,11 +7798,11 @@ msgid "workspace.tokens.num-active-sets" msgstr "%s aktivierte Sets" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "Ungültiger Tokenwert. Der ermittelte Wert ist zu hoch: %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "" "Die Deckkraft muss zwischen 0 und 100 % oder zwischen 0 und 1 liegen (z. B. " "50 % oder 0,5)." @@ -7846,7 +7847,7 @@ msgid "workspace.tokens.select-set" msgstr "Set auswählen." #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "Der Token referenziert sich selbst" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 @@ -7887,7 +7888,7 @@ msgid "workspace.tokens.shadow-blur" msgstr "Weichzeichnen" #: src/app/main/data/workspace/tokens/errors.cljs:109 -msgid "workspace.tokens.shadow-blur-range" +msgid "errors.tokens.shadow-blur-range" msgstr "Wert muss größer oder gleich 0 sein." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:987, src/app/main/ui/workspace/tokens/management/create/form.cljs:988 @@ -7925,7 +7926,7 @@ msgid "workspace.tokens.size" msgstr "Größe" #: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 -msgid "workspace.tokens.stroke-width-range" +msgid "errors.tokens.stroke-width-range" msgstr "Die Rahmenbreite muss größer oder gleich 0 sein." #: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:40, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:161 @@ -8036,11 +8037,11 @@ msgid "workspace.tokens.value-not-valid" msgstr "Der Wert ist nicht gültig" #: src/app/main/data/workspace/tokens/errors.cljs:69 -msgid "workspace.tokens.value-with-percent" +msgid "errors.tokens.value-with-percent" msgstr "Ungültiger Wert: % ist nicht zulässig." #: src/app/main/data/workspace/tokens/errors.cljs:65 -msgid "workspace.tokens.value-with-units" +msgid "errors.tokens.value-with-units" msgstr "Ungültiger Wert: Einheiten sind hier nicht zulässig." #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 @@ -8372,3 +8373,135 @@ msgstr "Automatisch gespeicherte Versionen werden für %s Tage aufbewahrt." #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Klicken Sie, um den Pfad zu schließen" + +#: src/app/main/ui/dashboard/grid.cljs:248 +msgid "dashboard.deleted.will-be-deleted-at" +msgstr "Wird gelöscht am %s" + +#: src/app/main/errors.cljs:105 +msgid "errors.unexpected-exception" +msgstr "Unerwarteter Fehler: %s" + +#: src/app/main/ui/static.cljs:315 +msgid "errors.webgl-context-lost.desc-message" +msgstr "" +"WebGL funktioniert nicht mehr. Bitte laden Sie die Seite neu, um sie " +"zurückzusetzen" + +#: src/app/main/ui/static.cljs:405 +msgid "labels.internal-error.desc-message-first" +msgstr "Etwas Schlimmes ist passiert." + +#: src/app/main/ui/static.cljs:318 +msgid "labels.reload-page" +msgstr "Seite neu laden" + +#: src/app/main/ui/dashboard/sidebar.cljs:347 +msgid "dashboard.create-new-org" +msgstr "Neue Organisation erstellen" + +#: src/app/main/ui/dashboard/deleted.cljs:52 +msgid "dashboard.delete-project-forever-confirmation.description" +msgstr "" +"Wollen Sie wirklich das Projekt %s löschen? Diese Aktion kann nicht " +"rückgängig gemacht werden." + +#: src/app/main/ui/workspace/sidebar/debug.cljs:38 +msgid "workspace.debug.title" +msgstr "In Arbeitsbereich bearbeiten" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:422 +msgid "workspace.layout-grid.editor.margin.expand" +msgstr "Optionen für Abstände anzeigen" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:449 +msgid "workspace.layout-item.fit-content-horizontal" +msgstr "Inhalt horizontal anpassen" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:477 +msgid "workspace.layout-item.fit-content-vertical" +msgstr "Inhalt vertikal anpassen" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:471 +msgid "workspace.layout-item.height-100" +msgstr "Gesamte Höhe" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:444 +msgid "workspace.layout-item.width-100" +msgstr "Gesamte Breite" + +#: src/app/main/ui/workspace/libraries.cljs:338 +msgid "workspace.libraries.connected-to" +msgstr "Verbunden mit" + +#: src/app/main/ui/workspace/top_toolbar.cljs:231 +msgid "workspace.toolbar.debug" +msgstr "Werkzeuge zur Fehlersuche" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:287 +#, fuzzy, unused +msgid "workspace.tokens.shadow-token-spread-value-error" +msgstr "Der Wert für die Streuung darf nicht negativ sein" + +#: src/app/main/errors.cljs:303 +msgid "errors.deprecated.contact.before" +msgstr "Penpot unterstützt diese Assets nicht mehr. Sie können uns allerdings" + +#: src/app/main/ui/static.cljs:314 +msgid "errors.webgl-context-lost.main-message" +msgstr "Ups! Der Canvas-Kontext ist verloren gegangen" + +#: src/app/main/ui/ds/product/loader.cljs:25 +msgid "loader.tips.03.message" +msgstr "" +"Gestalten Sie flexibel mit vertrauten, CSS-ähnlichen Layout-Steuerelementen." + +#: src/app/main/ui/workspace/tokens/modals/import.cljs:132 +#, unused +msgid "workspace.tokens.choose-folder" +msgstr "Ordner auswählen" + +#: src/app/main/ui/workspace/tokens/modals/import.cljs:127 +#, unused +msgid "workspace.tokens.choose-file" +msgstr "Datei auswählen" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:498 +msgid "workspace.shape.menu.restore-variant" +msgstr "Variante wiederherstellen" + +#: src/app/main/ui/workspace/context_menu.cljs:619, src/app/main/ui/workspace/sidebar/assets/components.cljs:633, src/app/main/ui/workspace/sidebar/assets/groups.cljs:75, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1106 +msgid "workspace.shape.menu.combine-as-variants" +msgstr "Als Varianten kombinieren" + +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:635 +msgid "workspace.shape.menu.combine-as-variants-error" +msgstr "Die Komponenten müssen sich auf derselben Seite befinden" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1157 +msgid "workspace.shape.menu.remove-variant-property" +msgstr "Eigenschaft entfernen" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:512, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1073, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1221, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1307 +msgid "workspace.shape.menu.add-variant-property" +msgstr "Neue Eigenschaft hinzufügen" + +#: src/app/main/ui/workspace/plugins.cljs:287 +msgid "workspace.plugins.permissions.allow-localstorage" +msgstr "Daten im Browser speichern." + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:469 +msgid "workspace.options.size.unlock" +msgstr "Seitenverhältnis entsperren" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:469 +msgid "workspace.options.size.lock" +msgstr "Seitenverhältnis sperren" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:608 +msgid "workspace.options.interaction-animation-direction-right" +msgstr "Rechts" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:612 +msgid "workspace.options.interaction-animation-direction-left" +msgstr "Links" diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 54a33820ec..201f641b3b 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -169,7 +169,7 @@ msgstr "Create an account" #: src/app/main/ui/auth.cljs #, unused msgid "auth.sidebar-tagline" -msgstr "The open-source solution for design and prototyping." +msgstr "Penpot is the open-source design platform for teams that build digital products at scale." #: src/app/main/ui/auth/register.cljs:51 #, markdown @@ -380,8 +380,11 @@ msgstr "+ Create org" msgid "dashboard.create-new-org" msgstr "Create org" -msgid "dashboard.go-to-control-center" -msgstr "Go to Control Center" +msgid "dashboard.my-teams" +msgstr "My teams" + +msgid "dashboard.go-to-admin-console" +msgstr "Go to Admin Console" #: src/app/main/ui/dashboard/sidebar.cljs:340 msgid "dashboard.create-new-team" @@ -1157,7 +1160,10 @@ msgid "dashboard.select-org-modal.accept" msgstr "ADD TO ORGANIZATION" msgid "dashboard.change-org-modal.title" -msgstr "CHANGE TEAM'S ORGANIZATION" +msgstr "Change team's organization" + +msgid "dashboard.change-org-modal.text" +msgstr "Projects and files will remain available to team members. The team will get the configuration from the new organization." msgid "dashboard.change-org-modal.choose" msgstr "Move to:" @@ -1216,6 +1222,27 @@ msgstr "Type to search results" msgid "dashboard.unpublish-shared" msgstr "Unpublish Library" +msgid "dashboard.webgl-switch.title" +msgstr "WebGL rendering" + +msgid "dashboard.webgl-switch.beta" +msgstr "Beta" + +msgid "dashboard.webgl-switch.description" +msgstr "WebGL rendering can improve performance, but it is in beta and may be less stable. Some visual differences may appear between the canvas, exports (SVG/PDF), and view mode." + +msgid "dashboard.webgl-switch.status" +msgstr "Status" + +msgid "dashboard.webgl-switch.enabled" +msgstr "Enabled" + +msgid "dashboard.webgl-switch.disabled" +msgstr "Disabled" + +msgid "dashboard.webgl-switch.feedback" +msgstr "Give feedback" + #:src/app/main/ui/workspace/tokens/import_from_library.cljs msgid "modals.import-library-tokens.title" msgstr "Import tokens from library?" @@ -1389,6 +1416,10 @@ msgstr "Your browser cannot do this operation" msgid "errors.clipboard-permission-denied" msgstr "Clipboard access denied. Please allow clipboard permissions in your browser to paste content" +#: src/app/main/data/workspace/clipboard.cljs +msgid "errors.clipboard-api-unavailable" +msgstr "Clipboard API is unavailable. Serve Penpot over HTTPS to enable clipboard access" + #: src/app/main/errors.cljs:235 msgid "errors.comment-error" msgstr "There was an error with the comment" @@ -1516,6 +1547,14 @@ msgstr "The recovery token is invalid." msgid "errors.invalid-text" msgstr "Invalid text" +#: common/src/app/common/types/team.cljc:26 +msgid "errors.team-name-invalid-chars" +msgstr "The team name can't contain any of the following characters:'.', ':' or '/'" + +#: common/src/app/common/types/font.cljc +msgid "errors.font-family-invalid-chars" +msgstr "The font family name can only contain letters, numbers, spaces, hyphens, underscores, and dots." + #: src/app/main/ui/static.cljs:74 msgid "errors.invite-invalid" msgstr "Invite invalid" @@ -1524,6 +1563,14 @@ msgstr "Invite invalid" msgid "errors.invite-invalid.info" msgstr "This invite might be canceled or may be expired." +#: src/app/main/ui/static.cljs +msgid "errors.invite-expired" +msgstr "This invitation has expired. Ask the team owner to send you a new one." + +#: src/app/main/ui/static.cljs +msgid "errors.invite-email-mismatch" +msgstr "This invitation is for a different email. Log out and sign in with the invited account, or ask the team owner for a new invitation." + #: src/app/main/ui/auth/login.cljs:89 msgid "errors.ldap-disabled" msgstr "LDAP authentication is disabled." @@ -1643,6 +1690,10 @@ msgstr "Cannot complete drop, a set with same name already exists at path." msgid "errors.token-theme-already-exists" msgstr "Theme Option with the same name exists" +#: src/app/common/files/tokens.cljc:298 +msgid "errors.token-theme-not-existing-sets" +msgstr "The theme refers to some not existing sets: %s" + #: src/app/main/data/media.cljs:73 msgid "errors.unexpected-error" msgstr "An unexpected error occurred." @@ -1679,6 +1730,8 @@ msgstr "WebGL has stopped working. Please reload the page to reset it" msgid "errors.webgl-context-lost.main-message" msgstr "Oops! The canvas context was lost" + + #: src/app/main/ui/dashboard/team.cljs:1051 msgid "errors.webhooks.connection" msgstr "Connection error, URL not reacheable" @@ -1965,6 +2018,10 @@ msgstr "Typography" msgid "inspect.attributes.typography.font-family" msgstr "Font Family" +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:308 +msgid "inspect.attributes.typography.mixed-font-family" +msgstr "Mixed Font Families" + #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:326, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:332 msgid "inspect.attributes.typography.font-size" msgstr "Font Size" @@ -2856,6 +2913,12 @@ msgstr "New password" msgid "labels.next" msgstr "Next" +#: src/app/main/ui/static.cljs:325 +msgid "labels.nitrate-unavailable.main-message" +msgstr "" +"Penpot is temporarily unavailable due to a system issue. Please try again " +"shortly." + #: src/app/main/ui/dashboard/comments.cljs:122, src/app/main/ui/workspace/comments.cljs:162 msgid "labels.no-comments-available" msgstr "You're all caught up! New comment notifications will appear here." @@ -2868,6 +2931,10 @@ msgstr "No pending invitations." msgid "labels.no-invitations-gather-people" msgstr "Gather your people and build great things together." +#: src/app/main/ui/components/select.cljs +msgid "labels.no-matches" +msgstr "No matches" + #: src/app/main/ui/static.cljs #, unused msgid "labels.not-found.desc-message" @@ -2912,6 +2979,10 @@ msgstr "Old password" msgid "labels.only-yours" msgstr "Only yours" +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:698 +msgid "labels.open" +msgstr "Open" + #: src/app/main/ui/comments.cljs:911, src/app/main/ui/comments.cljs:976, src/app/main/ui/workspace/palette.cljs:199, src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:107, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:906, src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:155, src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:213, src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:294, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:402, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1031, src/app/main/ui/workspace/sidebar/options/menus/text.cljs:316, src/app/main/ui/workspace/sidebar/options/menus/text.cljs:345, src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:146 msgid "labels.options" msgstr "Options" @@ -3049,6 +3120,9 @@ msgstr "Resend invitation" msgid "labels.restore" msgstr "Restore" +msgid "labels.exit" +msgstr "Exit" + #: src/app/main/ui/components/progress.cljs:80, src/app/main/ui/static.cljs:299, src/app/main/ui/static.cljs:308, src/app/main/ui/static.cljs:419 msgid "labels.retry" msgstr "Retry" @@ -3463,6 +3537,18 @@ msgstr "By removing your account you’ll lose all your current projects and arc msgid "modals.delete-account.title" msgstr "Are you sure you want to delete your account?" +#: src/app/main/ui/settings/delete_account.cljs +msgid "modals.delete-account.owned-orgs.list-title" +msgstr "Organizations that will be deleted" + +#: src/app/main/ui/settings/delete_account.cljs +msgid "modals.delete-account.owned-orgs.teams-count" +msgstr "%s teams" + +#: src/app/main/ui/settings/delete_account.cljs +msgid "modals.delete-account.owned-orgs.members-count" +msgstr "%s members" + #: src/app/main/ui/comments.cljs:889 msgid "modals.delete-comment-thread.accept" msgstr "Delete conversation" @@ -3537,6 +3623,16 @@ msgstr "Are you sure you want to delete this page?" msgid "modals.delete-page.title" msgstr "Delete page" +#: src/app/main/ui/workspace/sidebar/assets/components.cljs, src/app/main/ui/workspace/sidebar/assets/colors.cljs, src/app/main/ui/workspace/sidebar/assets/typographies.cljs +msgid "modals.delete-asset-group.title" +msgstr "Delete group" + +#: src/app/main/ui/workspace/sidebar/assets/components.cljs, src/app/main/ui/workspace/sidebar/assets/colors.cljs, src/app/main/ui/workspace/sidebar/assets/typographies.cljs +msgid "modals.delete-asset-group.message" +msgid_plural "modals.delete-asset-group.message" +msgstr[0] "Are you sure you want to delete this asset?" +msgstr[1] "Are you sure you want to delete these %s assets?" + #: src/app/main/ui/dashboard/project_menu.cljs:73 msgid "modals.delete-project-confirm.accept" msgstr "Delete project" @@ -5113,6 +5209,24 @@ msgstr "Inviting people while on the Unlimited plan" msgid "subscription.dashboard.upgrade-plan.power-up" msgstr "Power up" +msgid "subscription.dashboard.banner.unlock-features" +msgstr "Unlock Enterprise features" + +msgid "subscription.dashboard.banner.unlock-features-description" +msgstr "Set fine-grained permissions and keep your design operations secure, without compromising the open-source freedom your team already loves." + +msgid "subscription.dashboard.banner.upgrade-nitrate" +msgstr "Get started with Enterprise" + +msgid "subscription.error.nitrate.checkout-cancelled" +msgstr "Payment was not completed. Try again whenever you're ready." + +msgid "subscription.error.nitrate.checkout-failed" +msgstr "We couldn't start the checkout. Please try again. If the problem persists, contact us: support@penpot.app." + +msgid "subscription.error.nitrate.checkout-finish-failed" +msgstr "We couldn’t confirm your subscription. Please check your subscription status on the Subscription page. You may try again if needed." + #: src/app/main/ui/settings/sidebar.cljs:116, src/app/main/ui/settings/subscription.cljs:425, src/app/main/ui/settings/subscription.cljs:462 msgid "subscription.labels" msgstr "Subscription" @@ -5242,6 +5356,9 @@ msgstr "editor per month" msgid "subscription.settings.price-organization-month" msgstr "organization per month" +msgid "subscription.settings.organization-member-month" +msgstr "org member per month" + #: src/app/main/ui/dashboard/subscription.cljs:140, src/app/main/ui/settings/subscription.cljs:102, src/app/main/ui/settings/subscription.cljs:469, src/app/main/ui/settings/subscription.cljs:538 msgid "subscription.settings.professional" msgstr "Professional" @@ -5258,6 +5375,15 @@ msgstr "10GB of storage" msgid "subscription.settings.professional.teams-editors-benefit" msgstr "Unlimited teams. Up to 8 editors across your owned teams." +msgid "subscription.settings.professional.selfhost.control-over-data" +msgstr "Complete control over data and infrastructure." + +msgid "subscription.settings.professional.selfhost.unlimited-users" +msgstr "Unlimited users, teams and files." + +msgid "subscription.settings.professional.selfhost.community-support" +msgstr "All design and prototyping features Community support." + #: src/app/main/ui/settings/subscription.cljs:50 msgid "subscription.settings.recommended" msgstr "Recommended" @@ -5285,11 +5411,11 @@ msgid "subscription.settings.success.dialog.thanks" msgstr "Thank your for chosing the Penpot %s plan!" #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "Enjoy your plan!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "You are %s!" #: src/app/main/ui/settings/subscription.cljs:526 @@ -5374,7 +5500,7 @@ msgstr "Shared Libraries - %s - Penpot" #: src/app/main/ui/auth/verify_token.cljs:70, src/app/main/ui/auth.cljs:34 msgid "title.default" -msgstr "Penpot - Design Freedom for Teams" +msgstr "Penpot | Full-stack design" #: src/app/main/ui/settings/feedback.cljs:161 msgid "title.settings.feedback" @@ -5709,6 +5835,10 @@ msgstr "Text Transform" msgid "workspace.assets.ungroup" msgstr "Ungroup" +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs +msgid "workspace.assets.delete-group" +msgstr "Delete group" + #: src/app/main/ui/workspace/colorpicker.cljs:428, src/app/main/ui/workspace/colorpicker.cljs:441 msgid "workspace.colorpicker.color-tokens" msgstr "Color tokens" @@ -5992,6 +6122,12 @@ msgstr "Switch to light theme" msgid "workspace.header.menu.toggle-system-theme" msgstr "Switch to system theme" +msgid "workspace.header.menu.enable-webgl" +msgstr "Enable WebGL rendering (beta)" + +msgid "workspace.header.menu.disable-webgl" +msgstr "Disable WebGL rendering (beta)" + #: src/app/main/ui/workspace/main_menu.cljs:492 msgid "workspace.header.menu.undo" msgstr "Undo" @@ -6228,10 +6364,34 @@ msgstr "Connect library" msgid "workspace.libraries.text.multiple-typography" msgstr "Multiple typographies" +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:332 +msgid "workspace.libraries.text.mixed-typography" +msgstr "Mixed assets" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:332 +msgid "workspace.libraries.text.mixed-tokens" +msgstr "Mixed tokens" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:332 +msgid "workspace.libraries.text.mixed-tokens-and-assets" +msgstr "Mixed assets and tokens" + #: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:335 msgid "workspace.libraries.text.multiple-typography-tooltip" msgstr "Unlink all typographies" +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:335 +msgid "workspace.libraries.text.multiple-assets-tooltip" +msgstr "Unlink all assets" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:335 +msgid "workspace.libraries.text.multiple-token-tooltip" +msgstr "Detach all tokens" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:335 +msgid "workspace.libraries.text.multiple-token-and-asset-tooltip" +msgstr "Detach all tokens & unlink all typographies" + #: src/app/main/ui/workspace/libraries.cljs:103, src/app/main/ui/workspace/libraries.cljs:130 msgid "workspace.libraries.typography" msgid_plural "workspace.libraries.typography" @@ -6298,6 +6458,10 @@ msgstr "Toggle blur" msgid "workspace.options.canvas-background" msgstr "Canvas background" +#: src/app/main/ui/workspace/sidebar/options/page.cljs +msgid "workspace.options.pixel grid-color" +msgstr "Pixel grid color" + #: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:567 msgid "workspace.options.clip-content" msgstr "Clip content" @@ -7307,6 +7471,14 @@ msgstr "Size" msgid "workspace.options.size-presets" msgstr "Size presets" +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs +msgid "workspace.options.search-size-preset" +msgstr "Search size preset" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs +msgid "workspace.options.no-size-preset-results" +msgstr "No matching size preset" + #: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:469 msgid "workspace.options.size.lock" msgstr "Lock ratio" @@ -7397,6 +7569,14 @@ msgstr "Center" msgid "workspace.options.stroke.dashed" msgstr "Dashed" +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs +msgid "workspace.options.stroke-dash" +msgstr "Dash" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs +msgid "workspace.options.stroke-gap" +msgstr "Gap" + #: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:138 msgid "workspace.options.stroke.dotted" msgstr "Dotted" @@ -7599,14 +7779,14 @@ msgid "workspace.plugins.empty-plugins" msgstr "No plugins installed yet" #: src/app/main/ui/workspace/plugins.cljs:193 - msgid "workspace.plugins.error.manifest" - msgstr "The plugin manifest is incorrect." +msgid "workspace.plugins.error.manifest" +msgstr "The plugin manifest is incorrect." msgid "plugins.validation.message" msgstr "Field %s is invalid: %s" #: src/app/main/data/plugins.cljs:105, src/app/main/ui/workspace/main_menu.cljs:766, src/app/main/ui/workspace/plugins.cljs:84 - msgid "workspace.plugins.error.need-editor" +msgid "workspace.plugins.error.need-editor" msgstr "You need to be an editor to use this plugin" #: src/app/main/ui/workspace/plugins.cljs:189 @@ -7643,6 +7823,14 @@ msgstr "" msgid "workspace.plugins.permissions.allow-download" msgstr "Start file downloads." +#: src/app/main/ui/workspace/plugins.cljs +msgid "workspace.plugins.permissions.clipboard-read" +msgstr "Read the contents of your clipboard." + +#: src/app/main/ui/workspace/plugins.cljs +msgid "workspace.plugins.permissions.clipboard-write" +msgstr "Read and write to your clipboard." + #: src/app/main/ui/workspace/plugins.cljs:287 msgid "workspace.plugins.permissions.allow-localstorage" msgstr "Store data in the browser." @@ -8170,7 +8358,7 @@ msgid "workspace.tokens.color" msgstr "Color" #: src/app/main/data/workspace/tokens/errors.cljs:101, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:251 -msgid "workspace.tokens.composite-line-height-needs-font-size" +msgid "errors.tokens.composite-line-height-needs-font-size" msgstr "Line Height depends on Font Size. Add a Font Size to get the resolved value." #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:57 @@ -8222,7 +8410,7 @@ msgid "workspace.tokens.edit-token" msgstr "Edit %s token" #: src/app/main/data/workspace/tokens/errors.cljs:41 -msgid "workspace.tokens.empty-input" +msgid "errors.tokens.empty-input" msgstr "Token value cannot be empty" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 @@ -8230,7 +8418,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "Enter %s token name" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "Import Error: Could not parse JSON." #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -8294,7 +8482,7 @@ msgid "workspace.tokens.import-button-prefix" msgstr "Import %s" #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "Import Error:" #: src/app/main/ui/workspace/tokens/import/modal.cljs:273 @@ -8345,58 +8533,66 @@ msgid "workspace.tokens.individual-tokens" msgstr "Use individual tokens" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "Invalid color value: %s" #: src/app/main/data/workspace/tokens/errors.cljs:93 -msgid "workspace.tokens.invalid-font-family-token-value" +msgid "errors.tokens.invalid-font-family-token-value" msgstr "Invalid token value: you can only reference a font-family token" #: src/app/main/data/workspace/tokens/errors.cljs:89 -msgid "workspace.tokens.invalid-font-weight-token-value" +msgid "errors.tokens.invalid-font-weight-token-value" msgstr "" "Invalid font weight value: use numeric values (100-950) or standard names " "(thin, light, regular, bold, etc.) optionally followed by 'Italic'" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "Import Error: Invalid token data in JSON." #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "Import Error: Invalid token name in JSON." +#: src/app/main/data/workspace/tokens/errors.cljs:32 +msgid "workspace.tokens.duplicated-json-token-name" +msgstr "Import Error: Duplicated token name in JSON." + #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "\"%s\" is not a valid token name.\n" "Token names should only contain letters and digits separated by . " "characters and must not start with a $ sign." +#: src/app/main/data/workspace/tokens/errors.cljs:33 +msgid "workspace.tokens.duplicated-json-token-name-detail" +msgstr "A token already exists at the path '%s' or at a prefix thereof, in another set." + #: src/app/main/data/workspace/tokens/errors.cljs:105 -msgid "workspace.tokens.invalid-shadow-type-token-value" +msgid "errors.tokens.invalid-shadow-type-token-value" msgstr "Invalid shadow type: only 'innerShadow' or 'dropShadow' are accepted" #: src/app/main/data/workspace/tokens/errors.cljs:81 -msgid "workspace.tokens.invalid-text-case-token-value" +msgid "errors.tokens.invalid-text-case-token-value" msgstr "" "Invalid token value: only none, Uppercase, Lowercase or Capitalize are " "accepted" #: src/app/main/data/workspace/tokens/errors.cljs:85 -msgid "workspace.tokens.invalid-text-decoration-token-value" +msgid "errors.tokens.invalid-text-decoration-token-value" msgstr "Invalid token value: only none, underline and strike-through are accepted" #: src/app/main/data/workspace/tokens/errors.cljs:117 -msgid "workspace.tokens.invalid-token-value-shadow" +msgid "errors.tokens.invalid-token-value-shadow" msgstr "Invalid value: must reference a composite shadow token." #: src/app/main/data/workspace/tokens/errors.cljs:97 -msgid "workspace.tokens.invalid-token-value-typography" +msgid "errors.tokens.invalid-token-value-typography" msgstr "Invalid value: must reference a composite typography token." #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "Invalid token value: %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -8440,7 +8636,7 @@ msgid "workspace.tokens.missing-reference" msgstr "Missing reference" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "Missing token references: " #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 @@ -8490,7 +8686,7 @@ msgid "workspace.tokens.no-themes-currently" msgstr "You currently have no themes." #: src/app/main/data/workspace/tokens/errors.cljs:19 -msgid "workspace.tokens.no-token-files-found" +msgid "errors.tokens.no-token-files-found" msgstr "No tokens, sets, or themes were found in this file." #: src/app/main/ui/workspace/tokens/remapping_modal.cljs:95 @@ -8502,11 +8698,11 @@ msgid "workspace.tokens.num-active-sets" msgstr "%s active sets" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "Invalid token value. The resolved value is too large: %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "Opacity must be between 0 and 100% or 0 and 1 (e.g. 50% or 0.5)." #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:121 @@ -8582,7 +8778,7 @@ msgid "workspace.tokens.select-set" msgstr "Select set." #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "Token has self reference" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 @@ -8619,7 +8815,7 @@ msgid "workspace.tokens.shadow-blur" msgstr "Blur" #: src/app/main/data/workspace/tokens/errors.cljs:109 -msgid "workspace.tokens.shadow-blur-range" +msgid "errors.tokens.shadow-blur-range" msgstr "Shadow blur must be greater than or equal to 0." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:987, src/app/main/ui/workspace/tokens/management/create/form.cljs:988 @@ -8640,7 +8836,7 @@ msgid "workspace.tokens.shadow-spread" msgstr "Spread" #: src/app/main/data/workspace/tokens/errors.cljs:113 -msgid "workspace.tokens.shadow-spread-range" +msgid "errors.tokens.shadow-spread-range" msgstr "Shadow spread must be greater than or equal to 0." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:1215 @@ -8670,7 +8866,7 @@ msgid "workspace.tokens.size" msgstr "Size" #: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 -msgid "workspace.tokens.stroke-width-range" +msgid "errors.tokens.stroke-width-range" msgstr "Stroke width must be greater than or equal to 0." #: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:40, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:161 @@ -8785,11 +8981,11 @@ msgid "workspace.tokens.value-not-valid" msgstr "The value is not valid" #: src/app/main/data/workspace/tokens/errors.cljs:69 -msgid "workspace.tokens.value-with-percent" +msgid "errors.tokens.value-with-percent" msgstr "Invalid value: % is not allowed." #: src/app/main/data/workspace/tokens/errors.cljs:65 -msgid "workspace.tokens.value-with-units" +msgid "errors.tokens.value-with-units" msgstr "Invalid value: Units are not allowed." #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs @@ -8813,6 +9009,10 @@ msgstr "Duplicate Tokens Group" msgid "workspace.tokens.rename-group-name-hint" msgstr "Your tokens will automatically be renamed to %s.(suffix).(tokenName)" +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs, src/app/main/ui/workspace/colorpicker/color_tokens.cljs +msgid "workspace.tokens.search-by-token" +msgstr "Search by token name" + #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 msgid "workspace.toolbar.assets" msgstr "Assets" @@ -8891,10 +9091,20 @@ msgstr "Done" msgid "workspace.top-bar.view-only" msgstr "**Inspecting code** (View Only)" +msgid "workspace.top-bar.webgl-context-lost" +msgstr "Rendering unavailable. Refresh to restore editing." + +msgid "workspace.top-bar.webgl-context-lost.reload" +msgstr "Refresh" + #: src/app/main/ui/workspace/sidebar/history.cljs:333 msgid "workspace.undo.empty" msgstr "There are no history changes so far" +#: src/app/main/ui/workspace/sidebar/history.cljs +msgid "workspace.undo.entry.by" +msgstr "by %s" + #: src/app/main/ui/workspace/sidebar/history.cljs:147 msgid "workspace.undo.entry.delete" msgstr "Deleted %s" @@ -9064,6 +9274,9 @@ msgstr "Autosaved %s" msgid "workspace.versions.button.pin" msgstr "Pin version" +msgid "workspace.versions.button.preview" +msgstr "Preview version" + #: src/app/main/ui/workspace/sidebar/versions.cljs:273 msgid "workspace.versions.button.restore" msgstr "Restore version" @@ -9108,6 +9321,12 @@ msgstr "This version is locked by %s and cannot be modified" msgid "workspace.versions.locked-by-you" msgstr "This version is locked by you" +msgid "workspace.versions.preview-banner-title" +msgstr "Previewing version: %s" + +msgid "workspace.versions.preview.unnamed" +msgstr "Unnamed version" + #: src/app/main/ui/workspace/sidebar/versions.cljs:83 msgid "workspace.versions.restore-warning" msgstr "Do you want to restore this version?" @@ -9143,6 +9362,14 @@ msgstr "" msgid "workspace.versions.warning.text" msgstr "Autosaved versions will be kept for %s days." +#: src/app/render_wasm/api.cljs +msgid "webgl.webgl-context-lost.toast" +msgstr "WebGL context was lost" + +#: src/app/render_wasm/api.cljs +msgid "webgl.webgl-context-recovered.toast" +msgstr "WebGL context was recovered" + msgid "webgl.modals.webgl-unavailable.title" msgstr "Oops! WebGL is not available" @@ -9176,6 +9403,12 @@ msgstr "Projects and files will remain available to team members, but the organi msgid "modals.remove-team-org.accept" msgstr "Remove from organization" +msgid "webgl.toast.webgl-render-enabled" +msgstr "WebGL rendering enabled" + +msgid "webgl.toast.webgl-render-disabled" +msgstr "WebGL rendering disabled" + #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Click to close the path" @@ -9185,3 +9418,160 @@ msgstr "Are you sure you want to delete this team that is part of %s org?" msgid "plugins.validation.message" msgstr "Field %s is invalid: %s" + +msgid "nitrate.code-activation.title" +msgstr "Activate Nitrate" + +msgid "nitrate.code-activation.input-label" +msgstr "Enter your activation code" + +msgid "nitrate.code-activation.submit" +msgstr "Activate" + +msgid "nitrate.code-activation.footer" +msgstr "Need a code? Contact us:" + +msgid "nitrate.code-activation.placeholder" +msgstr "Paste your activation code here" + +msgid "nitrate.activation-success.title" +msgstr "You are Business Nitrate!" + +msgid "nitrate.activation-success.active-until" +msgstr "Your plan is active until %s." + +msgid "nitrate.activation-success.manage-info" +msgstr "You can manage your subscription anytime from the Subscription page in your account settings." + +msgid "nitrate.activation-success.enjoy" +msgstr "Enjoy your plan!" + +msgid "nitrate.activation-success.create-org" +msgstr "Create organization" + +msgid "nitrate.form.title" +msgstr "Unlock Enterprise features" + +msgid "nitrate.form.enterprise-intro" +msgstr "Everything your teams need to work securely at scale:" + +msgid "nitrate.form.enterprise-feature-1" +msgstr "Global to fine-grained permissions across teams and projects" + +msgid "nitrate.form.enterprise-feature-2" +msgstr "Centralized control over who does what and where, from a dedicated Admin Console" + +msgid "nitrate.form.enterprise-feature-3" +msgstr "Full open-source freedom, with the governance your org needs" + +msgid "nitrate.form.enterprise.price" +msgstr "$25 / member per month" + +msgid "nitrate.form.start-enterprise" +msgstr "Get started with Enterprise" + +msgid "nitrate.form.try-free" +msgstr "Try it free for 14 days" + +msgid "nitrate.form.cancel-anytime" +msgstr "Payment will be processed at the end of the trial. Cancel anytime." + +msgid "nitrate.form.have-code" +msgstr "Have an activation code?" + +msgid "nitrate.form.enter-code" +msgstr "Enter activation code" + +msgid "nitrate.form.see-plan" +msgstr "See my current plan" + +msgid "nitrate.form.contact-upgrade" +msgstr "Contact us to upgrade to Enterprise:" + +msgid "nitrate.form.contact-trial" +msgstr "Contact us to try Enterprise for 14 days:" + +msgid "nitrate.activation-code.invalid-error" +msgstr "Invalid code." + +msgid "nitrate.activation-code.expired-error" +msgstr "This code has expired." + +msgid "nitrate.contact-sales.title" +msgstr "Switch to %s plan?" + +msgid "nitrate.contact-sales.downgrade-title" +msgstr "When you downgrade:" + +msgid "nitrate.contact-sales.downgrade-org-deleted" +msgstr "Your organization will be deleted." + +msgid "nitrate.contact-sales.downgrade-teams-available" +msgstr "The teams, projects and files will no longer be part of any organization but they will remain available." + +msgid "nitrate.contact-sales.downgrade-storage-limited" +msgstr "Your total storage, auto-version history, and file recovery period will be limited." + +msgid "nitrate.contact-sales.downgrade-contact-info" +msgstr "To switch to this plan, please contact our sales team. We'll help you update your subscription and ensure everything is set up correctly." + +msgid "nitrate.contact-sales.button" +msgstr "Contact sales" + +msgid "nitrate.subscription.active-until" +msgstr "Active until %s" + +msgid "nitrate.subscription.settings.manual-cancel" +msgstr "Cancel subscription" + +msgid "nitrate.subscription.settings.renew-with-code" +msgstr "Renew with activation code" + +msgid "subscription.settings.activate-by-code" +msgstr "Enter activation code" + +msgid "subscription.current-plan.title" +msgstr "Your subscription" + +msgid "subscription.current-plan.professional" +msgstr "Professional" + +msgid "subscription.current-plan.unlimited" +msgstr "Unlimited" + +msgid "subscription.current-plan.unlimited-trial" +msgstr "Unlimited Trial" + +msgid "subscription.current-plan.nitrate" +msgstr "Enterprise" + +msgid "subscription.current-plan.nitrate-trial" +msgstr "Enterprise Trial" + +msgid "subscription.current-plan.enterprise" +msgstr "Enterprise" + +msgid "subscription.banner.see-enterprise" +msgstr "See Enterprise in action" + +msgid "subscription.banner.create-org-info" +msgstr "Create an organization and see exactly how Enterprise works. Set rules, manage your teams and discover a new level of control over your design workflow." + +msgid "dashboard.no-permission-create-team.message" +msgstr "You are not allowed to create teams within %s organization. If you need more information, contact the organization's owner." + +msgid "dashboard.no-org-allows-create-team.message" +msgstr "You don't have permission to add teams to any of your organizations." + +msgid "dashboard.select-org-modal.permission-info" +msgstr "Here you find all your organizations where you are allowed to create or add teams." + +msgid "dashboard.no-permission-delete-team.message" +msgstr "You are not allowed to delete teams that are part of %s organization. If you need more information, contact the organization's owner." + +msgid "dashboard.no-permission-move-team.message" +msgstr "You are not allowed to move teams that are part of %s organization. If you need more information, contact the organization's owner." + +msgid "dashboard.invitations.no-permission" +msgstr "You do not have permission to invite people to join or to edit or delete invitations in this team." + diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 6855d5fa84..6d6189b790 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -389,8 +389,11 @@ msgstr "+ Crear org" msgid "dashboard.create-new-org" msgstr "Crear org" -msgid "dashboard.go-to-control-center" -msgstr "Ir al centro de control" +msgid "dashboard.my-teams" +msgstr "Mis equipos" + +msgid "dashboard.go-to-admin-console" +msgstr "Ir a la Admin Console" #: src/app/main/ui/dashboard/sidebar.cljs:340 msgid "dashboard.create-new-team" @@ -1161,7 +1164,10 @@ msgid "dashboard.select-org-modal.accept" msgstr "AÑADIR A UNA ORGANIZACIÓN" msgid "dashboard.change-org-modal.title" -msgstr "CAMBIAR EL EQUIPO DE ORGANIZACIÓN" +msgstr "Cambiar el equipo de organización" + +msgid "dashboard.change-org-modal.text" +msgstr "Los miembros del equipo seguirán teniendo acceso a los proyectos y ficheros. El equipo tendrá la configuración de la nueva organización." msgid "dashboard.change-org-modal.choose" msgstr "Mover a:" @@ -1291,6 +1297,27 @@ msgstr "Tu nombre" msgid "dashboard.your-penpot" msgstr "Tu Penpot" +msgid "dashboard.webgl-switch.title" +msgstr "Renderizado WebGL" + +msgid "dashboard.webgl-switch.beta" +msgstr "Beta" + +msgid "dashboard.webgl-switch.description" +msgstr "El renderizado WebGL puede mejorar el rendimiento, pero está en beta y puede ser menos estable. Pueden aparecer diferencias visuales entre el lienzo, las exportaciones (SVG/PDF) y el modo de vista." + +msgid "dashboard.webgl-switch.status" +msgstr "Estado" + +msgid "dashboard.webgl-switch.enabled" +msgstr "Activado" + +msgid "dashboard.webgl-switch.disabled" +msgstr "Desactivado" + +msgid "dashboard.webgl-switch.feedback" +msgstr "Enviar comentarios" + #: src/app/main/ui/alert.cljs:35 msgid "ds.alert-ok" msgstr "Ok" @@ -1620,6 +1647,10 @@ msgstr "" msgid "errors.token-theme-already-exists" msgstr "Ya existe un theme con este nombre" +#: src/app/common/files/tokens.cljc:298 +msgid "errors.token-theme-not-existing-sets" +msgstr "El tema referencia sets que no existen: %s" + #: src/app/main/data/media.cljs:73 msgid "errors.unexpected-error" msgstr "Ha ocurrido un error inesperado." @@ -1930,6 +1961,10 @@ msgstr "Tipografía" msgid "inspect.attributes.typography.font-family" msgstr "Familia tipográfica" +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:308 +msgid "inspect.attributes.typography.mixed-font-family" +msgstr "Varias Familias tipográficas" + #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:326, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:332 msgid "inspect.attributes.typography.font-size" msgstr "Tamaño de fuente" @@ -2787,6 +2822,12 @@ msgstr "Nueva contraseña" msgid "labels.next" msgstr "Siguiente" +#: src/app/main/ui/static.cljs:325 +msgid "labels.nitrate-unavailable.main-message" +msgstr "" +"Penpot no está disponible temporalmente debido a un problema del sistema. " +"Por favor, inténtalo de nuevo en unos momentos." + #: src/app/main/ui/dashboard/comments.cljs:122, src/app/main/ui/workspace/comments.cljs:162 msgid "labels.no-comments-available" msgstr "¡Ya estás al día! Nuevas notificaciones de comentarios aparecerán aquí." @@ -2976,6 +3017,9 @@ msgstr "Reenviar invitacion" msgid "labels.restore" msgstr "Restaurar" +msgid "labels.exit" +msgstr "Salir" + #: src/app/main/ui/components/progress.cljs:80, src/app/main/ui/static.cljs:299, src/app/main/ui/static.cljs:308, src/app/main/ui/static.cljs:419 msgid "labels.retry" msgstr "Reintentar" @@ -3392,6 +3436,18 @@ msgstr "Si borras tu cuenta perderás todos tus proyectos y archivos." msgid "modals.delete-account.title" msgstr "¿Seguro que quieres borrar tu cuenta?" +#: src/app/main/ui/settings/delete_account.cljs +msgid "modals.delete-account.owned-orgs.list-title" +msgstr "Organizaciones que se eliminarán" + +#: src/app/main/ui/settings/delete_account.cljs +msgid "modals.delete-account.owned-orgs.teams-count" +msgstr "%s equipos" + +#: src/app/main/ui/settings/delete_account.cljs +msgid "modals.delete-account.owned-orgs.members-count" +msgstr "%s miembros" + #: src/app/main/ui/comments.cljs:889 msgid "modals.delete-comment-thread.accept" msgstr "Eliminar conversación" @@ -5057,6 +5113,24 @@ msgstr "Invita a personas mientras estás en el plan Unlimited" msgid "subscription.dashboard.upgrade-plan.power-up" msgstr "Mejora" +msgid "subscription.dashboard.banner.unlock-features" +msgstr "Desbloquea las funciones Enterprise" + +msgid "subscription.dashboard.banner.unlock-features-description" +msgstr "Establece permisos detallados y garantiza la seguridad de tus operaciones de diseño, sin renunciar a la libertad del código abierto que tanto valora tu equipo." + +msgid "subscription.dashboard.banner.upgrade-nitrate" +msgstr "Empieza a utilizar Enterprise" + +msgid "subscription.error.nitrate.checkout-cancelled" +msgstr "El pago no se completó. Inténtalo de nuevo cuando quieras." + +msgid "subscription.error.nitrate.checkout-failed" +msgstr "No hemos podido iniciar el proceso de pago. Inténtalo de nuevo. Si el problema persiste, contáctanos: support@penpot.app." + +msgid "subscription.error.nitrate.checkout-finish-failed" +msgstr "No hemos podido confirmar tu suscripción. Revisa el estado de tu suscripción en la página de Suscripciones. Puedes volver a intentarlo si lo necesitas." + #: src/app/main/ui/settings/sidebar.cljs:116, src/app/main/ui/settings/subscription.cljs:425, src/app/main/ui/settings/subscription.cljs:462 msgid "subscription.labels" msgstr "Suscripción" @@ -5187,6 +5261,9 @@ msgstr "editor por mes" msgid "subscription.settings.price-organization-month" msgstr "organización por mes" +msgid "subscription.settings.organization-member-month" +msgstr "miembro organización por mes" + #: src/app/main/ui/dashboard/subscription.cljs:140, src/app/main/ui/settings/subscription.cljs:102, src/app/main/ui/settings/subscription.cljs:469, src/app/main/ui/settings/subscription.cljs:538 msgid "subscription.settings.professional" msgstr "Professional" @@ -5203,6 +5280,15 @@ msgstr "10 GB de almacenamiento" msgid "subscription.settings.professional.teams-editors-benefit" msgstr "Equipos ilimitados. Hasta 8 editores en todos tus equipos." +msgid "subscription.settings.professional.selfhost.control-over-data" +msgstr "Control total sobre los datos y la infraestructura." + +msgid "subscription.settings.professional.selfhost.unlimited-users" +msgstr "Número ilimitado de usuarios, equipos y archivos." + +msgid "subscription.settings.professional.selfhost.community-support" +msgstr "Todas las funciones de diseño y creación de prototipos. Asistencia de la comunidad." + #: src/app/main/ui/settings/subscription.cljs:50 msgid "subscription.settings.recommended" msgstr "Recomendado" @@ -5230,11 +5316,11 @@ msgid "subscription.settings.success.dialog.thanks" msgstr "¡Gracias por elegir el plan %s de Penpot!" #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "¡Disfruta de tu plan!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "Eres %s!" #: src/app/main/ui/settings/subscription.cljs:526 @@ -5903,6 +5989,13 @@ msgstr "Cambiar a tema claro" msgid "workspace.header.menu.toggle-system-theme" msgstr "Cambiar a tema del sistema" +msgid "workspace.header.menu.enable-webgl" +msgstr "Activar renderizado WebGL (beta)" + +msgid "workspace.header.menu.disable-webgl" +msgstr "Desactivar renderizado WebGL (beta)" + + #: src/app/main/ui/workspace/main_menu.cljs:492 msgid "workspace.header.menu.undo" msgstr "Deshacer" @@ -6136,13 +6229,33 @@ msgid "workspace.libraries.shared-library-btn" msgstr "Conectar biblioteca" #: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:332 -msgid "workspace.libraries.text.multiple-typography" -msgstr "Varias tipografías" +msgid "workspace.libraries.text.mixed-typography" +msgstr "Varios recursos" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:332 +msgid "workspace.libraries.text.mixed-tokens" +msgstr "Varios tokens" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:332 +msgid "workspace.libraries.text.mixed-tokens-and-assets" +msgstr "Varios tokens y recursos" #: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:335 msgid "workspace.libraries.text.multiple-typography-tooltip" msgstr "Desvincular todas las tipografías" +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:335 +msgid "workspace.libraries.text.multiple-assets-tooltip" +msgstr "Desvincular todos los recursos" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:335 +msgid "workspace.libraries.text.multiple-token-tooltip" +msgstr "Desvincular todos los tokens" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:335 +msgid "workspace.libraries.text.multiple-token-and-asset-tooltip" +msgstr "Desvincular todos los tokens y recursos" + #: src/app/main/ui/workspace/libraries.cljs:103, src/app/main/ui/workspace/libraries.cljs:130 msgid "workspace.libraries.typography" msgid_plural "workspace.libraries.typography" @@ -6209,6 +6322,10 @@ msgstr "Mostrar/ocultar desenfoque" msgid "workspace.options.canvas-background" msgstr "Color de fondo" +#: src/app/main/ui/workspace/sidebar/options/page.cljs +msgid "workspace.options.pixel grid-color" +msgstr "Color de la rejilla de pixeles" + #: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:567 msgid "workspace.options.clip-content" msgstr "Truncar contenido" @@ -7999,7 +8116,7 @@ msgid "workspace.tokens.choose-folder" msgstr "Elige carpeta" #: src/app/main/data/workspace/tokens/errors.cljs:101, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:251 -msgid "workspace.tokens.composite-line-height-needs-font-size" +msgid "errors.tokens.composite-line-height-needs-font-size" msgstr "" "El Line Height depende del Font Size. Añade un Font Size para obtener el " "valor computado." @@ -8034,7 +8151,7 @@ msgstr "Copiar ruta de token" #: src/app/main/data/workspace/tokens/library_edit.cljs:240, src/app/main/data/workspace/tokens/library_edit.cljs:509 msgid "workspace.tokens.duplicate-suffix" -msgstr "copiar" +msgstr "copia" #: src/app/main/ui/workspace/tokens/management/context_menu.cljs:337 msgid "workspace.tokens.edit" @@ -8053,7 +8170,7 @@ msgid "workspace.tokens.edit-token" msgstr "Editar token de %s" #: src/app/main/data/workspace/tokens/errors.cljs:41 -msgid "workspace.tokens.empty-input" +msgid "errors.tokens.empty-input" msgstr "El valor del token no puede estar vacío" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 @@ -8061,7 +8178,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "Introduce un nombre para el token %s" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "Error al importar: No se pudo procesar el JSON." #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -8121,7 +8238,7 @@ msgid "workspace.tokens.import-button-prefix" msgstr "Importar %s" #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "Error al importar:" #: src/app/main/ui/workspace/tokens/import/modal.cljs:273 @@ -8176,55 +8293,63 @@ msgid "workspace.tokens.individual-tokens" msgstr "Usa tokens individuales" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "Valor de color inválido: %s" #: src/app/main/data/workspace/tokens/errors.cljs:93 -msgid "workspace.tokens.invalid-font-family-token-value" +msgid "errors.tokens.invalid-font-family-token-value" msgstr "Valor de token no válido: solo puedes referenciar tokens tipo font-family" #: src/app/main/data/workspace/tokens/errors.cljs:89 -msgid "workspace.tokens.invalid-font-weight-token-value" +msgid "errors.tokens.invalid-font-weight-token-value" msgstr "" "Valor de peso de fuente inválido: usa valores numéricos (100-950) o nombres " "estándar (thin, light, regular, bold, etc.) opcionalmente seguidos de " "'Italic'" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "Error al importar: Datos de token no válidos en JSON." #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "Error al importar: Nombre de token no válido en JSON." +#: src/app/main/data/workspace/tokens/errors.cljs:32 +msgid "workspace.tokens.duplicated-json-token-name" +msgstr "Error al importar: Nombre de token duplicado en JSON." + #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "\"%s\" no es un nombre de token válido.\n" "Los nombres de token solo pueden contener letras y dígitos separados por " "caracteres . y no pueden empezar con un signo $." +#: src/app/main/data/workspace/tokens/errors.cljs:33 +msgid "workspace.tokens.duplicated-json-token-name-detail" +msgstr "Existe un token en la ruta '%s' o en un prefijo del mismo, en otro set." + #: src/app/main/data/workspace/tokens/errors.cljs:105 -msgid "workspace.tokens.invalid-shadow-type-token-value" +msgid "errors.tokens.invalid-shadow-type-token-value" msgstr "Tipo de sombra no válida: solo se aceptan 'innerShadow' o 'dropShadow'" #: src/app/main/data/workspace/tokens/errors.cljs:85 -msgid "workspace.tokens.invalid-text-decoration-token-value" +msgid "errors.tokens.invalid-text-decoration-token-value" msgstr "" "Valor de token no válido: solo none, underline y strike-through son " "aceptados" #: src/app/main/data/workspace/tokens/errors.cljs:117 -msgid "workspace.tokens.invalid-token-value-shadow" +msgid "errors.tokens.invalid-token-value-shadow" msgstr "Valor no válido: debe hacer referencia a un token de sombra compuesto." #: src/app/main/data/workspace/tokens/errors.cljs:97 -msgid "workspace.tokens.invalid-token-value-typography" +msgid "errors.tokens.invalid-token-value-typography" msgstr "Valor no válido: debe hacer referencia a un token tipográfico compuesto." #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "Valor de token no válido: %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -8256,7 +8381,7 @@ msgid "workspace.tokens.missing-reference" msgstr "Referencia no encontrada" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "Referencias de tokens no encontradas: " #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 @@ -8312,11 +8437,11 @@ msgid "workspace.tokens.num-active-sets" msgstr "%s sets activos" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "Valor de token no valido. El valor resuelto es muy grande: %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "La opacidad debe estar entre 0 y 100% o 0 y 1 (p.e. 50% o 0.5)." #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:121 @@ -8372,7 +8497,7 @@ msgid "workspace.tokens.select-set" msgstr "Selecciona set" #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "El token tiene una autoreferencia" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 @@ -8411,7 +8536,7 @@ msgid "workspace.tokens.shadow-blur" msgstr "Blur" #: src/app/main/data/workspace/tokens/errors.cljs:109 -msgid "workspace.tokens.shadow-blur-range" +msgid "errors.tokens.shadow-blur-range" msgstr "El desenfoque (blur) de la sombra debe ser mayor o igual a 0." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:987, src/app/main/ui/workspace/tokens/management/create/form.cljs:988 @@ -8432,7 +8557,7 @@ msgid "workspace.tokens.shadow-spread" msgstr "Spread" #: src/app/main/data/workspace/tokens/errors.cljs:113 -msgid "workspace.tokens.shadow-spread-range" +msgid "errors.tokens.shadow-spread-range" msgstr "La extensión (spread) de la sombra debe ser mayor o igual a 0." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:1215 @@ -8458,7 +8583,7 @@ msgid "workspace.tokens.shadow-y" msgstr "Y" #: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 -msgid "workspace.tokens.stroke-width-range" +msgid "errors.tokens.stroke-width-range" msgstr "Stroke width debe ser mayor o igual a 0." #: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:40, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:161 @@ -8556,7 +8681,7 @@ msgid "workspace.tokens.value-not-valid" msgstr "El valor no es válido" #: src/app/main/data/workspace/tokens/errors.cljs:65 -msgid "workspace.tokens.value-with-units" +msgid "errors.tokens.value-with-units" msgstr "Valor no válido: No se permiten unidades." #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs @@ -8582,6 +8707,10 @@ msgstr "Duplicar grupo de tokens" msgid "workspace.tokens.rename-group-name-hint" msgstr "Tus tokens serán automáticamente renombrados a %s.(sufijo).(token)" +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs, src/app/main/ui/workspace/colorpicker/color_tokens.cljs +msgid "workspace.tokens.search-by-token" +msgstr "Buscar por el nombre del token" + #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 msgid "workspace.toolbar.assets" msgstr "Recursos" @@ -8660,6 +8789,12 @@ msgstr "Hecho" msgid "workspace.top-bar.view-only" msgstr "**Inspeccionando código** (View only)" +msgid "workspace.top-bar.webgl-context-lost" +msgstr "Renderizado no disponible. Recarga la página para restaurar la edición." + +msgid "workspace.top-bar.webgl-context-lost.reload" +msgstr "Recargar" + #: src/app/main/ui/workspace/sidebar/history.cljs:333 msgid "workspace.undo.empty" msgstr "Todavía no hay cambios en el histórico" @@ -8869,6 +9004,9 @@ msgstr "Versiones de %s" msgid "workspace.versions.loading" msgstr "Cargando..." +msgid "workspace.versions.preview-banner-title" +msgstr "Previsualizando version: %s" + #: src/app/main/ui/workspace/sidebar/versions.cljs:83 msgid "workspace.versions.restore-warning" msgstr "¿Quieres restaurar esta versión?" @@ -8898,6 +9036,14 @@ msgstr "Si quieres aumentar este límite, contáctanos en [support@penpot.app](% msgid "workspace.versions.warning.text" msgstr "Los autoguardados duran %s días." +#: src/app/render_wasm/api.cljs +msgid "webgl.webgl-context-lost.toast" +msgstr "Se perdió el contexto WebGL" + +#: src/app/render_wasm/api.cljs +msgid "webgl.webgl-context-recovered.toast" +msgstr "Se recuperó el contexto WebGL" + msgid "webgl.modals.webgl-unavailable.title" msgstr "Vaya, WebGL no está disponible" @@ -8931,9 +9077,176 @@ msgstr "Los proyectos y archivos seguirán estando disponibles para los miembros msgid "modals.remove-team-org.accept" msgstr "Eliminar de la organización" +msgid "webgl.toast.webgl-render-enabled" +msgstr "Renderizado WebGL activado" + +msgid "webgl.toast.webgl-render-disabled" +msgstr "Renderizado WebGL desactivado" + #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Pulsar para cerrar la ruta" msgid "modals.delete-org-team-confirm.message" msgstr "¿Estás seguro de que deseas eliminar este equipo que forma parte de la organización %s?" + +msgid "nitrate.code-activation.title" +msgstr "Activar Nitrate" + +msgid "nitrate.code-activation.input-label" +msgstr "Introduce tu código de activación" + +msgid "nitrate.code-activation.submit" +msgstr "Activar" + +msgid "nitrate.code-activation.footer" +msgstr "¿Necesitas un código? Contáctanos:" + +msgid "nitrate.code-activation.placeholder" +msgstr "Pega aquí tu código de activación" + + +msgid "nitrate.activation-success.title" +msgstr "¡Ya eres Business Nitrate!" + +msgid "nitrate.activation-success.active-until" +msgstr "Tu plan está activo hasta el %s." + +msgid "nitrate.activation-success.manage-info" +msgstr "Puedes gestionar tu suscripción en cualquier momento desde la página de Suscripción en la configuración de tu cuenta." + +msgid "nitrate.activation-success.enjoy" +msgstr "¡Disfruta de tu plan!" + +msgid "nitrate.activation-success.create-org" +msgstr "Crear organización" + +msgid "nitrate.form.title" +msgstr "Desbloquea las funciones Enterprise" + +msgid "nitrate.form.enterprise-intro" +msgstr "Todo lo que tus equipos necesitan para trabajar de forma segura a escala:" + +msgid "nitrate.form.enterprise-feature-1" +msgstr "Permisos globales y granulares en equipos y proyectos" + +msgid "nitrate.form.enterprise-feature-2" +msgstr "Control centralizado sobre quién hace qué y dónde, desde una consola de administración dedicada" + +msgid "nitrate.form.enterprise-feature-3" +msgstr "Total libertad open-source, con la gobernanza que tu organización necesita" + +msgid "nitrate.form.enterprise.price" +msgstr "$25 / miembro por mes" + +msgid "nitrate.form.start-enterprise" +msgstr "Empieza a utilizar Enterprise" + +msgid "nitrate.form.try-free" +msgstr "Pruébalo gratis durante 14 días" + +msgid "nitrate.form.cancel-anytime" +msgstr "El pago se procesará al finalizar el periodo de prueba. Puedes cancelar en cualquier momento." + +msgid "nitrate.form.have-code" +msgstr "¿Tienes un código de activación?" + +msgid "nitrate.form.enter-code" +msgstr "Introducir código de activación" + +msgid "nitrate.form.see-plan" +msgstr "Ver mi plan actual" + +msgid "nitrate.form.contact-upgrade" +msgstr "Contáctanos para actualizar a Enterprise:" + +msgid "nitrate.form.contact-trial" +msgstr "Contáctanos para probar Enterprise durante 14 días:" + +msgid "nitrate.activation-code.invalid-error" +msgstr "Código inválido." + +msgid "nitrate.activation-code.expired-error" +msgstr "Este código ha caducado." + +msgid "nitrate.contact-sales.title" +msgstr "¿Cambiar al plan %s?" + +msgid "nitrate.contact-sales.downgrade-title" +msgstr "Al bajar de plan:" + +msgid "nitrate.contact-sales.downgrade-org-deleted" +msgstr "Tu organización será eliminada." + +msgid "nitrate.contact-sales.downgrade-teams-available" +msgstr "Los equipos, proyectos y archivos dejarán de pertenecer a la organización pero seguirán disponibles." + +msgid "nitrate.contact-sales.downgrade-storage-limited" +msgstr "Tu almacenamiento total, el historial de versiones automático y el período de recuperación de archivos serán limitados." + +msgid "nitrate.contact-sales.downgrade-contact-info" +msgstr "Para cambiar a este plan, contacta con nuestro equipo de ventas. Te ayudaremos a actualizar tu suscripción y a asegurarnos de que todo esté configurado correctamente." + +msgid "nitrate.contact-sales.button" +msgstr "Contactar con ventas" + +msgid "nitrate.subscription.active-until" +msgstr "Activo hasta el %s" + +msgid "nitrate.subscription.settings.manual-cancel" +msgstr "Cancelar subscripción" + +msgid "nitrate.subscription.settings.renew-with-code" +msgstr "Renovar con código de activación" + +msgid "subscription.settings.more-information" +msgstr "Más información" + +msgid "subscription.settings.activate-by-code" +msgstr "Introducir código de activación" + +msgid "subscription.current-plan.title" +msgstr "Tu suscripción" + +msgid "subscription.current-plan.professional" +msgstr "Professional" + +msgid "subscription.current-plan.unlimited" +msgstr "Unlimited" + +msgid "subscription.current-plan.unlimited-trial" +msgstr "Unlimited (Prueba)" + +msgid "subscription.current-plan.nitrate" +msgstr "Enterprise" + +msgid "subscription.current-plan.nitrate-trial" +msgstr "Enterprise (Prueba)" + +msgid "subscription.current-plan.enterprise" +msgstr "Enterprise" + +msgid "subscription.banner.see-enterprise" +msgstr "Vea Enterprise en acción" + +msgid "subscription.banner.create-org-info" +msgstr "Crea una organización y descubre exactamente cómo funciona Enterprise. Establece normas, gestiona tus equipos y disfruta de un nuevo nivel de control sobre tu flujo de trabajo de diseño." + +msgid "dashboard.no-permission-create-team.message" +msgstr "No tienes permiso para crear equipos en la organización %s. Si necesitas más información, contacta con el propietario de la organización." + +msgid "dashboard.no-org-allows-create-team.message" +msgstr "No tienes permiso para añadir equipos a ninguna de tus organizaciones." + +msgid "dashboard.select-org-modal.permission-info" +msgstr "Aquí encontrarás todas las organizaciones en las que tienes permiso para crear o añadir equipos." + +msgid "dashboard.no-permission-delete-team.message" +msgstr "No tienes permiso para eliminar equipos que pertenecen a la organización %s. Si necesitas más información, contacta con el propietario de la organización." + +msgid "dashboard.no-permission-move-team.message" +msgstr "No tienes permiso para mover equipos que son parte de la organización %s. Si necesitas más información, contacta con el propietario de la organización." + +msgid "dashboard.invitations.no-permission" +msgstr "No tienes permiso para invitar a personas a unirse ni para editar o eliminar invitaciones en este equipo." + diff --git a/frontend/translations/fr.po b/frontend/translations/fr.po index d061e50e7e..c65ce98b8e 100644 --- a/frontend/translations/fr.po +++ b/frontend/translations/fr.po @@ -1,15 +1,15 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-02-16 08:35+0000\n" -"Last-Translator: Anonymous \n" -"Language-Team: French " -"\n" +"PO-Revision-Date: 2026-04-29 17:10+0000\n" +"Last-Translator: Ingrid Pigueron \n" +"Language-Team: French \n" "Language: fr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n!=1);\n" -"X-Generator: Weblate 5.16-dev\n" +"X-Generator: Weblate 5.17.1-dev\n" #: src/app/main/ui/auth/register.cljs:215, src/app/main/ui/static.cljs:158, src/app/main/ui/viewer/login.cljs:100 msgid "auth.already-have-account" @@ -110,7 +110,7 @@ msgstr "Lien de récupération de mot de passe envoyé." #: src/app/main/ui/auth/verify_token.cljs:49 msgid "auth.notifications.team-invitation-accepted" -msgstr "Vous avez rejoint l’équipe avec succès" +msgstr "Vous avez bien rejoint l’équipe" #: src/app/main/ui/auth/login.cljs:188, src/app/main/ui/auth/register.cljs:174 msgid "auth.password" @@ -230,8 +230,8 @@ msgstr "Tous les utilisateurs de Penpot" #: src/app/main/ui/viewer/share_link.cljs:204 msgid "common.share-link.confirm-deletion-link-description" msgstr "" -"Êtes-vous certain de vouloir supprimer ce lien ? Si oui, plus personne ne " -"pourra y accéder" +"Voulez -vous vraiment supprimer ce lien ? Si oui, plus personne ne pourra y " +"accéder" #: src/app/main/ui/viewer/share_link.cljs:259, src/app/main/ui/viewer/share_link.cljs:289 msgid "common.share-link.current-tag" @@ -247,7 +247,7 @@ msgstr "Obtenir le lien" #: src/app/main/ui/viewer/share_link.cljs:142 msgid "common.share-link.link-copied-success" -msgstr "Lien copié avec succès" +msgstr "Lien copié" #: src/app/main/ui/viewer/share_link.cljs:231 msgid "common.share-link.manage-ops" @@ -312,7 +312,7 @@ msgstr "Faites une équipe  !" #: src/app/main/ui/dashboard/projects.cljs #, unused msgid "dasboard.tutorial-hero.info" -msgstr "Apprenez les bases de Penpot en s'amusant avec ce tutoriel pratique." +msgstr "Apprenez les bases de Penpot en vous amusant avec ce tutoriel pratique." #: src/app/main/ui/dashboard/projects.cljs #, unused @@ -357,11 +357,11 @@ msgstr "Générer un nouveau jeton" #: src/app/main/ui/settings/access_tokens.cljs:64 msgid "dashboard.access-tokens.create.success" -msgstr "Jeton d'accès créé avec succès." +msgstr "Le jeton d'accès a bien été créé." #: src/app/main/ui/settings/access_tokens.cljs:286 msgid "dashboard.access-tokens.empty.add-one" -msgstr "Pressez le bouton \"Générer un nouveau jeton\" pour en générer un." +msgstr "Appuyez sur le bouton \"Générer un nouveau jeton\" pour en générer un." #: src/app/main/ui/settings/access_tokens.cljs:285 msgid "dashboard.access-tokens.empty.no-access-tokens" @@ -369,19 +369,19 @@ msgstr "Vous n'avez pas encore de jeton." #: src/app/main/ui/settings/access_tokens.cljs:135 msgid "dashboard.access-tokens.expiration-180-days" -msgstr "180 jours" +msgstr "180 jours" #: src/app/main/ui/settings/access_tokens.cljs:132 msgid "dashboard.access-tokens.expiration-30-days" -msgstr "30 jours" +msgstr "30 jours" #: src/app/main/ui/settings/access_tokens.cljs:133 msgid "dashboard.access-tokens.expiration-60-days" -msgstr "60 jours" +msgstr "60 jours" #: src/app/main/ui/settings/access_tokens.cljs:134 msgid "dashboard.access-tokens.expiration-90-days" -msgstr "90 jours" +msgstr "90 jours" #: src/app/main/ui/settings/access_tokens.cljs:131 msgid "dashboard.access-tokens.expiration-never" @@ -389,11 +389,11 @@ msgstr "Jamais" #: src/app/main/ui/settings/access_tokens.cljs:268 msgid "dashboard.access-tokens.expired-on" -msgstr "A expiré le %s" +msgstr "Est arrivé à expiration le %s" #: src/app/main/ui/settings/access_tokens.cljs:269 msgid "dashboard.access-tokens.expires-on" -msgstr "Expire le %s" +msgstr "Arrive à expiration le %s" #: src/app/main/ui/settings/access_tokens.cljs:267 msgid "dashboard.access-tokens.no-expiration" @@ -412,7 +412,7 @@ msgstr "" #: src/app/main/ui/settings/access_tokens.cljs:142 msgid "dashboard.access-tokens.token-will-expire" -msgstr "Le jeton expirera le %s" +msgstr "Le jeton arrivera à expiration le %s" #: src/app/main/ui/settings/access_tokens.cljs:143 msgid "dashboard.access-tokens.token-will-not-expire" @@ -453,18 +453,18 @@ msgstr "Votre Penpot" #: src/app/main/ui/dashboard/deleted.cljs:262 msgid "dashboard.delete-all-forever-confirmation.description" msgstr "" -"Êtes-vous sûr de vouloir supprimer tous vos projets et fichiers effacés " -"pour toujours ? Cette action est irréversible." +"Voulez-vous vraiment supprimer définitivement tous vos projets et fichiers " +"effacés ? Cette action est irréversible." #: src/app/main/ui/dashboard/file_menu.cljs:221 msgid "dashboard.delete-file-forever-confirmation.description" msgstr "" -"Êtes-vous sûr de vouloir supprimer %s pour toujours ? Cette action est " +"Voulez-vous vraiment supprimer définitivement %s ? Cette action est " "irréversible." #: src/app/main/data/dashboard.cljs:778 msgid "dashboard.delete-files-success-notification" -msgstr "%s fichiers ont été effacés avec succès." +msgstr "%s fichiers ont bien été effacés." #: src/app/main/ui/dashboard/deleted.cljs:51, src/app/main/ui/dashboard/deleted.cljs:53, src/app/main/ui/dashboard/deleted.cljs:261, src/app/main/ui/dashboard/deleted.cljs:263, src/app/main/ui/dashboard/file_menu.cljs:220, src/app/main/ui/dashboard/file_menu.cljs:222 msgid "dashboard.delete-forever-confirmation.title" @@ -477,13 +477,13 @@ msgstr "Supprimer le projet" #: src/app/main/ui/dashboard/deleted.cljs:52 msgid "dashboard.delete-project-forever-confirmation.description" msgstr "" -"Êtes-vous sûr de vouloir supprimer définitivement le projet %s ? Vous allez " -"le supprimer définitivement avec tous les fichiers qu'il contient. Cette " -"action est irréversible." +"Voulez-vous vraiment supprimer définitivement le projet %s ? Vous allez le " +"supprimer définitivement avec tous les fichiers qu'il contient. Cette action " +"est irréversible." #: src/app/main/data/dashboard.cljs:777, src/app/main/data/dashboard.cljs:811 msgid "dashboard.delete-success-notification" -msgstr "%s a été supprimé avec succès." +msgstr "%s a bien été supprimé." #: src/app/main/ui/dashboard/sidebar.cljs:495 msgid "dashboard.delete-team" @@ -586,7 +586,7 @@ msgstr "Commencez à fabriquer des choses géniales" #, unused msgid "dashboard.errors.error-on-delete-file" -msgstr "Il y a eu une erreur lors de la suppression du fichier %s." +msgstr "Une erreur s'est produite lors de la suppression du fichier %s." #: src/app/main/data/dashboard.cljs:781 msgid "dashboard.errors.error-on-delete-files" @@ -718,10 +718,10 @@ msgstr "" #, markdown msgid "dashboard.fonts.hero-text2" msgstr "" -"Ne téléchargez que des polices que vous possédez ou dont la license vous " +"Ne téléchargez que des polices que vous possédez ou dont la licence vous " "permet de les utiliser dans Penpot. Vous trouverez plus d'informations dans " -"la section Propriété des Contenus des [conditions générales d'utilisation " -"de Penpot](%s). Vous pouvez également vous renseigner sur les [licenses de " +"la section Propriété des Contenus des [conditions générales d'utilisation de " +"Penpot](%s). Vous pouvez également vous renseigner sur les [licences de " "polices](https://www.typography.com/faq)." #: src/app/main/ui/dashboard/fonts.cljs:214 @@ -810,7 +810,7 @@ msgstr "Médias en cours de traitement" #: src/app/main/ui/dashboard/import.cljs:125 msgid "dashboard.import.progress.process-page" -msgstr "Traitement de la page : %s" +msgstr "Traitement de la page : %s" #: src/app/main/ui/dashboard/import.cljs:131 msgid "dashboard.import.progress.process-typographies" @@ -822,7 +822,7 @@ msgstr "Envoi des données au serveur (%s/%s)" #: src/app/main/ui/dashboard/import.cljs:122 msgid "dashboard.import.progress.upload-media" -msgstr "Envoi du fichier : %s" +msgstr "Envoi du fichier : %s" #: src/app/main/ui/dashboard/team.cljs:765 msgid "dashboard.invitation-modal.delete" @@ -938,7 +938,7 @@ msgstr "Les paramètres des notifications ont été mis à jour" #: src/app/main/ui/settings/password.cljs:38 msgid "dashboard.notifications.password-saved" -msgstr "Mot de passe enregistré avec succès !" +msgstr "Mot de passe enregistré !" #: src/app/main/ui/dashboard/comments.cljs:45 msgid "dashboard.notifications.view" @@ -1049,7 +1049,7 @@ msgstr "Tout restaurer" #: src/app/main/data/dashboard.cljs:903 msgid "dashboard.restore-files-success-notification" -msgstr "%s fichiers ont été restaurés avec succès." +msgstr "%s fichiers ont bien été restaurés." #: src/app/main/ui/dashboard/deleted.cljs:82 msgid "dashboard.restore-project-button" @@ -1065,7 +1065,7 @@ msgstr "Restaurer le projet" #: src/app/main/data/dashboard.cljs:875, src/app/main/data/dashboard.cljs:902, src/app/main/data/dashboard.cljs:939, src/app/main/ui/dashboard/file_menu.cljs:198 msgid "dashboard.restore-success-notification" -msgstr "%s a été restauré avec succès." +msgstr "%s a bien été restauré." #: src/app/main/ui/settings/profile.cljs:78 msgid "dashboard.save-settings" @@ -1189,7 +1189,7 @@ msgstr "Votre projet a bien été dupliqué" #: src/app/main/ui/dashboard/file_menu.cljs:132, src/app/main/ui/dashboard/grid.cljs:634, src/app/main/ui/dashboard/sidebar.cljs:166 msgid "dashboard.success-move-file" -msgstr "Votre fichier a été déplacé avec succès" +msgstr "Votre fichier a bien été déplacé" #: src/app/main/ui/dashboard/file_menu.cljs:131 msgid "dashboard.success-move-files" @@ -1229,7 +1229,7 @@ msgstr "Les fichiers supprimés resteront dans la corbeille pendant" #: src/app/main/ui/dashboard/deleted.cljs:300 msgid "dashboard.trash-info-text-part2" -msgstr " %s jours. " +msgstr " %s jours. " #: src/app/main/ui/dashboard/deleted.cljs:301 msgid "dashboard.trash-info-text-part3" @@ -1247,7 +1247,7 @@ msgstr "Écrivez pour rechercher" #: src/app/main/ui/dashboard/file_menu.cljs:319, src/app/main/ui/workspace/main_menu.cljs:642 msgid "dashboard.unpublish-shared" -msgstr "Retirer la Bibliothèque" +msgstr "Supprimer la bibliothèque" #: src/app/main/ui/settings/options.cljs:74 msgid "dashboard.update-settings" @@ -1285,7 +1285,7 @@ msgstr "Créer un webhook" #: src/app/main/ui/dashboard/team.cljs:1031 msgid "dashboard.webhooks.create.success" -msgstr "Webhook créé avec succès." +msgstr "Webhook créé." #: src/app/main/ui/dashboard/team.cljs:1136 msgid "dashboard.webhooks.description" @@ -1305,7 +1305,7 @@ msgstr "Aucun webhook créé jusqu’à présent." #, unused msgid "dashboard.webhooks.update.success" -msgstr "Webhook mis à jour avec succès." +msgstr "Webhook mis à jour." #: src/app/main/ui/settings.cljs:34 msgid "dashboard.your-account-title" @@ -1453,21 +1453,21 @@ msgstr "Domaine non autorisé" #: src/app/main/ui/auth/recovery_request.cljs:57, src/app/main/ui/auth/register.cljs:98, src/app/main/ui/auth/register.cljs:101, src/app/main/ui/dashboard/team.cljs:627, src/app/main/ui/settings/change_email.cljs:37 msgid "errors.email-has-permanent-bounces" -msgstr "L'adresse e-mail « %s » a un taux de rebond trop élevé." +msgstr "L'adresse e-mail « %s » a un taux de rebond trop élevé." #: src/app/main/ui/dashboard/team.cljs:196, src/app/main/ui/dashboard/team.cljs:858, src/app/main/ui/onboarding/team_choice.cljs:110 msgid "errors.email-spam-or-permanent-bounces" -msgstr "L'e-mail \"%s\" a été signalé comme spam ou a été rejeté." +msgstr "L'e-mail « %s » a été signalé comme spam ou a été rejeté." #: src/app/main/errors.cljs:279 msgid "errors.feature-mismatch" msgstr "" -"Il semble que vous ouvrez un fichier qui a la fonctionnalité '%s' activée, " +"Vous semblez ouvrir un fichier pour lequel la fonctionnalité « %s » activée, " "mais votre interface Penpot ne la prend pas en charge ou l'a désactivée." #: src/app/main/errors.cljs:283, src/app/main/errors.cljs:297 msgid "errors.feature-not-supported" -msgstr "La fonctionnalité '%s' n'est pas prise en charge." +msgstr "La fonctionnalité « %s » n'est pas prise en charge." #: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:296, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:240 msgid "errors.field-max-length" @@ -1491,7 +1491,7 @@ msgid "errors.file-feature-mismatch" msgstr "" "Il semble y avoir une incompatibilité entre les fonctionnalités actives et " "celles du fichier que tentez d'ouvrir. Vous devez activer les migrations " -"pour '%s' avant de pouvoir ouvrir le fichier." +"pour « %s » avant de pouvoir ouvrir le fichier." #: src/app/main/data/auth.cljs:347, src/app/main/ui/auth/login.cljs:104, src/app/main/ui/auth/register.cljs:110, src/app/main/ui/auth/register.cljs:304, src/app/main/ui/auth/verify_token.cljs:94, src/app/main/ui/dashboard/team.cljs:199, src/app/main/ui/dashboard/team.cljs:861, src/app/main/ui/onboarding/team_choice.cljs:113, src/app/main/ui/settings/access_tokens.cljs:79, src/app/main/ui/settings/feedback.cljs:84 msgid "errors.generic" @@ -1538,7 +1538,7 @@ msgstr "Invitation non valide" #: src/app/main/ui/static.cljs:75 msgid "errors.invite-invalid.info" -msgstr "Cette invitation est peut-être été annulée ou a expiré." +msgstr "Cette invitation a peut-être été annulée ou est arrivée à expiration." #: src/app/main/ui/auth/login.cljs:89 msgid "errors.ldap-disabled" @@ -1553,7 +1553,7 @@ msgstr "" #: src/app/main/ui/dashboard/team.cljs:187, src/app/main/ui/dashboard/team.cljs:849, src/app/main/ui/onboarding/team_choice.cljs:101 msgid "errors.maximum-invitations-by-request-reached" msgstr "" -"Le nombre maximum (%s) d'e-mails qui peuvent être invités dans une seule " +"Le nombre maximal (%s) d'e-mails qui peuvent être invités dans une seule " "demande est atteint" #: src/app/main/data/workspace/media.cljs:190 @@ -1563,8 +1563,7 @@ msgstr "L’image est trop grande." #: src/app/main/data/media.cljs:70, src/app/main/data/workspace/media.cljs:193 msgid "errors.media-type-mismatch" msgstr "" -"Il semble que le contenu de l’image ne correspond pas à l’extension de " -"fichier." +"Le contenu de l’image semble ne pas correspondre à l’extension de fichier." #: src/app/main/data/media.cljs:67, src/app/main/data/workspace/media.cljs:178, src/app/main/data/workspace/media.cljs:181, src/app/main/data/workspace/media.cljs:184, src/app/main/data/workspace/media.cljs:187 msgid "errors.media-type-not-allowed" @@ -1622,7 +1621,7 @@ msgstr "Le fichier SVG n'est pas valide ou est mal formé" #: src/app/main/errors.cljs:270 msgid "errors.team-feature-mismatch" -msgstr "Fonctionnalité incompatible détectée '%s'" +msgstr "Fonctionnalité incompatible détectée « %s »" #: src/app/main/ui/dashboard/sidebar.cljs:373, src/app/main/ui/dashboard/team.cljs:393 msgid "errors.team-leave.insufficient-members" @@ -1632,17 +1631,17 @@ msgstr "" #: src/app/main/ui/dashboard/sidebar.cljs:376, src/app/main/ui/dashboard/team.cljs:396 msgid "errors.team-leave.member-does-not-exists" -msgstr "Le membre que vous essayez d'assigner n'existe pas." +msgstr "Le membre que vous essayez d'affecter n'existe pas." #: src/app/main/ui/dashboard/sidebar.cljs:379, src/app/main/ui/dashboard/team.cljs:399 msgid "errors.team-leave.owner-cant-leave" msgstr "" -"Le propriétaire ne peut pas quitter l'équipe, vous devez réassigner le rôle " +"Le propriétaire ne peut pas quitter l'équipe, vous devez réaffecter le rôle " "de propriétaire." #: src/app/main/ui/workspace/tokens/sets/helpers.cljs:26, src/app/main/ui/workspace/tokens/sets/helpers.cljs:45 msgid "errors.token-set-already-exists" -msgstr "Une collection avec le même nom existe déjà" +msgstr "Il existe déjà une collection portant ce nom" #: src/app/main/data/tokens.cljs: #, unused @@ -1652,12 +1651,12 @@ msgstr "Impossible de dupliquer une collection inconnue" #: src/app/main/data/workspace/tokens/library_edit.cljs:337 msgid "errors.token-set-exists-on-drop" msgstr "" -"Impossible de déposer, une collection avec le même nom existe déjà dans ce " +"Impossible de déposer. Il existe déjà une collection portant ce nom à ce " "chemin." #: src/app/main/data/workspace/tokens/library_edit.cljs:125, src/app/main/data/workspace/tokens/library_edit.cljs:144 msgid "errors.token-theme-already-exists" -msgstr "Une option de thème avec le même nom existe déjà" +msgstr "Il existe déjà une option de thème portant ce nom" #: src/app/main/data/media.cljs:73 msgid "errors.unexpected-error" @@ -2061,7 +2060,7 @@ msgstr "" #: src/app/main/ui/inspect/right_sidebar.cljs:166 msgid "inspect.layer-info" -msgstr "Info sur la couche" +msgstr "Infos sur le calque" #: src/app/main/ui/inspect/right_sidebar.cljs:137 msgid "inspect.multiple-selected" @@ -2412,7 +2411,7 @@ msgstr "Événement" #: src/app/main/ui/dashboard/team.cljs:668 msgid "labels.expired-invitation" -msgstr "Expirée" +msgstr "Arrivée à expiration" #: src/app/main/ui/exports/assets.cljs:172, src/app/main/ui/workspace/tokens/sidebar.cljs:134 msgid "labels.export" @@ -2743,7 +2742,7 @@ msgstr "Supprimer" #: src/app/main/ui/dashboard/team.cljs:355 msgid "labels.remove-member" -msgstr "Retirer le membre" +msgstr "Supprimer le membre" #: src/app/main/ui/dashboard/file_menu.cljs:299, src/app/main/ui/dashboard/project_menu.cljs:88, src/app/main/ui/dashboard/sidebar.cljs:471, src/app/main/ui/workspace/sidebar/assets/groups.cljs:167, src/app/main/ui/workspace/sidebar/versions.cljs:192, src/app/main/ui/workspace/tokens/sets/context_menu.cljs:63 msgid "labels.rename" @@ -3553,7 +3552,7 @@ msgstr "" #: src/app/main/ui/workspace/header.cljs, src/app/main/ui/dashboard/file_menu.cljs #, unused msgid "modals.remove-shared-confirm.message" -msgstr "Retirer « %s » en tant que bibliothèque partagée" +msgstr "Supprimer « %s » en tant que bibliothèque partagée" #: src/app/main/ui/workspace/nudge.cljs:52 msgid "modals.small-nudge" @@ -3568,14 +3567,14 @@ msgstr[1] "Dépublier" #: src/app/main/ui/delete_shared.cljs:50 msgid "modals.unpublish-shared-confirm.message" msgid_plural "modals.unpublish-shared-confirm.message" -msgstr[0] "Vous êtes sûr de vouloir retirer cette bibliothèque ?" -msgstr[1] "Vous êtes sûr de vouloir retirer ces bibliothèques ?" +msgstr[0] "Voulez-vous vraiment supprimer cette bibliothèque ?" +msgstr[1] "Voulez-vous vraiment supprimer ces bibliothèques ?" #: src/app/main/ui/delete_shared.cljs:45 msgid "modals.unpublish-shared-confirm.title" msgid_plural "modals.unpublish-shared-confirm.title" -msgstr[0] "Retirer la bibliothèque" -msgstr[1] "Retirer les bibliothèques" +msgstr[0] "Supprimer la bibliothèque" +msgstr[1] "Supprimer les bibliothèques" #: src/app/main/ui/workspace/sidebar/options/menus/component.cljs, src/app/main/ui/workspace/context_menu.cljs #, unused @@ -3739,7 +3738,7 @@ msgstr "" #: src/app/main/ui/settings/options.cljs:27, src/app/main/ui/settings/profile.cljs:30 msgid "notifications.profile-saved" -msgstr "Profil enregistré avec succès !" +msgstr "Profil enregistré !" #: src/app/main/ui/settings/change_email.cljs:46 msgid "notifications.validation-email-sent" @@ -4696,11 +4695,11 @@ msgstr "Activer/désactiver les calques" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:190 msgid "shortcuts.toggle-layout-flex" -msgstr "Ajouter/supprimer flex layout" +msgstr "Ajouter/supprimer la disposition flex" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:191 msgid "shortcuts.toggle-layout-grid" -msgstr "Ajouter / Retirer grid layout" +msgstr "Ajouter/Supprimer la disposition en grille" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:192 msgid "shortcuts.toggle-lock" @@ -4921,8 +4920,8 @@ msgstr[1] "Il y a actuellement %s personnes dans vos équipe qui peuvent être #: src/app/main/ui/settings/subscription.cljs:230 msgid "subscription.settings.management.dialog.downgrade" msgstr "" -"Attention : changer vers un abonnement plus bas signifie moins de stockage " -"et des sauvegardes et des version d'historique plus courtes." +"Attention : en passant à un abonnement inférieur, le stockage est moins " +"important et les sauvegardes et les version d'historique sont plus courtes." #: src/app/main/ui/settings/subscription.cljs:211 msgid "subscription.settings.management.dialog.editors" @@ -5010,11 +5009,11 @@ msgid "subscription.settings.success.dialog.thanks" msgstr "Merci d'avoir choisi l'abonnement Penpot %s !" #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "Profitez bien de votre abonnement !" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "Vous êtes %s !" #: src/app/main/ui/settings/subscription.cljs:526 @@ -5056,8 +5055,8 @@ msgstr "Mettre à niveau votre abonnement" #, markdown msgid "subscription.workspace.versions.warning.enterprise.subtext-owner" msgstr "" -"Si vous souhaitez augmenter cette limite, écrivez-nous à l'adressse " -"[%s](mailto)" +"Si vous souhaitez augmenter cette limite, écrivez-nous à l'adresse [%s]" +"(mailto)" #: src/app/main/ui/workspace/sidebar/versions.cljs:59 #, markdown @@ -5946,7 +5945,7 @@ msgstr "Activer/Désactiver le flou" #: src/app/main/ui/workspace/sidebar/options/page.cljs:42, src/app/main/ui/workspace/sidebar/options/page.cljs:50 msgid "workspace.options.canvas-background" -msgstr "Couleur de fond du canvas" +msgstr "Couleur de fond du canevas" #: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:567 msgid "workspace.options.clip-content" @@ -6424,7 +6423,7 @@ msgstr "Naviguer vers" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:52 msgid "workspace.options.interaction-navigate-to-dest" -msgstr "Naviguer vers : %s" +msgstr "Accéder à : %s" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:53, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:55, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:57, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:357 msgid "workspace.options.interaction-none" @@ -6444,7 +6443,7 @@ msgstr "Ouvrir la superposition" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:54 msgid "workspace.options.interaction-open-overlay-dest" -msgstr "Ouvrir la superposition : %s" +msgstr "Ouvrir la superposition : %s" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:61, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:351 msgid "workspace.options.interaction-open-url" @@ -6513,7 +6512,7 @@ msgstr "Activer/désactiver la superposition" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:56 msgid "workspace.options.interaction-toggle-overlay-dest" -msgstr "Activer/désactiver la superposition : %s" +msgstr "Activer/désactiver la superposition : %s" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:415 msgid "workspace.options.interaction-trigger" @@ -7357,7 +7356,7 @@ msgstr "Copier en CSS" #: src/app/main/ui/workspace/context_menu.cljs:220 msgid "workspace.shape.menu.copy-css-nested" -msgstr "Copier en CSS (couches imbriquées)" +msgstr "Copier en CSS (calques imbriqués)" #: src/app/main/ui/workspace/context_menu.cljs:203 msgid "workspace.shape.menu.copy-link" @@ -7498,7 +7497,7 @@ msgstr "Chemin" #: src/app/main/ui/workspace/context_menu.cljs:549 msgid "workspace.shape.menu.remove-flex" -msgstr "Retirer flex layout" +msgstr "Supprimer la disposition flex" #: src/app/main/ui/workspace/context_menu.cljs:552 msgid "workspace.shape.menu.remove-grid" @@ -7550,7 +7549,7 @@ msgstr "Afficher le composant principal" #: src/app/main/ui/workspace/context_menu.cljs:314 msgid "workspace.shape.menu.thumbnail-remove" -msgstr "Retirer la miniature" +msgstr "Supprimer la miniature" #: src/app/main/ui/workspace/context_menu.cljs:316 msgid "workspace.shape.menu.thumbnail-set" @@ -7706,7 +7705,7 @@ msgid "workspace.tokens.color" msgstr "Couleur" #: src/app/main/data/workspace/tokens/errors.cljs:101, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:251 -msgid "workspace.tokens.composite-line-height-needs-font-size" +msgid "errors.tokens.composite-line-height-needs-font-size" msgstr "" "L'interlignage dépend de la taille de la police. Ajoutez une taille de " "police pour obtenir la valeur déduite." @@ -7721,7 +7720,7 @@ msgstr "En créer un." #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:235 msgid "workspace.tokens.create-token" -msgstr "Créer un nouveau token %s" +msgstr "Créer un token %s" #: src/app/main/ui/workspace/tokens/management/context_menu.cljs:353 msgid "workspace.tokens.delete" @@ -7753,10 +7752,10 @@ msgstr "Modifier les thèmes" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:234 msgid "workspace.tokens.edit-token" -msgstr "Modifier le token" +msgstr "Modifier le token %s" #: src/app/main/data/workspace/tokens/errors.cljs:41 -msgid "workspace.tokens.empty-input" +msgid "errors.tokens.empty-input" msgstr "La valeur du token doit être renseignée" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 @@ -7764,7 +7763,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "Entrez le nom du token %s" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "Erreur d'importation : Impossible d'interpréter le fichier JSON." #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -7818,7 +7817,7 @@ msgid "workspace.tokens.import-button-prefix" msgstr "Importer %s" #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "Erreur d'importation :" #: src/app/main/ui/workspace/tokens/import/modal.cljs:273 @@ -7877,45 +7876,44 @@ msgid "workspace.tokens.individual-tokens" msgstr "Utiliser des tokens individuels" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "Couleur non valide : %s" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "Erreur d'importation : données du token non valides dans le fichier JSON." #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "Erreur lors de l'importation : nom du token non valide au format JSON." #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "« %s » n'est pas un nom de token valide.\n" -"Les noms des tokens ne doivent pas comporter de lettres et de chiffres " -"séparés par des caractères « . » et ne doivent pas commencer par le symbole " -"« $ »." +"Les noms de token ne doivent pas comporter de lettres et de chiffres séparés " +"par des caractères « . » et ne doivent pas commencer par le symbole « $ »." #: src/app/main/data/workspace/tokens/errors.cljs:81 -msgid "workspace.tokens.invalid-text-case-token-value" +msgid "errors.tokens.invalid-text-case-token-value" msgstr "" "Valeur du token non valide : seules les valeurs Aucune, Majuscules, " "Minuscules ou Première lettre en capitale sont acceptées" #: src/app/main/data/workspace/tokens/errors.cljs:85 -msgid "workspace.tokens.invalid-text-decoration-token-value" +msgid "errors.tokens.invalid-text-decoration-token-value" msgstr "" "Valeur du token non valide : seules les valeurs none, underline et " "strike-through sont acceptées" #: src/app/main/data/workspace/tokens/errors.cljs:97 -msgid "workspace.tokens.invalid-token-value-typography" +msgid "errors.tokens.invalid-token-value-typography" msgstr "" "Valeur non valide : elle doit faire référence à un token de typographie " "composite." #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "Valeur du token non valide : %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -7951,7 +7949,7 @@ msgid "workspace.tokens.min-size" msgstr "Taille min." #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "Références du token manquantes : " #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 @@ -7991,7 +7989,7 @@ msgid "workspace.tokens.no-themes-currently" msgstr "Vous n'avez actuellement aucun thème." #: src/app/main/data/workspace/tokens/errors.cljs:19 -msgid "workspace.tokens.no-token-files-found" +msgid "errors.tokens.no-token-files-found" msgstr "Aucun token, collection ou thème n'ont été trouvés dans ce fichier." #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:134 @@ -7999,11 +7997,11 @@ msgid "workspace.tokens.num-active-sets" msgstr "%s collections actives" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "Valeur du token non valide. La valeur est trop grande : %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "" "L'opacité doit être comprise entre 0 % et 100 % ou entre 0 et 1 (ex : 50 % " "ou 0.5)." @@ -8044,7 +8042,7 @@ msgid "workspace.tokens.select-set" msgstr "Sélectionner la collection." #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "Le token s'auto-référence" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 @@ -8079,7 +8077,7 @@ msgid "workspace.tokens.size" msgstr "Taille" #: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 -msgid "workspace.tokens.stroke-width-range" +msgid "errors.tokens.stroke-width-range" msgstr "La largueur du tracé doit être plus grand ou égal à 0." #: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:41, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:169 @@ -8132,7 +8130,7 @@ msgstr "" #: src/app/main/ui/workspace/tokens/style_dictionary.cljs:259 #, unused msgid "workspace.tokens.token-not-resolved" -msgstr "Impossible de trouver une référence de token ayant comme nom : %s" +msgstr "Impossible de trouver une référence de token portant le nom : %s" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:267 msgid "workspace.tokens.token-value" @@ -8144,7 +8142,7 @@ msgstr "Entrez une valeur ou un alias avec {alias}" #: src/app/main/ui/workspace/tokens/management.cljs:67 msgid "workspace.tokens.tokens-section-title" -msgstr "TOKENS - %s" +msgstr "TOKENS – %s" #: src/app/main/ui/workspace/tokens/sidebar.cljs:122 msgid "workspace.tokens.tools" @@ -8167,11 +8165,11 @@ msgid "workspace.tokens.value-not-valid" msgstr "La valeur n'est pas valide" #: src/app/main/data/workspace/tokens/errors.cljs:69 -msgid "workspace.tokens.value-with-percent" +msgid "errors.tokens.value-with-percent" msgstr "Valeur non valide : % n'est pas autorisé." #: src/app/main/data/workspace/tokens/errors.cljs:65 -msgid "workspace.tokens.value-with-units" +msgid "errors.tokens.value-with-units" msgstr "Valeur non valide : les unités ne sont pas autorisées." #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 @@ -8498,8 +8496,362 @@ msgstr "" #: src/app/main/ui/workspace/sidebar/versions.cljs:431 msgid "workspace.versions.warning.text" -msgstr "Les versions auto-enregistrées seront gardées %s jours." +msgstr "Les versions auto-enregistrées seront conservées %s jours." #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Cliquez pour fermer le chemin" + +#: src/app/main/ui/dashboard/sidebar.cljs:347 +msgid "dashboard.create-new-org" +msgstr "Créer une organisation" + +#: src/app/main/errors.cljs:105 +msgid "errors.unexpected-exception" +msgstr "Erreur inattendue : %s" + +#: src/app/main/ui/static.cljs:318 +msgid "labels.reload-page" +msgstr "Recharger la page" + +#: src/app/main/ui/settings/subscription.cljs:50 +msgid "subscription.settings.recommended" +msgstr "Recommandé" + +#: src/app/main/ui/dashboard/team.cljs:933 +msgid "team.invitations-selected" +msgid_plural "team.invitations-selected" +msgstr[0] "1 invitation sélectionnée" +msgstr[1] "%s invitations sélectionnées" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:471 +msgid "workspace.layout-item.height-100" +msgstr "Hauteur 100 %" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:444 +msgid "workspace.layout-item.width-100" +msgstr "Largeur 100 %" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:612 +msgid "workspace.options.interaction-animation-direction-left" +msgstr "Gauche" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:608 +msgid "workspace.options.interaction-animation-direction-right" +msgstr "Droite" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:620 +msgid "workspace.options.interaction-animation-direction-up" +msgstr "Haut" + +#: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:108, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:401 +#, fuzzy +msgid "workspace.options.orientation.horizontal" +msgstr "Horizontal" + +#: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:104, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:397 +#, fuzzy +msgid "workspace.options.orientation.vertical" +msgstr "Vertical" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:232 +msgid "workspace.tokens.shadow-add-shadow" +msgstr "Ajouter une ombre" + +#: src/app/main/data/workspace/tokens/errors.cljs:109 +msgid "workspace.tokens.shadow-blur-range" +msgstr "Le flou de l'ombre doit être supérieur ou égal à 0." + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:1215 +#, unused +msgid "workspace.tokens.shadow-title" +msgstr "Ombres" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:281 +msgid "workspace.tokens.shadow-token-blur-value-error" +msgstr "La valeur de flou ne peut pas être négative" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:287 +#, unused +msgid "workspace.tokens.shadow-token-spread-value-error" +msgstr "La valeur de la portée ne peut pas être négative" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:139, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:141 +#, fuzzy +msgid "workspace.tokens.shadow-x" +msgstr "X" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:150, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:152 +#, fuzzy +msgid "workspace.tokens.shadow-y" +msgstr "Y" + +#: src/app/main/data/workspace/tokens/errors.cljs:105 +msgid "workspace.tokens.invalid-shadow-type-token-value" +msgstr "" +"Type d'ombre non valide : seuls les types « innerShadow » ou « dropShadow » " +"sont acceptés" + +#: src/app/main/data/workspace/tokens/errors.cljs:117 +msgid "workspace.tokens.invalid-token-value-shadow" +msgstr "Valeur non valide : doit référencer un token d'ombre composite." + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:303 +msgid "workspace.tokens.missing-reference" +msgstr "Référence manquante" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:89 +msgid "workspace.tokens.remap-warning-time" +msgstr "Cette action pourrait prendre un instant." + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:987, src/app/main/ui/workspace/tokens/management/create/form.cljs:988 +#, unused +msgid "workspace.tokens.shadow-color" +msgstr "Couleur" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:123 +msgid "workspace.tokens.shadow-remove-shadow" +msgstr "Supprimer l'ombre" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:173, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:174, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:177 +msgid "workspace.tokens.shadow-spread" +msgstr "Portée" + +#: src/app/main/data/workspace/tokens/errors.cljs:113 +msgid "workspace.tokens.shadow-spread-range" +msgstr "La portée de l'ombre doit être supérieure ou égale à 0." + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:51 +#, unused +msgid "workspace.tokens.theme-name-already-exists" +msgstr "Il existe déjà un thème portant ce nom" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:100 +#, unused +msgid "workspace.tokens.theme.disable" +msgstr "Désactiver" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:96 +#, unused +msgid "workspace.tokens.theme.enable" +msgstr "Activer" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:268, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:223 +msgid "workspace.tokens.token-name-duplication-validation-error" +msgstr "Il existe déjà un token au chemin : %s" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:265, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:220 +msgid "workspace.tokens.token-name-length-validation-error" +msgstr "Le nom doit comporter au moins 1 caractère" + +#: src/app/main/ui/workspace/top_toolbar.cljs:231 +msgid "workspace.toolbar.debug" +msgstr "Outils de débogage" + +#: src/app/main/ui/static.cljs:315 +msgid "errors.webgl-context-lost.desc-message" +msgstr "WebGL ne fonctionne plus. Rechargez la page pour le réinitialiser" + +#: src/app/main/ui/static.cljs:314 +msgid "errors.webgl-context-lost.main-message" +msgstr "Oups ! Le contexte du canevas a été perdu" + +#: src/app/main/ui/dashboard/subscription.cljs:196 +msgid "subscription.dashboard.professional-dashboard-cta-title" +msgstr "" +"Il y a %s éditeurs dans les équipes dont vous êtes propriétaire alors que " +"votre abonnement Professionnel permet d'en avoir jusqu'à 8." + +#: src/app/main/ui/dashboard/subscription.cljs:184 +msgid "subscription.dashboard.unlimited-members-extra-editors-cta-text" +msgstr "" +"Seuls les nouveaux éditeurs dans les équipes dont vous êtes propriétaire " +"sont pris en compte pour la facturation future. Un forfait de 175 $/mois " +"s'applique toujours au-delà de 25 éditeurs." + +#: src/app/main/ui/settings/subscription.cljs:298 +msgid "subscription.settings.management-dialog.step-2-add-payment-button" +msgstr "Ajouter les détails de paiement" + +#: src/app/main/ui/settings/subscription.cljs:285 +msgid "subscription.settings.management-dialog.step-2-description" +msgstr "" +"Ajoutez vos détails de paiement dès maintenant pour que votre abonnement ne " +"soit pas interrompu après la période d'essai et pour continuer à soutenir " +"notre projet Open Source. Vous n'allez pas être facturé pour le moment." + +#: src/app/main/ui/settings/subscription.cljs:293 +msgid "subscription.settings.management-dialog.step-2-skip-button" +msgstr "Ignorer pour le moment et commencer l'essai" + +#: src/app/main/ui/settings/subscription.cljs:203 +msgid "subscription.settings.management-dialog.step-2-title" +msgstr "Aidez-nous à nous développer et à faciliter votre essai" + +#: src/app/main/ui/settings/subscription.cljs:263 +msgid "subscription.settings.management.dialog.input-error" +msgstr "" +"Vous pouvez réduire votre nombre actuel d'éditeurs. Dans les paramètres de " +"l'équipe, modifiez le rôle (d'éditeur/admin en spectateur) des personnes qui " +"ne modifient pas vraiment des fichiers." + +#: src/app/main/ui/settings/subscription.cljs:266 +msgid "subscription.settings.management.dialog.unlimited-capped-warning" +msgstr "" +"Conseil : vous pouvez augmenter le nombre de vos utilisateurs dès maintenant " +"pour anticiper les invitations. Au-delà de 25 éditeurs dans vos équipes, un " +"forfait de 175 $/mois vous sera facturé." + +#: src/app/main/ui/workspace/sidebar/versions.cljs:58 +#, markdown +msgid "subscription.workspace.versions.warning.subtext-owner" +msgstr "" +"Si vous souhaitez augmenter cette limite, [mettez votre abonnement à " +"niveau|target:self](%s)" + +#: src/app/main/ui/viewer/header.cljs:187 +msgid "viewer.header.edit-in-workspace" +msgstr "Modifier dans l'espace de travail" + +#: src/app/main/ui/workspace/sidebar/debug.cljs:38 +msgid "workspace.debug.title" +msgstr "Outils de débogage" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:449 +msgid "workspace.layout-item.fit-content-horizontal" +msgstr "Adapter le contenu (horizontalement)" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:477 +msgid "workspace.layout-item.fit-content-vertical" +msgstr "Adapter le contenu (verticalement)" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:567 +msgid "workspace.options.component.variant.duplicated.copy.title" +msgstr "" +"Ce composant comporte des variantes en conflit. Assurez-vous que chaque " +"variante possède une collection de valeurs de propriétés unique." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:267 +msgid "workspace.options.component.variant.duplicated.single.all" +msgstr "" +"Ces variantes possèdent des propriétés et des valeurs identiques. Ajustez " +"les valeurs afin de pouvoir les extraire." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:557 +msgid "workspace.options.component.variant.malformed.copy" +msgstr "" +"Ce composant comporte des variantes dont le nom n'est pas valide. Assurez-" +"vous que chaque variante respecte la structure appropriée." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:91 +msgid "workspace.options.component.variants-help-modal.outro" +msgstr "" +"Si vous modifiez l'un de ces éléments (par exemple, en renommant ou en " +"regroupant un calque), la connexion est rompue. Si vous annulez la " +"modification, la connexion est rétablie." + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:129 +msgid "workspace.tokens.font-size-value-enter" +msgstr "Taille de police ou {alias}" + +#: src/app/main/data/workspace/tokens/application.cljs:325 +msgid "workspace.tokens.font-variant-not-found" +msgstr "" +"Erreur lors de la définition de la graisse/du style de la police. Ce style " +"de police n'existe pas dans la police active" + +#: src/app/main/data/workspace/tokens/errors.cljs:93 +msgid "workspace.tokens.invalid-font-family-token-value" +msgstr "" +"Valeur de token non valide : vous ne pouvez référencer qu'un token de " +"famille de polices" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:153 +msgid "workspace.tokens.letter-spacing-value-enter-composite" +msgstr "Interlettrage ou {alias}" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs +#, unused +msgid "workspace.tokens.no-remap-needed" +msgstr "" +"Ce token n'est pas utilisé actuellement dans votre conception. Aucun " +"remappage n'est donc nécessaire." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:95 +msgid "workspace.tokens.not-remap" +msgstr "Ne pas remapper" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:178 +msgid "workspace.tokens.reference-composite" +msgstr "Entrer un alias de typographie pour un token" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:204 +msgid "workspace.tokens.reference-composite-shadow" +msgstr "Entrer un alias d'ombre pour un token" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:99 +msgid "workspace.tokens.remap" +msgstr "Remapper des tokens" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:86 +msgid "workspace.tokens.remap-token-references-title" +msgstr "Remapper tous les tokens qui utilisent «  %s  » dans « %s » ?" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:88 +msgid "workspace.tokens.remap-warning-effects" +msgstr "" +"Tous les calques et les références utilisant l'ancien nom du token vont être " +"modifiés." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:92 +#, unused +msgid "workspace.tokens.remapping-in-progress" +msgstr "Remappage des références du token..." + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs +#, unused +msgid "workspace.tokens.warning-name-change" +msgstr "Si vous renommez ce token, toute référence à son ancien nom sera rompue" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:261 +msgid "labels.switch" +msgstr "Changer" + +#: src/app/main/ui/dashboard/subscription.cljs:84 +msgid "subscription.dashboard.power-up.professional.bottom-button" +msgstr "Passez à un forfait supérieur !" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:465 +msgid "workspace.layout-item.fix-height" +msgstr "Corriger la hauteur" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:439 +msgid "workspace.layout-item.fix-width" +msgstr "Corriger la largeur" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:570 +msgid "workspace.options.component.variant.duplicated.copy.locate" +msgstr "Chercher les variantes en conflit" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:616 +msgid "workspace.options.interaction-animation-direction-down" +msgstr "Vers le bas" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:593 +msgid "workspace.options.interaction-animation-direction-in" +msgstr "À l'intérieur" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:596 +msgid "workspace.options.interaction-animation-direction-out" +msgstr "À l'extérieur" + +#: src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs:265 +msgid "workspace.options.more-token-colors" +msgstr "Plus de tokens de couleur" + +#: src/app/main/data/workspace/tokens/errors.cljs:89 +msgid "workspace.tokens.invalid-font-weight-token-value" +msgstr "" +"Valeur de graisse de la police non valide : utilisez des valeurs numériques " +"(100 à 950) ou des noms standard (thin, light, regular, bold, etc.) " +"éventuellement suivi de la mention « Italic »" diff --git a/frontend/translations/fr_CA.po b/frontend/translations/fr_CA.po index c71fb8dff0..27a5e058a3 100644 --- a/frontend/translations/fr_CA.po +++ b/frontend/translations/fr_CA.po @@ -1,15 +1,15 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-02-16 08:15+0000\n" +"PO-Revision-Date: 2026-02-28 00:09+0000\n" "Last-Translator: Alexis Morin \n" -"Language-Team: French (Canada) " -"\n" +"Language-Team: French (Canada) \n" "Language: fr_CA\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n > 1;\n" -"X-Generator: Weblate 5.16-dev\n" +"X-Generator: Weblate 5.16.1-dev\n" #: src/app/main/ui/auth/register.cljs:215, src/app/main/ui/static.cljs:158, src/app/main/ui/viewer/login.cljs:100 msgid "auth.already-have-account" @@ -4663,7 +4663,7 @@ msgstr "Basculer les calques" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:190 msgid "shortcuts.toggle-layout-flex" -msgstr "Ajouter / retirer mise en page flex" +msgstr "Ajouter / retirer disposition flex" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:191 msgid "shortcuts.toggle-layout-grid" @@ -5025,11 +5025,11 @@ msgid "subscription.settings.success.dialog.thanks" msgstr "Merci d'avoir choisi le forfait Penpot %s!" #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "Profite bien de ton forfait!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "Tu es %s!" #: src/app/main/ui/settings/subscription.cljs:526 @@ -5458,3 +5458,3381 @@ msgstr "Tokens de couleur" #: src/app/main/ui/workspace/colorpicker.cljs:434 msgid "workspace.colorpicker.get-color" msgstr "Pipette de couleur" + +#: src/app/main/ui/static.cljs:314 +msgid "errors.webgl-context-lost.main-message" +msgstr "Oups! Le contexte du canevas a été perdu" + +#: src/app/main/ui/dashboard/sidebar.cljs:347 +msgid "dashboard.create-new-org" +msgstr "Crée une nouvelle organisation" + +#: src/app/main/errors.cljs:105 +msgid "errors.unexpected-exception" +msgstr "Erreur inattendue : %s" + +#: src/app/main/ui/static.cljs:315 +msgid "errors.webgl-context-lost.desc-message" +msgstr "WebGL a arrêté de fonctionner. Recharger la page pour le réinitialiser" + +#: src/app/main/ui/static.cljs:318 +msgid "labels.reload-page" +msgstr "Recharger la page" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:506 +msgid "workspace.component.swap.loop-error" +msgstr "Les composants ne peuvent pas être imbriqués dans eux-mêmes." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:505 +msgid "workspace.component.switch.loop-error-multi" +msgstr "" +"Certaines copies n'ont pu être interchangées. Les composants ne peuvent pas " +"être imbriqués dans eux-mêmes." + +#: src/app/main/ui/workspace/context_menu.cljs:796 +msgid "workspace.context-menu.grid-cells.area" +msgstr "Créer une zone" + +#: src/app/main/ui/workspace/context_menu.cljs:799 +msgid "workspace.context-menu.grid-cells.create-board" +msgstr "Créer un tableau" + +#: src/app/main/ui/workspace/context_menu.cljs:791 +msgid "workspace.context-menu.grid-cells.merge" +msgstr "Fusionner les cellules" + +#: src/app/main/ui/workspace/context_menu.cljs:754 +msgid "workspace.context-menu.grid-track.column.add-after" +msgstr "Ajouter 1 colonne à droite" + +#: src/app/main/ui/workspace/context_menu.cljs:753 +msgid "workspace.context-menu.grid-track.column.add-before" +msgstr "Ajouter 1 colonne à gauche" + +#, unused +msgid "workspace.focus.selection" +msgstr "Sélection" + +#: src/app/main/ui/workspace/context_menu.cljs:755 +msgid "workspace.context-menu.grid-track.column.delete" +msgstr "Supprimer la colonne" + +#: src/app/main/ui/workspace/context_menu.cljs:756 +msgid "workspace.context-menu.grid-track.column.delete-shapes" +msgstr "Supprimer la colonne et les formes" + +#: src/app/main/ui/workspace/context_menu.cljs:752 +msgid "workspace.context-menu.grid-track.column.duplicate" +msgstr "Dupliquer la colonne" + +#: src/app/main/ui/workspace/context_menu.cljs:761 +msgid "workspace.context-menu.grid-track.row.add-after" +msgstr "Ajouter 1 colonne dessous" + +#: src/app/main/ui/workspace/context_menu.cljs:760 +msgid "workspace.context-menu.grid-track.row.add-before" +msgstr "Ajouter 1 colonne dessus" + +#: src/app/main/ui/workspace/context_menu.cljs:762 +msgid "workspace.context-menu.grid-track.row.delete" +msgstr "Supprimer la rangée" + +#: src/app/main/ui/workspace/context_menu.cljs:763 +msgid "workspace.context-menu.grid-track.row.delete-shapes" +msgstr "Supprimer la rangée et les formes" + +#: src/app/main/ui/workspace/context_menu.cljs:759 +msgid "workspace.context-menu.grid-track.row.duplicate" +msgstr "Dupliquer la rangée" + +#: src/app/main/ui/workspace/sidebar/debug.cljs:38 +msgid "workspace.debug.title" +msgstr "Outils de débogage" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:512 +msgid "workspace.focus.focus-mode" +msgstr "Mode focus" + +#: src/app/main/ui/workspace/context_menu.cljs:396, src/app/main/ui/workspace/context_menu.cljs:711 +msgid "workspace.focus.focus-off" +msgstr "Désactiver mode focus" + +#: src/app/main/ui/workspace/context_menu.cljs:395 +msgid "workspace.focus.focus-on" +msgstr "Activer mode focus" + +#: src/app/util/color.cljs:34 +msgid "workspace.gradients.linear" +msgstr "Dégradé linéaire" + +#: src/app/util/color.cljs:35 +msgid "workspace.gradients.radial" +msgstr "Dégradé radial" + +#: src/app/main/ui/workspace/main_menu.cljs:274 +msgid "workspace.header.menu.disable-dynamic-alignment" +msgstr "Désactiver l'alignement dynamique" + +#: src/app/main/ui/workspace/main_menu.cljs:228 +msgid "workspace.header.menu.disable-scale-content" +msgstr "Désactiver la mise à l'échelle proportionnelle" + +#: src/app/main/ui/workspace/header.cljs +#, unused +msgid "workspace.header.menu.disable-scale-text" +msgstr "Désactiver la mise à l'échelle du texte" + +#: src/app/main/ui/workspace/main_menu.cljs:259 +msgid "workspace.header.menu.disable-snap-guides" +msgstr "Désactiver l'accrochage aux guides" + +#: src/app/main/ui/workspace/main_menu.cljs:289 +msgid "workspace.header.menu.disable-snap-pixel-grid" +msgstr "Désactiver l'accrochage aux pixels" + +#: src/app/main/ui/workspace/main_menu.cljs:243 +msgid "workspace.header.menu.disable-snap-ruler-guides" +msgstr "Désactiver l'accrochage aux guides règles" + +#: src/app/main/ui/workspace/main_menu.cljs:275 +msgid "workspace.header.menu.enable-dynamic-alignment" +msgstr "Activer l'alignement dynamique" + +#: src/app/main/ui/workspace/main_menu.cljs:229 +msgid "workspace.header.menu.enable-scale-content" +msgstr "Activer la mise à l'échelle proportionnelle" + +#: src/app/main/ui/workspace/header.cljs +#, unused +msgid "workspace.header.menu.enable-scale-text" +msgstr "Activer la mise à l'échelle du texte" + +#: src/app/main/ui/workspace/main_menu.cljs:260 +msgid "workspace.header.menu.enable-snap-guides" +msgstr "Accrochage aux guides" + +#: src/app/main/ui/workspace/main_menu.cljs:290 +msgid "workspace.header.menu.enable-snap-pixel-grid" +msgstr "Activer l'accrochage aux pixels" + +#: src/app/main/ui/workspace/main_menu.cljs:244 +msgid "workspace.header.menu.enable-snap-ruler-guides" +msgstr "Accrochage aux règles guides" + +#: src/app/main/ui/workspace/main_menu.cljs:422 +msgid "workspace.header.menu.hide-artboard-names" +msgstr "Masquer le nom des tableaux" + +#: src/app/main/ui/workspace/main_menu.cljs:376 +msgid "workspace.header.menu.hide-guides" +msgstr "Masquer les guides" + +#: src/app/main/ui/workspace/main_menu.cljs:393 +msgid "workspace.header.menu.hide-palette" +msgstr "Masquer la palette de couleurs" + +#: src/app/main/ui/workspace/main_menu.cljs:434 +msgid "workspace.header.menu.hide-pixel-grid" +msgstr "Masquer la grille pixel" + +#: src/app/main/ui/workspace/main_menu.cljs:360 +msgid "workspace.header.menu.hide-rules" +msgstr "Masquer les règles" + +#: src/app/main/ui/workspace/main_menu.cljs:407 +msgid "workspace.header.menu.hide-textpalette" +msgstr "Masquer la palette de polices" + +#: src/app/main/ui/workspace/main_menu.cljs:884 +msgid "workspace.header.menu.option.edit" +msgstr "Éditer" + +#: src/app/main/ui/workspace/main_menu.cljs:873 +msgid "workspace.header.menu.option.file" +msgstr "Fichier" + +#: src/app/main/ui/workspace/main_menu.cljs:930 +msgid "workspace.header.menu.option.help-info" +msgstr "Aide et info" + +#: src/app/main/ui/workspace/main_menu.cljs:916 +#, unused +msgid "workspace.header.menu.option.power-up" +msgstr "Bonifier ton forfait" + +#: src/app/main/ui/workspace/main_menu.cljs:906 +msgid "workspace.header.menu.option.preferences" +msgstr "Préférences" + +#: src/app/main/ui/workspace/main_menu.cljs:895 +msgid "workspace.header.menu.option.view" +msgstr "Affichage" + +#: src/app/main/ui/workspace/main_menu.cljs:506 +msgid "workspace.header.menu.redo" +msgstr "Rétablir" + +#: src/app/main/ui/workspace/main_menu.cljs:477 +msgid "workspace.header.menu.select-all" +msgstr "Tout sélectionner" + +#: src/app/main/ui/workspace/main_menu.cljs:423 +msgid "workspace.header.menu.show-artboard-names" +msgstr "Afficher le nom des tableaux" + +#: src/app/main/ui/workspace/main_menu.cljs:377 +msgid "workspace.header.menu.show-guides" +msgstr "Afficher les guides" + +#: src/app/main/ui/workspace/main_menu.cljs:394 +msgid "workspace.header.menu.show-palette" +msgstr "Afficher la palette de couleurs" + +#: src/app/main/ui/workspace/main_menu.cljs:435 +msgid "workspace.header.menu.show-pixel-grid" +msgstr "Afficher la grille pixel" + +#: src/app/main/ui/workspace/main_menu.cljs:361 +msgid "workspace.header.menu.show-rules" +msgstr "Afficher les règles" + +#: src/app/main/ui/workspace/main_menu.cljs:408 +msgid "workspace.header.menu.show-textpalette" +msgstr "Afficher la palette des polices" + +#: src/app/main/ui/workspace/main_menu.cljs:316 +msgid "workspace.header.menu.toggle-dark-theme" +msgstr "Basculer au mode sombre" + +#: src/app/main/ui/workspace/main_menu.cljs:314, src/app/main/ui/workspace/main_menu.cljs:317 +msgid "workspace.header.menu.toggle-light-theme" +msgstr "Basculer au mode clair" + +#: src/app/main/ui/workspace/main_menu.cljs:315 +msgid "workspace.header.menu.toggle-system-theme" +msgstr "Basculer au thème du système" + +#: src/app/main/ui/workspace/main_menu.cljs:492 +msgid "workspace.header.menu.undo" +msgstr "Défaire" + +#: src/app/main/ui/viewer/header.cljs:93, src/app/main/ui/workspace/right_header.cljs:92 +msgid "workspace.header.reset-zoom" +msgstr "Réinitialiser" + +#: src/app/main/ui/workspace/left_header.cljs:128 +msgid "workspace.header.save-error" +msgstr "Erreur de sauvegarde" + +#: src/app/main/ui/workspace/left_header.cljs:127 +msgid "workspace.header.saved" +msgstr "Sauvegardé" + +#: src/app/main/ui/workspace/left_header.cljs:125, src/app/main/ui/workspace/left_header.cljs:126 +msgid "workspace.header.saving" +msgstr "Sauvegarde en cours" + +#: src/app/main/ui/workspace/right_header.cljs:232 +msgid "workspace.header.share" +msgstr "Partager" + +#: src/app/main/ui/workspace/right_header.cljs:48, src/app/main/ui/workspace/right_header.cljs:53 +#, unused +msgid "workspace.header.unsaved" +msgstr "Changements non sauvegardés" + +#: src/app/main/ui/workspace/right_header.cljs:237 +msgid "workspace.header.viewer" +msgstr "Mode visionnement (%s)" + +#: src/app/main/ui/viewer/header.cljs:74, src/app/main/ui/workspace/right_header.cljs:74 +msgid "workspace.header.zoom" +msgstr "Zoom" + +#: src/app/main/ui/viewer/header.cljs:104 +msgid "workspace.header.zoom-fill" +msgstr "Remplir l'écran" + +#: src/app/main/ui/viewer/header.cljs:97 +msgid "workspace.header.zoom-fit" +msgstr "Ajuster aux dimensions" + +#: src/app/main/ui/workspace/right_header.cljs:96 +msgid "workspace.header.zoom-fit-all" +msgstr "Zoom cadrant tout" + +#: src/app/main/ui/viewer/header.cljs:111 +msgid "workspace.header.zoom-full-screen" +msgstr "Plein écran" + +#: src/app/main/ui/workspace/right_header.cljs:104 +msgid "workspace.header.zoom-selected" +msgstr "Zoom à la sélection" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:422 +msgid "workspace.layout-grid.editor.margin.expand" +msgstr "Afficher la marge des 4 côtés" + +#: src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs:275, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:859 +msgid "workspace.layout-grid.editor.options.edit-grid" +msgstr "Éditer la grille" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1539 +msgid "workspace.layout-grid.editor.options.exit" +msgstr "Quitter" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:584, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:593, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:599 +msgid "workspace.layout-grid.editor.padding.bottom" +msgstr "Marge interne du bas" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:669 +msgid "workspace.layout-grid.editor.padding.expand" +msgstr "Afficher la marge interne des 4 côtés" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:416, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:427, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:433 +msgid "workspace.layout-grid.editor.padding.horizontal" +msgstr "Marge interne horizontale" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:618, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:627, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:633 +msgid "workspace.layout-grid.editor.padding.left" +msgstr "Marge interne gauche" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:551, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:560, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:566 +msgid "workspace.layout-grid.editor.padding.right" +msgstr "Marge interne droite" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:517, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:526, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:532 +msgid "workspace.layout-grid.editor.padding.top" +msgstr "Marge interne du haut" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:380, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:391, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:397 +msgid "workspace.layout-grid.editor.padding.vertical" +msgstr "Marge interne verticale" + +#: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs:62 +msgid "workspace.layout-grid.editor.title" +msgstr "Édition de la grille" + +#: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs:70 +msgid "workspace.layout-grid.editor.top-bar.done" +msgstr "Terminé" + +#: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs:66 +msgid "workspace.layout-grid.editor.top-bar.locate" +msgstr "Situer" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1565 +msgid "workspace.layout-grid.editor.top-bar.locate.tooltip" +msgstr "Situer la mise en page grille" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:449 +msgid "workspace.layout-item.fit-content-horizontal" +msgstr "S'ajuster au contenu (horizontal)" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:477 +msgid "workspace.layout-item.fit-content-vertical" +msgstr "S'ajuster au contenu (vertical)" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:465 +msgid "workspace.layout-item.fix-height" +msgstr "Hauteur fixe" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:439 +msgid "workspace.layout-item.fix-width" +msgstr "Largeur fixe" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:471 +msgid "workspace.layout-item.height-100" +msgstr "Hauteur 100%" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:444 +msgid "workspace.layout-item.width-100" +msgstr "Largeur 100%" + +#: src/app/main/ui/workspace/libraries.cljs +#, unused +msgid "workspace.libraries.add" +msgstr "Ajouter" + +#: src/app/main/ui/workspace/libraries.cljs:100, src/app/main/ui/workspace/libraries.cljs:126 +msgid "workspace.libraries.colors" +msgid_plural "workspace.libraries.colors" +msgstr[0] "1 couleur" +msgstr[1] "%s couleurs" + +#: src/app/main/ui/workspace/color_palette.cljs:147 +msgid "workspace.libraries.colors.empty-palette" +msgstr "Aucun style de couleur dans la bibliothèque" + +#: src/app/main/ui/workspace/text_palette.cljs:161 +msgid "workspace.libraries.colors.empty-typography-palette" +msgstr "Aucun style de typographie dans la bibliothèque" + +#: src/app/main/ui/workspace/color_palette_ctx_menu.cljs:88, src/app/main/ui/workspace/colorpicker/libraries.cljs:48, src/app/main/ui/workspace/text_palette_ctx_menu.cljs:49 +msgid "workspace.libraries.colors.file-library" +msgstr "Bibliothèque du fichier" + +#: src/app/main/ui/workspace/colorpicker.cljs +#, unused +msgid "workspace.libraries.colors.hsv" +msgstr "HSV" + +#: src/app/main/ui/workspace/color_palette_ctx_menu.cljs:111, src/app/main/ui/workspace/colorpicker/libraries.cljs:47 +msgid "workspace.libraries.colors.recent-colors" +msgstr "Couleurs récentes" + +#: src/app/main/ui/workspace/colorpicker.cljs +#, unused +msgid "workspace.libraries.colors.rgb-complementary" +msgstr "RVB complémentaire" + +#: src/app/main/ui/workspace/colorpicker.cljs:355 +msgid "workspace.libraries.colors.rgba" +msgstr "RVBA" + +#: src/app/main/ui/workspace/colorpicker.cljs:555 +msgid "workspace.libraries.colors.save-color" +msgstr "Enregistrer le style de couleur" + +#: src/app/main/ui/workspace/libraries.cljs:94, src/app/main/ui/workspace/libraries.cljs:118 +msgid "workspace.libraries.components" +msgid_plural "workspace.libraries.components" +msgstr[0] "1 composant" +msgstr[1] "%s composants" + +#: src/app/main/ui/workspace/libraries.cljs:338 +msgid "workspace.libraries.connected-to" +msgstr "Connecté à" + +#: src/app/main/ui/workspace/libraries.cljs:392 +msgid "workspace.libraries.empty.add-some" +msgstr "Ou ajoute celles-ci pour tester :" + +#: src/app/main/ui/workspace/libraries.cljs:386 +msgid "workspace.libraries.empty.no-libraries" +msgstr "Aucune bibliothèque partagée dans ton équipe, tu peux chercher" + +#: src/app/main/ui/workspace/libraries.cljs:390 +msgid "workspace.libraries.empty.some-templates" +msgstr "des modèles ici" + +#: src/app/main/ui/workspace/libraries.cljs:313 +msgid "workspace.libraries.file-library" +msgstr "Bibliothèque du fichier" + +#: src/app/main/ui/workspace/libraries.cljs:97, src/app/main/ui/workspace/libraries.cljs:122 +msgid "workspace.libraries.graphics" +msgid_plural "workspace.libraries.graphics" +msgstr[0] "1 graphisme" +msgstr[1] "%s graphismes" + +#: src/app/main/ui/workspace/libraries.cljs:307 +msgid "workspace.libraries.in-this-file" +msgstr "BIBLIOTHÈQUES DANS CE FICHIER" + +#: src/app/main/ui/workspace/libraries.cljs:628, src/app/main/ui/workspace/libraries.cljs:648 +msgid "workspace.libraries.libraries" +msgstr "BIBLIOTHÈQUES" + +#: src/app/main/ui/workspace/libraries.cljs +#, unused +msgid "workspace.libraries.library" +msgstr "BIBLIOTHÈQUE" + +#: src/app/main/ui/workspace/libraries.cljs:487 +msgid "workspace.libraries.library-updates" +msgstr "MISES À JOUR DE BIBLIOTHÈQUES" + +#: src/app/main/ui/workspace/libraries.cljs:381 +msgid "workspace.libraries.loading" +msgstr "Chargement…" + +#: src/app/main/ui/workspace/libraries.cljs:387 +#, unused +msgid "workspace.libraries.more-templates" +msgstr "Tu peux chercher " + +#: src/app/main/ui/workspace/libraries.cljs:485 +msgid "workspace.libraries.no-libraries-need-sync" +msgstr "Aucune bibliothèque partagée ne requiert de mise à jour" + +#: src/app/main/ui/workspace/libraries.cljs:399 +msgid "workspace.libraries.no-matches-for" +msgstr "Aucun résultat trouvé pour « %s »" + +#: src/app/main/ui/workspace/libraries.cljs:356 +msgid "workspace.libraries.search-shared-libraries" +msgstr "Recherche des bibliothèques partagées" + +#: src/app/main/ui/workspace/libraries.cljs:352 +msgid "workspace.libraries.shared-libraries" +msgstr "BIBLIOTHÈQUES PARTAGÉES" + +#: src/app/main/ui/workspace/libraries.cljs:372 +msgid "workspace.libraries.shared-library-btn" +msgstr "Connecter une bibliothèque" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:332 +msgid "workspace.libraries.text.multiple-typography" +msgstr "Multiples typographies" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:335 +msgid "workspace.libraries.text.multiple-typography-tooltip" +msgstr "Délier toutes les typographies" + +#: src/app/main/ui/workspace/libraries.cljs:103, src/app/main/ui/workspace/libraries.cljs:130 +msgid "workspace.libraries.typography" +msgid_plural "workspace.libraries.typography" +msgstr[0] "1 typographie" +msgstr[1] "%s typographies" + +#: src/app/main/ui/workspace/libraries.cljs:343 +msgid "workspace.libraries.unlink-library-btn" +msgstr "Déconnecter la bibliothèque" + +#: src/app/main/ui/workspace/libraries.cljs:507 +msgid "workspace.libraries.update" +msgstr "Mettre à jour" + +#: src/app/main/ui/workspace/libraries.cljs:583 +msgid "workspace.libraries.update.see-all-changes" +msgstr "voir tous les changements" + +#: src/app/main/ui/workspace/libraries.cljs:630 +msgid "workspace.libraries.updates" +msgstr "MISES À JOUR" + +#: src/app/main/ui/ds/notifications/shared/notification_pill.cljs:67, src/app/main/ui/ds/notifications/shared/notification_pill.cljs:72 +msgid "workspace.notification-pill.detail" +msgstr "Détails" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:784 +msgid "workspace.options.add-interaction" +msgstr "Cliquer le bouton + pour ajouter des interactions." + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:96 +msgid "workspace.options.blur-options.add-blur" +msgstr "Ajouter un flou" + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:119 +msgid "workspace.options.blur-options.remove-blur" +msgstr "Supprimer le flou" + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:92, src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:112 +msgid "workspace.options.blur-options.title" +msgstr "Flouter" + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:91 +msgid "workspace.options.blur-options.title.group" +msgstr "Flou de groupe" + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:90 +msgid "workspace.options.blur-options.title.multiple" +msgstr "Flou de la sélection" + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:115 +msgid "workspace.options.blur-options.toggle-blur" +msgstr "Basculer le flou" + +#: src/app/main/ui/workspace/sidebar/options/page.cljs:42, src/app/main/ui/workspace/sidebar/options/page.cljs:50 +msgid "workspace.options.canvas-background" +msgstr "Arrière-plan du canevas" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:567 +msgid "workspace.options.clip-content" +msgstr "Rogner le contenu" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1027, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1033, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1282 +msgid "workspace.options.component" +msgstr "Composant" + +#: src/app/main/ui/inspect/annotation.cljs:19, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:194 +msgid "workspace.options.component.annotation" +msgstr "Annotation" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1041 +msgid "workspace.options.component.copy" +msgstr "Copier" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:187 +msgid "workspace.options.component.create-annotation" +msgstr "Créer une annotation" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:186 +msgid "workspace.options.component.edit-annotation" +msgstr "Éditer l'annotation" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1040, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1286 +msgid "workspace.options.component.main" +msgstr "Principal" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:781 +msgid "workspace.options.component.swap" +msgstr "Permuter composant" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:820 +msgid "workspace.options.component.swap.empty" +msgstr "Aucun atout dans cette bibliothèque" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1066 +msgid "workspace.options.component.unlinked" +msgstr "Non-lié" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:570 +msgid "workspace.options.component.variant.duplicated.copy.locate" +msgstr "Localiser les variants en conflit" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:567 +msgid "workspace.options.component.variant.duplicated.copy.title" +msgstr "" +"Ce composant a des variants en conflit. Assure-toi que chaque variant " +"possède des valeurs de propriétés uniques." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1341 +msgid "workspace.options.component.variant.duplicated.group.locate" +msgstr "Localiser les doublons" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1338 +msgid "workspace.options.component.variant.duplicated.group.title" +msgstr "Certains variants ont des propriétés et valeurs identiques" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:267 +msgid "workspace.options.component.variant.duplicated.single.all" +msgstr "" +"Ces variants ont des propriétés et valeurs identiques. Ajuster les valeurs " +"pour qu'elles puissent être trouvées." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:264 +msgid "workspace.options.component.variant.duplicated.single.one" +msgstr "" +"Ce variant a des propriétés et valeurs identiques à un autre variant. " +"Ajuster les valeurs pour qu'elles puissent être trouvées." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:270 +msgid "workspace.options.component.variant.duplicated.single.some" +msgstr "" +"Certains variants ont des propriétés et valeurs identiques. Ajuster les " +"valeurs pour qu'elles puissent être trouvées." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:557 +msgid "workspace.options.component.variant.malformed.copy" +msgstr "" +"Ce composant a des variants avec des noms invalides. Chaque variant doit " +"suivre la structure correcte." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1331 +msgid "workspace.options.component.variant.malformed.group.locate" +msgstr "Localiser les variants invalides" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1328 +msgid "workspace.options.component.variant.malformed.group.title" +msgstr "Certains variants ont des noms invalides" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:560 +msgid "workspace.options.component.variant.malformed.locate" +msgstr "Localiser les variants invalides" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:251 +msgid "workspace.options.component.variant.malformed.single.all" +msgstr "Ces variants ont des noms invalides." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:248 +msgid "workspace.options.component.variant.malformed.single.one" +msgstr "Ce variant a un nom invalide." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:254 +msgid "workspace.options.component.variant.malformed.single.some" +msgstr "Certains variants ont des noms invalides." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:433 +msgid "workspace.options.component.variant.malformed.structure.example" +msgstr "[propriété]=[valeur], [propriété]=[valeur]" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:431 +msgid "workspace.options.component.variant.malformed.structure.title" +msgstr "Utilise la structure suivante :" + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:54 +msgid "workspace.options.component.variants-help-modal.intro" +msgstr "" +"Pour maintenir les changements entre les variants, Penpot connecte les " +"calques qui :" + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:91 +msgid "workspace.options.component.variants-help-modal.outro" +msgstr "" +"Chaque changement (ex. renommer ou grouper un calque) brise la connection. " +"Défaire le changement va la restaurer." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:67 +msgid "workspace.options.component.variants-help-modal.rule1" +msgstr "Ont le même nom." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:76 +msgid "workspace.options.component.variants-help-modal.rule2" +msgstr "Sont du même type." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:77 +msgid "workspace.options.component.variants-help-modal.rule2.detail" +msgstr "" +"Les rectangles, ellipses, chemins et opérations booléennes comptent comme " +"étant du même type." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:87 +msgid "workspace.options.component.variants-help-modal.rule3" +msgstr "Ont le même niveau de hiérarchie." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:88 +msgid "workspace.options.component.variants-help-modal.rule3.detail" +msgstr "Les groupes, tableaux et mises en page sont considérés équivalents." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1045, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1289, src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:47 +msgid "workspace.options.component.variants-help-modal.title" +msgstr "Comment les variants demeurent connectés" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:164 +msgid "workspace.options.constraints" +msgstr "Contraintes" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:151 +msgid "workspace.options.constraints.bottom" +msgstr "Bas" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:142, src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:153 +msgid "workspace.options.constraints.center" +msgstr "Centre" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:224 +msgid "workspace.options.constraints.fix-when-scrolling" +msgstr "Figer au défilement" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:139 +msgid "workspace.options.constraints.left" +msgstr "Gauche" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:141 +msgid "workspace.options.constraints.leftright" +msgstr "Gauche et droite" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:140 +msgid "workspace.options.constraints.right" +msgstr "Droite" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:143, src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:154 +msgid "workspace.options.constraints.scale" +msgstr "Redimensionner" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:150 +msgid "workspace.options.constraints.top" +msgstr "Haut" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:152 +msgid "workspace.options.constraints.topbottom" +msgstr "Haut et bas" + +#: src/app/main/ui/workspace/sidebar/options.cljs:197 +msgid "workspace.options.design" +msgstr "Design" + +#: src/app/main/ui/inspect/exports.cljs:140 +msgid "workspace.options.export" +msgstr "Exporter" + +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs, src/app/main/ui/inspect/exports.cljs +#, unused +msgid "workspace.options.export-multiple" +msgstr "Exporter la sélection" + +#: src/app/main/ui/inspect/exports.cljs:196, src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:273 +msgid "workspace.options.export-object" +msgid_plural "workspace.options.export-object" +msgstr[0] "Exporter 1 élément" +msgstr[1] "Exporter %s éléments" + +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:214 +msgid "workspace.options.export.add-export" +msgstr "Ajouter une exportation" + +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:226, src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:261 +msgid "workspace.options.export.remove-export" +msgstr "Supprimer l'exportation" + +#: src/app/main/ui/inspect/exports.cljs:179, src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:255 +msgid "workspace.options.export.suffix" +msgstr "Suffixe" + +#: src/app/main/ui/exports/assets.cljs:250 +msgid "workspace.options.exporting-complete" +msgstr "Exportation complétée" + +#: src/app/main/ui/exports/assets.cljs:171, src/app/main/ui/exports/assets.cljs:251, src/app/main/ui/inspect/exports.cljs:195, src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:272 +msgid "workspace.options.exporting-object" +msgstr "En cours d'exportation…" + +#: src/app/main/ui/exports/assets.cljs:249 +msgid "workspace.options.exporting-object-error" +msgstr "L'exportation a échoué" + +#: src/app/main/ui/exports/assets.cljs:252 +msgid "workspace.options.exporting-object-slow" +msgstr "Exportation inopinément lente" + +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:107, src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:236 +msgid "workspace.options.fill" +msgstr "Remplissage" + +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:208 +msgid "workspace.options.fill.add-fill" +msgstr "Ajouter un remplissage" + +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:223 +msgid "workspace.options.fill.remove-fill" +msgstr "Supprimer le remplissage" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:405 +msgid "workspace.options.fit-content" +msgstr "Redimensionner le tableau au contenu" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:704 +msgid "workspace.options.flows.add-flow-start" +msgstr "Ajouter un début de parcours" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:700 +msgid "workspace.options.flows.flow" +msgstr "Parcours" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:164 +msgid "workspace.options.flows.flow-start" +msgstr "Démarrer le parcours" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:672 +msgid "workspace.options.flows.flow-starts" +msgstr "Départs de parcours" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:155 +#, unused +msgid "workspace.options.flows.remove-flow" +msgstr "Supprimer le parcours" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:32 +msgid "workspace.options.grid.auto" +msgstr "Auto" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:163 +msgid "workspace.options.grid.column" +msgstr "Colonnes" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs +#, unused +msgid "workspace.options.grid.grid-title" +msgstr "Grille" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:204, src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:247 +msgid "workspace.options.grid.params.color" +msgstr "Couleur" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs +#, unused +msgid "workspace.options.grid.params.columns" +msgstr "Colonnes" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:270 +msgid "workspace.options.grid.params.gutter" +msgstr "Gouttière" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:257 +msgid "workspace.options.grid.params.height" +msgstr "Hauteur" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:281 +msgid "workspace.options.grid.params.margin" +msgstr "Marge" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs +#, unused +msgid "workspace.options.grid.params.rows" +msgstr "Rangées" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:226, src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:302 +msgid "workspace.options.grid.params.set-default" +msgstr "Définir par défaut" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs +#, unused +msgid "workspace.options.grid.params.size" +msgstr "Taille" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs +#, unused +msgid "workspace.options.grid.params.type" +msgstr "Type" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:241 +msgid "workspace.options.grid.params.type.bottom" +msgstr "Bas" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:239 +msgid "workspace.options.grid.params.type.center" +msgstr "Centre" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:238 +msgid "workspace.options.grid.params.type.left" +msgstr "Gauche" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:242 +msgid "workspace.options.grid.params.type.right" +msgstr "Droite" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:235 +msgid "workspace.options.grid.params.type.stretch" +msgstr "Étirer" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:237 +msgid "workspace.options.grid.params.type.top" +msgstr "Haut" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:221, src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:300 +msgid "workspace.options.grid.params.use-default" +msgstr "Utiliser la valeur par défaut" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:258 +msgid "workspace.options.grid.params.width" +msgstr "Largeur" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:164 +msgid "workspace.options.grid.row" +msgstr "Rangées" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:162 +msgid "workspace.options.grid.square" +msgstr "Carrée" + +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:106 +msgid "workspace.options.group-fill" +msgstr "Remplissage du groupe" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:45 +msgid "workspace.options.group-stroke" +msgstr "Contour du groupe" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:332 +msgid "workspace.options.guides.add-guide" +msgstr "Ajouter un guide" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:191 +msgid "workspace.options.guides.remove-guide" +msgstr "Supprimer le guide" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:329 +msgid "workspace.options.guides.title" +msgstr "Guides" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:187 +msgid "workspace.options.guides.toggle-guide" +msgstr "Basculer le guide" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:435, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:454 +msgid "workspace.options.height" +msgstr "Hauteur" + +#: src/app/main/ui/workspace/sidebar/options.cljs:201 +msgid "workspace.options.inspect" +msgstr "Inspection" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:438 +msgid "workspace.options.interaction-action" +msgstr "Action" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:43, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:344 +msgid "workspace.options.interaction-after-delay" +msgstr "Après un délai" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:578 +msgid "workspace.options.interaction-animation" +msgstr "Animation" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:616 +msgid "workspace.options.interaction-animation-direction-down" +msgstr "Vers le bas" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:593 +msgid "workspace.options.interaction-animation-direction-in" +msgstr "Entrante" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:612 +msgid "workspace.options.interaction-animation-direction-left" +msgstr "Vers la gauche" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:596 +msgid "workspace.options.interaction-animation-direction-out" +msgstr "Sortante" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:608 +msgid "workspace.options.interaction-animation-direction-right" +msgstr "Vers la droite" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:620 +msgid "workspace.options.interaction-animation-direction-up" +msgstr "Vers le haut" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:383 +msgid "workspace.options.interaction-animation-dissolve" +msgstr "Fondu" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:382 +msgid "workspace.options.interaction-animation-none" +msgstr "Aucune" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:389 +msgid "workspace.options.interaction-animation-push" +msgstr "Pousser" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:384 +msgid "workspace.options.interaction-animation-slide" +msgstr "Glisser" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:368 +msgid "workspace.options.interaction-auto" +msgstr "automatique" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:568 +msgid "workspace.options.interaction-background" +msgstr "Ajouter un recouvrement d'arrière-plan" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:561 +msgid "workspace.options.interaction-close-outside" +msgstr "Fermer en cliquant à l'extérieur" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:349 +msgid "workspace.options.interaction-close-overlay" +msgstr "Fermer le recouvrement" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:58 +msgid "workspace.options.interaction-close-overlay-dest" +msgstr "Fermer le recouvrement : %s" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:426 +msgid "workspace.options.interaction-delay" +msgstr "Délai" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:449 +msgid "workspace.options.interaction-destination" +msgstr "Destination" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:628 +msgid "workspace.options.interaction-duration" +msgstr "Durée" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:641 +msgid "workspace.options.interaction-easing" +msgstr "Lissage de vitesse" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:393 +msgid "workspace.options.interaction-easing-ease" +msgstr "Accélération" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:394 +msgid "workspace.options.interaction-easing-ease-in" +msgstr "Accélération progressive" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:396 +msgid "workspace.options.interaction-easing-ease-in-out" +msgstr "Accélération progressive-dégressive" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:395 +msgid "workspace.options.interaction-easing-ease-out" +msgstr "Accélération dégressive" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:392 +msgid "workspace.options.interaction-easing-linear" +msgstr "Linéaire" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs +#, unused +msgid "workspace.options.interaction-in" +msgstr "Entrante" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:41, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:341 +msgid "workspace.options.interaction-mouse-enter" +msgstr "Survol de souris" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:42, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:342 +msgid "workspace.options.interaction-mouse-leave" +msgstr "Sortie de souris" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:430, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:632 +msgid "workspace.options.interaction-ms" +msgstr "ms" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:346 +msgid "workspace.options.interaction-navigate-to" +msgstr "Naviguer vers" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:52 +msgid "workspace.options.interaction-navigate-to-dest" +msgstr "Naviguer vers : %s" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:53, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:55, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:57, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:357 +msgid "workspace.options.interaction-none" +msgstr "(non défini)" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:654 +msgid "workspace.options.interaction-offset-effect" +msgstr "Effet de décalage" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:37, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:337 +msgid "workspace.options.interaction-on-click" +msgstr "Au clique" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:347 +msgid "workspace.options.interaction-open-overlay" +msgstr "Ouvrir en superposition" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:54 +msgid "workspace.options.interaction-open-overlay-dest" +msgstr "Ouvrir en superposition : %s" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:61, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:351 +msgid "workspace.options.interaction-open-url" +msgstr "Ouvrir URL" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs +#, unused +msgid "workspace.options.interaction-out" +msgstr "Sortante" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:380, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:554 +msgid "workspace.options.interaction-pos-bottom-center" +msgstr "En bas au centre" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:378, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:538 +msgid "workspace.options.interaction-pos-bottom-left" +msgstr "En bas à gauche" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:379, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:546 +msgid "workspace.options.interaction-pos-bottom-right" +msgstr "En bas à droite" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:374, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:506 +msgid "workspace.options.interaction-pos-center" +msgstr "Centre" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:373 +msgid "workspace.options.interaction-pos-manual" +msgstr "Manuelle" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:377, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:530 +msgid "workspace.options.interaction-pos-top-center" +msgstr "En haut au centre" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:375, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:514 +msgid "workspace.options.interaction-pos-top-left" +msgstr "En haut à gauche" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:376, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:522 +msgid "workspace.options.interaction-pos-top-right" +msgstr "En haut à droite" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:492 +msgid "workspace.options.interaction-position" +msgstr "Position" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:460 +msgid "workspace.options.interaction-preserve-scroll" +msgstr "Maintenir le défilement" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:60, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:350 +msgid "workspace.options.interaction-prev-screen" +msgstr "Écran précédent" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:482 +msgid "workspace.options.interaction-relative-to" +msgstr "Relatif à" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:59, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:356, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:370, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:371 +msgid "workspace.options.interaction-self" +msgstr "soi-même" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:348 +msgid "workspace.options.interaction-toggle-overlay" +msgstr "Basculer la superposition" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:56 +msgid "workspace.options.interaction-toggle-overlay-dest" +msgstr "Basculer la superposition : %s" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:415 +msgid "workspace.options.interaction-trigger" +msgstr "Déclencheur" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:469 +msgid "workspace.options.interaction-url" +msgstr "URL" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:39, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:339 +msgid "workspace.options.interaction-while-hovering" +msgstr "Pendant le survol" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:40, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:340 +msgid "workspace.options.interaction-while-pressing" +msgstr "Pendant l'appui" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:743 +msgid "workspace.options.interactions" +msgstr "Interactions" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:746 +msgid "workspace.options.interactions.add-interaction" +msgstr "Ajouter une interaction" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs +#, unused +msgid "workspace.options.interactions.remove-interaction" +msgstr "Supprimer l'interaction" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:197 +msgid "workspace.options.layer-options.blend-mode.color" +msgstr "Couleur" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:186 +msgid "workspace.options.layer-options.blend-mode.color-burn" +msgstr "Densité couleur +" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:189 +msgid "workspace.options.layer-options.blend-mode.color-dodge" +msgstr "Densité couleur -" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:184 +msgid "workspace.options.layer-options.blend-mode.darken" +msgstr "Obscurcir" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:193 +msgid "workspace.options.layer-options.blend-mode.difference" +msgstr "Différence" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:194 +msgid "workspace.options.layer-options.blend-mode.exclusion" +msgstr "Exclusion" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:192 +msgid "workspace.options.layer-options.blend-mode.hard-light" +msgstr "Lumière crue" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:195 +msgid "workspace.options.layer-options.blend-mode.hue" +msgstr "Teinte" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:187 +msgid "workspace.options.layer-options.blend-mode.lighten" +msgstr "Éclaircir" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:198 +msgid "workspace.options.layer-options.blend-mode.luminosity" +msgstr "Luminosité" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:185 +msgid "workspace.options.layer-options.blend-mode.multiply" +msgstr "Produit" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:183 +msgid "workspace.options.layer-options.blend-mode.normal" +msgstr "Normal" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:190 +msgid "workspace.options.layer-options.blend-mode.overlay" +msgstr "Incrustation" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:196 +msgid "workspace.options.layer-options.blend-mode.saturation" +msgstr "Saturation" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:188 +msgid "workspace.options.layer-options.blend-mode.screen" +msgstr "Superposition" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:191 +msgid "workspace.options.layer-options.blend-mode.soft-light" +msgstr "Lumière tamisée" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs +#, unused +msgid "workspace.options.layer-options.title" +msgstr "Calque" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs +#, unused +msgid "workspace.options.layer-options.title.group" +msgstr "Calques du groupe" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs +#, unused +msgid "workspace.options.layer-options.title.multiple" +msgstr "Calques sélectionnés" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:255, src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:261 +msgid "workspace.options.layer-options.toggle-layer" +msgstr "Basculer la visibilité du calque" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +#, unused +msgid "workspace.options.layout-item.advanced-ops" +msgstr "Options avancées" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:686, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:693 +msgid "workspace.options.layout-item.layout-item-max-h" +msgstr "Hauteur max." + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:624, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:631 +msgid "workspace.options.layout-item.layout-item-max-w" +msgstr "Largeur max." + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:655, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:663 +msgid "workspace.options.layout-item.layout-item-min-h" +msgstr "Hauteur min." + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:591, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:600 +msgid "workspace.options.layout-item.layout-item-min-w" +msgstr "Largeur min." + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +#, unused +msgid "workspace.options.layout-item.title.layout-item-max-h" +msgstr "Hauteur maximale" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +#, unused +msgid "workspace.options.layout-item.title.layout-item-max-w" +msgstr "Largeur maximale" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +#, unused +msgid "workspace.options.layout-item.title.layout-item-min-h" +msgstr "Hauteur minimale" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +#, unused +msgid "workspace.options.layout-item.title.layout-item-min-w" +msgstr "Largeur minimale" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.bottom" +msgstr "Bas" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.direction.column" +msgstr "Colonne" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.direction.column-reverse" +msgstr "Colonne inversée" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.direction.row" +msgstr "Rangée" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.direction.row-reverse" +msgstr "Rangée inversée" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.gap" +msgstr "Gouttière" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.left" +msgstr "Gauche" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +#, unused +msgid "workspace.options.layout.margin" +msgstr "Marge" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +#, unused +msgid "workspace.options.layout.margin-all" +msgstr "Tous côtés" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +#, unused +msgid "workspace.options.layout.margin-simple" +msgstr "Marge simple" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.packed" +msgstr "compacté" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.padding" +msgstr "Marge interne" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.padding-all" +msgstr "Tous côtés" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.padding-simple" +msgstr "Marge interne simple" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.right" +msgstr "Droite" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.space-around" +msgstr "espace autour" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.space-between" +msgstr "espace entre" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.top" +msgstr "Haut" + +#: src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs:241 +msgid "workspace.options.more-colors" +msgstr "Plus de couleurs" + +#: src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs:223 +msgid "workspace.options.more-lib-colors" +msgstr "Plus de couleurs de bibliothèque" + +#: src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs:265 +msgid "workspace.options.more-token-colors" +msgstr "Plus de tokens de couleur" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:229, src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:241 +msgid "workspace.options.opacity" +msgstr "Opacité" + +#: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:108, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:401 +msgid "workspace.options.orientation.horizontal" +msgstr "Horizontal" + +#: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:104, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:397 +msgid "workspace.options.orientation.vertical" +msgstr "Vertical" + +#: src/app/main/ui/workspace/sidebar/options/shapes/frame.cljs, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs +#, unused +msgid "workspace.options.position" +msgstr "Position" + +#: src/app/main/ui/workspace/sidebar/options.cljs:199 +msgid "workspace.options.prototype" +msgstr "Prototype" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:182, src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:206 +msgid "workspace.options.radius" +msgstr "Rayon" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:271, src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:323 +msgid "workspace.options.radius-bottom-left" +msgstr "En bas à gauche" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:290, src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:331 +msgid "workspace.options.radius-bottom-right" +msgstr "En bas à droite" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:234, src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:307 +msgid "workspace.options.radius-top-left" +msgstr "En haut à gauche" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:253, src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:315 +msgid "workspace.options.radius-top-right" +msgstr "En haut à droite" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:340 +msgid "workspace.options.radius.hide-all-corners" +msgstr "Réduire les rayons indépendants" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:341 +msgid "workspace.options.radius.show-single-corners" +msgstr "Afficher les rayons indépendants" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:191 +msgid "workspace.options.recent-fonts" +msgstr "Récentes" + +#: src/app/main/ui/exports/assets.cljs:298 +msgid "workspace.options.retry" +msgstr "Relancer" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:536, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:544 +msgid "workspace.options.rotation" +msgstr "Rotation" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:188 +msgid "workspace.options.search-font" +msgstr "Recherche de police" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:786 +msgid "workspace.options.select-a-shape" +msgstr "" +"Sélectionner une forme, un tableau ou un groupe pour ensuite glisser une " +"connexion à un autre tableau." + +#: src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs:201 +msgid "workspace.options.selection-color" +msgstr "Couleurs de la sélection" + +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:105 +msgid "workspace.options.selection-fill" +msgstr "Remplissage de la sélection" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:44 +msgid "workspace.options.selection-stroke" +msgstr "Contour de la sélection" + +#: src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs:144 +msgid "workspace.options.shadow-options.add-shadow" +msgstr "Ajouter une ombre" + +#: src/app/main/ui/inspect/attributes/shadow.cljs:47, src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:181, src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:183 +msgid "workspace.options.shadow-options.blur" +msgstr "Flou" + +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:211 +msgid "workspace.options.shadow-options.color" +msgstr "Couleur de l'ombre" + +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:122 +msgid "workspace.options.shadow-options.drop-shadow" +msgstr "Ombre portée" + +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:123 +msgid "workspace.options.shadow-options.inner-shadow" +msgstr "Ombre interne" + +#: src/app/main/ui/inspect/attributes/shadow.cljs:45, src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:172 +msgid "workspace.options.shadow-options.offsetx" +msgstr "X" + +#: src/app/main/ui/inspect/attributes/shadow.cljs:46, src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:201 +msgid "workspace.options.shadow-options.offsety" +msgstr "Y" + +#: src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs:157, src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:161 +msgid "workspace.options.shadow-options.remove-shadow" +msgstr "Supprimer l'ombre" + +#: src/app/main/ui/inspect/attributes/shadow.cljs:48, src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:191, src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:193 +msgid "workspace.options.shadow-options.spread" +msgstr "Étalement" + +#: src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs:139 +msgid "workspace.options.shadow-options.title" +msgstr "Ombre" + +#: src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs:138 +msgid "workspace.options.shadow-options.title.group" +msgstr "Ombre du groupe" + +#: src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs:137 +msgid "workspace.options.shadow-options.title.multiple" +msgstr "Ombre de la sélection" + +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:157 +msgid "workspace.options.shadow-options.toggle-shadow" +msgstr "Basculer l'ombre" + +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:258 +msgid "workspace.options.show-fill-on-export" +msgstr "Afficher dans l'exportation" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:574 +msgid "workspace.options.show-in-viewer" +msgstr "Afficher en mode visionnement" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:168 +msgid "workspace.options.size" +msgstr "Taille" + +#: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:71, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:364 +msgid "workspace.options.size-presets" +msgstr "Tailles prédéfinies" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:469 +msgid "workspace.options.size.lock" +msgstr "Verrouiller les proportions" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:469 +msgid "workspace.options.size.unlock" +msgstr "Déverrouiller les proportions" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:44 +#, unused +msgid "workspace.options.stroke" +msgstr "Contour" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +#, unused +msgid "workspace.options.stroke-cap.circle-marker" +msgstr "Marqueur en cercle" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:175 +msgid "workspace.options.stroke-cap.circle-marker-short" +msgstr "Cercle" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +#, unused +msgid "workspace.options.stroke-cap.diamond-marker" +msgstr "Marquer en diamant" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:176 +msgid "workspace.options.stroke-cap.diamond-marker-short" +msgstr "Diamant" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +#, unused +msgid "workspace.options.stroke-cap.line-arrow" +msgstr "Flèche de ligne" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:172 +msgid "workspace.options.stroke-cap.line-arrow-short" +msgstr "Flèche" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:170 +msgid "workspace.options.stroke-cap.none" +msgstr "Aucun" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:178 +msgid "workspace.options.stroke-cap.round" +msgstr "Arrondi" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:179 +msgid "workspace.options.stroke-cap.square" +msgstr "Carré" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +#, unused +msgid "workspace.options.stroke-cap.square-marker" +msgstr "Marqueur en carré" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:174 +msgid "workspace.options.stroke-cap.square-marker-short" +msgstr "Rectangle" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +#, unused +msgid "workspace.options.stroke-cap.triangle-arrow" +msgstr "Flèche en triangle" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:173 +msgid "workspace.options.stroke-cap.triangle-arrow-short" +msgstr "Triangle" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:210 +msgid "workspace.options.stroke-color" +msgstr "Couleur du contour" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:225, src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:230 +msgid "workspace.options.stroke-width" +msgstr "Largeur du contour" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:189 +msgid "workspace.options.stroke.add-stroke" +msgstr "Ajouter un contour" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:111 +msgid "workspace.options.stroke.center" +msgstr "Centre" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:139 +msgid "workspace.options.stroke.dashed" +msgstr "Tirets" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:138 +msgid "workspace.options.stroke.dotted" +msgstr "Pointillés" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:112 +msgid "workspace.options.stroke.inner" +msgstr "Intérieur" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:140 +msgid "workspace.options.stroke.mixed" +msgstr "Mixte" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:113 +msgid "workspace.options.stroke.outer" +msgstr "Extérieur" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:202 +msgid "workspace.options.stroke.remove-stroke" +msgstr "Supprimer le contour" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:137 +msgid "workspace.options.stroke.solid" +msgstr "Plein" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:127 +msgid "workspace.options.text-options.align-bottom" +msgstr "Aligner en bas" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:123 +msgid "workspace.options.text-options.align-middle" +msgstr "Aligner au centre" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:119 +msgid "workspace.options.text-options.align-top" +msgstr "Aligner en haut" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:92 +msgid "workspace.options.text-options.direction-ltr" +msgstr "Gauche à droite" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:96 +msgid "workspace.options.text-options.direction-rtl" +msgstr "Droite à gauche" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:165 +msgid "workspace.options.text-options.grow-auto-height" +msgstr "Hauteur automatique" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:161 +msgid "workspace.options.text-options.grow-auto-width" +msgstr "Largeur automatique" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:157 +msgid "workspace.options.text-options.grow-fixed" +msgstr "Fixe" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:400 +msgid "workspace.options.text-options.letter-spacing" +msgstr "Interlettrage" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:381 +msgid "workspace.options.text-options.line-height" +msgstr "Hauteur de ligne" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +#, unused +msgid "workspace.options.text-options.lowercase" +msgstr "Minuscules" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +#, unused +msgid "workspace.options.text-options.none" +msgstr "Aucune" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:192 +msgid "workspace.options.text-options.strikethrough" +msgstr "Barré (%s)" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:61 +msgid "workspace.options.text-options.text-align-center" +msgstr "Aligner au centre" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:69 +msgid "workspace.options.text-options.text-align-justify" +msgstr "Justifier" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:57 +msgid "workspace.options.text-options.text-align-left" +msgstr "Aligner à gauche" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:65 +msgid "workspace.options.text-options.text-align-right" +msgstr "Aligner à droite" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:205 +msgid "workspace.options.text-options.title" +msgstr "Texte" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:204 +msgid "workspace.options.text-options.title-group" +msgstr "Texte du groupe" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:203 +msgid "workspace.options.text-options.title-selection" +msgstr "Texte de la sélection" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +#, unused +msgid "workspace.options.text-options.titlecase" +msgstr "Titré" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:188 +msgid "workspace.options.text-options.underline" +msgstr "Souligné (%s)" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +#, unused +msgid "workspace.options.text-options.uppercase" +msgstr "Majuscules" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:788 +msgid "workspace.options.use-play-button" +msgstr "" +"Utiliser le bouton de lecture dans le menu du haut pour voir le prototype." + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:420, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:442 +msgid "workspace.options.width" +msgstr "Largeur" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:482, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:505 +msgid "workspace.options.x" +msgstr "Axe X" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:495, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:516 +msgid "workspace.options.y" +msgstr "Axe Y" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:140 +msgid "workspace.path.actions.add-node" +msgstr "Ajouter un nœud (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:148 +msgid "workspace.path.actions.delete-node" +msgstr "Supprimer les nœuds (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:123 +msgid "workspace.path.actions.draw-nodes" +msgstr "Dessiner des nœuds (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:166 +msgid "workspace.path.actions.join-nodes" +msgstr "Connecter les nœuds (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:184 +msgid "workspace.path.actions.make-corner" +msgstr "Convertir en coin (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:192 +msgid "workspace.path.actions.make-curve" +msgstr "Convertir en courbe (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:158 +msgid "workspace.path.actions.merge-nodes" +msgstr "Fusionner les nœuds (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:131 +msgid "workspace.path.actions.move-nodes" +msgstr "Déplacer les nœuds (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:174 +msgid "workspace.path.actions.separate-nodes" +msgstr "Séparer les nœuds (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:203 +msgid "workspace.path.actions.snap-nodes" +msgstr "S'accrocher aux nœuds (%s)" + +#: src/app/main/ui/workspace/plugins.cljs:85 +msgid "workspace.plugins.button-open" +msgstr "Ouvrir" + +#: src/app/main/ui/workspace/plugins.cljs:199 +#, markdown +msgid "workspace.plugins.discover" +msgstr "Découvrir [d'autres plugiciels](%s)" + +#: src/app/main/ui/workspace/plugins.cljs:206 +msgid "workspace.plugins.empty-plugins" +msgstr "Aucun plugiciel installé" + +#: src/app/main/ui/workspace/plugins.cljs:193 +msgid "workspace.plugins.error.manifest" +msgstr "Le manifeste du plugiciel est invalide." + +#: src/app/main/data/plugins.cljs:105, src/app/main/ui/workspace/main_menu.cljs:766, src/app/main/ui/workspace/plugins.cljs:84 +msgid "workspace.plugins.error.need-editor" +msgstr "Tu dois être un éditeur pour utiliser ce plugiciel" + +#: src/app/main/ui/workspace/plugins.cljs:189 +msgid "workspace.plugins.error.url" +msgstr "Le plugiciel n'existe pas ou l'URL est invalide." + +#: src/app/main/ui/workspace/plugins.cljs:185 +msgid "workspace.plugins.install" +msgstr "Installer" + +#: src/app/main/ui/workspace/plugins.cljs:215 +msgid "workspace.plugins.installed-plugins" +msgstr "Plugiciels installés" + +#: src/app/main/ui/workspace/main_menu.cljs:721 +msgid "workspace.plugins.menu.plugins-manager" +msgstr "Gestionnaire de plugiciels" + +#: src/app/main/ui/workspace/main_menu.cljs:918 +msgid "workspace.plugins.menu.title" +msgstr "Plugiciels" + +#: src/app/main/ui/workspace/plugins.cljs:376 +msgid "workspace.plugins.permissions-update.title" +msgstr "MISE À JOUR DU PLUGICIEL" + +#: src/app/main/ui/workspace/plugins.cljs:380 +msgid "workspace.plugins.permissions-update.warning" +msgstr "" +"Ce plugiciel a été modifié depuis sa dernière ouverture. Des accès " +"supplémentaires sont demandés :" + +#: src/app/main/ui/workspace/plugins.cljs:280 +msgid "workspace.plugins.permissions.allow-download" +msgstr "Démarrer des téléchargements." + +#: src/app/main/ui/workspace/plugins.cljs:287 +msgid "workspace.plugins.permissions.allow-localstorage" +msgstr "Stocker des données dans le navigateur." + +#: src/app/main/ui/workspace/plugins.cljs:273 +msgid "workspace.plugins.permissions.comment-read" +msgstr "Lire tes commentaires et réponses." + +#: src/app/main/ui/workspace/plugins.cljs:267 +msgid "workspace.plugins.permissions.comment-write" +msgstr "Lire et modifier tes commentaires et répondre en ton nom." + +#: src/app/main/ui/workspace/plugins.cljs:240 +msgid "workspace.plugins.permissions.content-read" +msgstr "Lire le contenu de fichiers auxquels des usagers ont accès." + +#: src/app/main/ui/workspace/plugins.cljs:234 +msgid "workspace.plugins.permissions.content-write" +msgstr "Lire et modifier le contenu de fichiers auxquels des usagers ont accès." + +#: src/app/main/ui/workspace/plugins.cljs:327 +msgid "workspace.plugins.permissions.disclaimer" +msgstr "" +"Noter que ce plugiciel a été créé par une tierce partie. S'assurer d'y faire " +"confiance avant de permettre l'accès. La confidentialité des données est " +"importante pour nous. Merci de contacter le support en cas de question." + +#: src/app/main/ui/workspace/plugins.cljs:260 +msgid "workspace.plugins.permissions.library-read" +msgstr "Lire tes bibliothèques et atouts." + +#: src/app/main/ui/workspace/plugins.cljs:254 +msgid "workspace.plugins.permissions.library-write" +msgstr "Lire et modifier tes bibliothèques et atouts." + +#: src/app/main/ui/workspace/plugins.cljs:320 +msgid "workspace.plugins.permissions.title" +msgstr "LE PLUGICIEL « %s » DEMANDE D'ACCÉDER À :" + +#: src/app/main/ui/workspace/plugins.cljs:247 +msgid "workspace.plugins.permissions.user-read" +msgstr "Lire l'information de l'usager actuel." + +#: src/app/main/ui/workspace/plugins.cljs:211 +msgid "workspace.plugins.plugin-list-link" +msgstr "Liste des plugiciels" + +#: src/app/main/ui/workspace/plugins.cljs:88 +msgid "workspace.plugins.remove-plugin" +msgstr "Supprimer le plugiciel" + +#: src/app/main/ui/workspace/plugins.cljs:180 +msgid "workspace.plugins.search-placeholder" +msgstr "Inscrire l'URL d'un plugiciel" + +#, unused +msgid "workspace.plugins.success" +msgstr "Plugiciel chargé." + +#: src/app/main/ui/workspace/plugins.cljs:174 +msgid "workspace.plugins.title" +msgstr "Plugiciels" + +#: src/app/main/ui/workspace/plugins.cljs:440 +msgid "workspace.plugins.try-out.cancel" +msgstr "PAS MAINTENANT" + +#: src/app/main/ui/workspace/plugins.cljs:433 +msgid "workspace.plugins.try-out.message" +msgstr "" +"Tu veux jetter un coup d'œil ? Ça ouvrira un nouveau brouillon dans ton " +"équipe actuelle. (Autrement, tu peux accéder aux plugiciels installés dans " +"n'importe quel fichier.)" + +#: src/app/main/ui/workspace/plugins.cljs:429 +msgid "workspace.plugins.try-out.title" +msgstr "LE PLUGICIEL « %s » EST INSTALLÉ POUR TON USAGER!" + +#: src/app/main/ui/workspace/plugins.cljs:446 +msgid "workspace.plugins.try-out.try" +msgstr "ESSAYER LE PLUGICIEL" + +#: src/app/main/ui/workspace/context_menu.cljs:559 +msgid "workspace.shape.menu.add-flex" +msgstr "Ajouter une disposition flex" + +#: src/app/main/ui/workspace/context_menu.cljs:563 +msgid "workspace.shape.menu.add-grid" +msgstr "Ajouter une disposition en grille" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1248, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1272 +msgid "workspace.shape.menu.add-layout" +msgstr "Ajouter une disposition" + +#: src/app/main/ui/workspace/context_menu.cljs:612, src/app/main/ui/workspace/sidebar/assets/common.cljs:508, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1051, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1219, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1293 +msgid "workspace.shape.menu.add-variant" +msgstr "Créer un variant" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:512, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1073, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1221, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1307 +msgid "workspace.shape.menu.add-variant-property" +msgstr "Ajouter une propriété" + +#: src/app/main/ui/workspace/context_menu.cljs:282 +msgid "workspace.shape.menu.back" +msgstr "Envoyer à l'arrière-plan" + +#: src/app/main/ui/workspace/context_menu.cljs:279 +msgid "workspace.shape.menu.backward" +msgstr "Envoyer vers l'arrière" + +#: src/app/main/ui/workspace/context_menu.cljs:619, src/app/main/ui/workspace/sidebar/assets/components.cljs:633, src/app/main/ui/workspace/sidebar/assets/groups.cljs:75, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1106 +msgid "workspace.shape.menu.combine-as-variants" +msgstr "Combiner comme variants" + +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:635 +msgid "workspace.shape.menu.combine-as-variants-error" +msgstr "Les composants doivent être sur la même page" + +#: src/app/main/ui/workspace/context_menu.cljs:200 +msgid "workspace.shape.menu.copy" +msgstr "Copier" + +#: src/app/main/ui/workspace/context_menu.cljs:218 +msgid "workspace.shape.menu.copy-css" +msgstr "Copier en CSS" + +#: src/app/main/ui/workspace/context_menu.cljs:220 +msgid "workspace.shape.menu.copy-css-nested" +msgstr "Copier en CSS (calques imbriqués)" + +#: src/app/main/ui/workspace/context_menu.cljs:203 +msgid "workspace.shape.menu.copy-link" +msgstr "Copier le lien" + +#: src/app/main/ui/workspace/context_menu.cljs:216 +msgid "workspace.shape.menu.copy-paste-as" +msgstr "Copier/coller en tant que ..." + +#: src/app/main/ui/workspace/context_menu.cljs:230 +msgid "workspace.shape.menu.copy-props" +msgstr "Copier les propriétés" + +#: src/app/main/ui/workspace/context_menu.cljs:222 +msgid "workspace.shape.menu.copy-svg" +msgstr "Copier en SVG" + +#: src/app/main/ui/workspace/context_menu.cljs:227 +msgid "workspace.shape.menu.copy-text" +msgstr "Copier en texte" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:484 +msgid "workspace.shape.menu.create-annotation" +msgstr "Créer une annotation" + +#: src/app/main/ui/workspace/context_menu.cljs:382 +msgid "workspace.shape.menu.create-artboard-from-selection" +msgstr "Entableauter la sélection" + +#: src/app/main/ui/workspace/context_menu.cljs:592 +msgid "workspace.shape.menu.create-component" +msgstr "Créer un composant" + +#: src/app/main/ui/workspace/context_menu.cljs:596 +msgid "workspace.shape.menu.create-multiple-components" +msgstr "Créer plusieurs composants" + +#: src/app/main/ui/workspace/context_menu.cljs:206 +msgid "workspace.shape.menu.cut" +msgstr "Couper" + +#: src/app/main/ui/workspace/context_menu.cljs:629, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1001, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1290 +msgid "workspace.shape.menu.delete" +msgstr "Supprimer" + +#: src/app/main/ui/workspace/context_menu.cljs:506 +msgid "workspace.shape.menu.delete-flow-start" +msgstr "Supprimer le début de parcours" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:489 +msgid "workspace.shape.menu.detach-instance" +msgstr "Détacher l'instance" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:488 +msgid "workspace.shape.menu.detach-instances-in-bulk" +msgstr "Détacher les instances" + +#: src/app/main/ui/workspace/context_menu.cljs:447, src/app/main/ui/workspace/sidebar/options/menus/bool.cljs:88 +msgid "workspace.shape.menu.difference" +msgstr "Différence" + +#: src/app/main/ui/workspace/context_menu.cljs:212 +msgid "workspace.shape.menu.duplicate" +msgstr "Dupliquer" + +#: src/app/main/ui/workspace/context_menu.cljs:432 +msgid "workspace.shape.menu.edit" +msgstr "Modifier" + +#: src/app/main/ui/workspace/context_menu.cljs:453, src/app/main/ui/workspace/sidebar/options/menus/bool.cljs:98 +msgid "workspace.shape.menu.exclude" +msgstr "Exclusion" + +#: src/app/main/ui/workspace/context_menu.cljs:437, src/app/main/ui/workspace/context_menu.cljs:460, src/app/main/ui/workspace/sidebar/options/menus/bool.cljs:104 +msgid "workspace.shape.menu.flatten" +msgstr "Aplatir" + +#: src/app/main/ui/workspace/context_menu.cljs:299 +msgid "workspace.shape.menu.flip-horizontal" +msgstr "Retourner horizontalement" + +#: src/app/main/ui/workspace/context_menu.cljs:295 +msgid "workspace.shape.menu.flip-vertical" +msgstr "Retourner verticalement" + +#: src/app/main/ui/workspace/context_menu.cljs:508 +msgid "workspace.shape.menu.flow-start" +msgstr "Début de parcours" + +#: src/app/main/ui/workspace/context_menu.cljs:273 +msgid "workspace.shape.menu.forward" +msgstr "Emmener vers l'avant" + +#: src/app/main/ui/workspace/context_menu.cljs:276 +msgid "workspace.shape.menu.front" +msgstr "Emmener à l'avant-plan" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs, src/app/main/ui/workspace/context_menu.cljs +#, unused +msgid "workspace.shape.menu.go-main" +msgstr "Aller au fichier du composant principal" + +#: src/app/main/ui/workspace/context_menu.cljs:368 +msgid "workspace.shape.menu.group" +msgstr "Grouper" + +#: src/app/main/ui/workspace/context_menu.cljs:477, src/app/main/ui/workspace/sidebar/layer_item.cljs:172 +msgid "workspace.shape.menu.hide" +msgstr "Masquer" + +#: src/app/main/ui/workspace/context_menu.cljs:706, src/app/main/ui/workspace/main_menu.cljs:448 +msgid "workspace.shape.menu.hide-ui" +msgstr "Afficher / cacher l'interface utilisateur" + +#: src/app/main/ui/workspace/context_menu.cljs:450, src/app/main/ui/workspace/sidebar/options/menus/bool.cljs:93 +msgid "workspace.shape.menu.intersection" +msgstr "Intersection" + +#: src/app/main/ui/workspace/context_menu.cljs:485, src/app/main/ui/workspace/sidebar/layer_item.cljs:180, src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:268 +msgid "workspace.shape.menu.lock" +msgstr "Verrouiller" + +#: src/app/main/ui/workspace/context_menu.cljs:373 +msgid "workspace.shape.menu.mask" +msgstr "Utiliser comme masque" + +#: src/app/main/ui/workspace/context_menu.cljs:209, src/app/main/ui/workspace/context_menu.cljs:703 +msgid "workspace.shape.menu.paste" +msgstr "Coller" + +#: src/app/main/ui/workspace/context_menu.cljs:234 +msgid "workspace.shape.menu.paste-props" +msgstr "Coller les propriétés" + +#: src/app/main/ui/workspace/context_menu.cljs:443 +msgid "workspace.shape.menu.path" +msgstr "Opérations de chemin" + +#: src/app/main/ui/workspace/context_menu.cljs:549 +msgid "workspace.shape.menu.remove-flex" +msgstr "Supprimer la disposition flex" + +#: src/app/main/ui/workspace/context_menu.cljs:552 +msgid "workspace.shape.menu.remove-grid" +msgstr "Supprimer la disposition grille" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1266 +msgid "workspace.shape.menu.remove-layout" +msgstr "Supprimer la disposition" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1157 +msgid "workspace.shape.menu.remove-variant-property" +msgstr "Supprimer la propriété" + +#: src/app/main/ui/workspace/context_menu.cljs:329 +msgid "workspace.shape.menu.rename" +msgstr "Renommer" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:493 +msgid "workspace.shape.menu.reset-overrides" +msgstr "Annuler les modifications" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:499 +msgid "workspace.shape.menu.restore-main" +msgstr "Restaurer le composant principal" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:498 +msgid "workspace.shape.menu.restore-variant" +msgstr "Restaurer le variant" + +#: src/app/main/ui/workspace/context_menu.cljs:263 +msgid "workspace.shape.menu.select-layer" +msgstr "Sélectionner le calque" + +#: src/app/main/ui/workspace/context_menu.cljs:474, src/app/main/ui/workspace/sidebar/layer_item.cljs:171 +msgid "workspace.shape.menu.show" +msgstr "Afficher" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:481, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1217 +msgid "workspace.shape.menu.show-in-assets" +msgstr "Afficher dans le panneau d'atouts" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:502, src/app/main/ui/workspace/sidebar/assets/components.cljs:629 +msgid "workspace.shape.menu.show-main" +msgstr "Afficher le composant principal" + +#: src/app/main/ui/workspace/context_menu.cljs:314 +msgid "workspace.shape.menu.thumbnail-remove" +msgstr "Supprimer l'imagette" + +#: src/app/main/ui/workspace/context_menu.cljs:316 +msgid "workspace.shape.menu.thumbnail-set" +msgstr "Définir en tant qu'imagette" + +#: src/app/main/ui/workspace/context_menu.cljs:436 +#, unused +msgid "workspace.shape.menu.transform-to-path" +msgstr "Transformer en chemin" + +#: src/app/main/ui/workspace/context_menu.cljs:364 +msgid "workspace.shape.menu.ungroup" +msgstr "Dégroupper" + +#: src/app/main/ui/workspace/context_menu.cljs:444, src/app/main/ui/workspace/sidebar/options/menus/bool.cljs:83 +msgid "workspace.shape.menu.union" +msgstr "Union" + +#: src/app/main/ui/workspace/context_menu.cljs:482, src/app/main/ui/workspace/sidebar/layer_item.cljs:179, src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:274 +msgid "workspace.shape.menu.unlock" +msgstr "Déverrouiller" + +#: src/app/main/ui/workspace/context_menu.cljs:378 +msgid "workspace.shape.menu.unmask" +msgstr "Retirer le masque" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs, src/app/main/ui/workspace/sidebar/options/menus/component.cljs, src/app/main/ui/workspace/context_menu.cljs, src/app/main/ui/workspace/context_menu.cljs +#, unused +msgid "workspace.shape.menu.update-components-in-bulk" +msgstr "Mettre à jour les composants" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:505 +msgid "workspace.shape.menu.update-main" +msgstr "Mettre à jour le composant principal" + +#: src/app/main/ui/components/tab_container.cljs:52, src/app/main/ui/workspace/sidebar.cljs:62 +msgid "workspace.sidebar.collapse" +msgstr "Réduire le panneau latéral" + +#: src/app/main/ui/workspace/sidebar.cljs:73, src/app/main/ui/workspace/sidebar.cljs:77 +msgid "workspace.sidebar.expand" +msgstr "Afficher le panneau latéral" + +#: src/app/main/ui/workspace/right_header.cljs:226 +msgid "workspace.sidebar.history" +msgstr "Historique" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:509, src/app/main/ui/workspace/sidebar.cljs:154, src/app/main/ui/workspace/sidebar.cljs:157, src/app/main/ui/workspace/sidebar.cljs:164 +msgid "workspace.sidebar.layers" +msgstr "Calques" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:313, src/app/main/ui/workspace/sidebar/layers.cljs:374 +msgid "workspace.sidebar.layers.components" +msgstr "Composants" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:297 +msgid "workspace.sidebar.layers.filter" +msgstr "Filtrer" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:310, src/app/main/ui/workspace/sidebar/layers.cljs:338 +msgid "workspace.sidebar.layers.frames" +msgstr "Tableaux" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:311, src/app/main/ui/workspace/sidebar/layers.cljs:350 +msgid "workspace.sidebar.layers.groups" +msgstr "Groupes" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:315, src/app/main/ui/workspace/sidebar/layers.cljs:398 +msgid "workspace.sidebar.layers.images" +msgstr "Images" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:312, src/app/main/ui/workspace/sidebar/layers.cljs:362 +msgid "workspace.sidebar.layers.masks" +msgstr "Masques" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:293 +msgid "workspace.sidebar.layers.search" +msgstr "Recherche de calques" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:316, src/app/main/ui/workspace/sidebar/layers.cljs:410 +msgid "workspace.sidebar.layers.shapes" +msgstr "Formes" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:314, src/app/main/ui/workspace/sidebar/layers.cljs:386 +msgid "workspace.sidebar.layers.texts" +msgstr "Textes" + +#: src/app/main/ui/inspect/attributes/svg.cljs:56, src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs:101 +msgid "workspace.sidebar.options.svg-attrs.title" +msgstr "Attributs SVG importés" + +#: src/app/main/ui/workspace/sidebar/sitemap.cljs:264 +msgid "workspace.sidebar.sitemap" +msgstr "Pages" + +#: src/app/main/ui/workspace/sidebar/sitemap.cljs:274 +msgid "workspace.sidebar.sitemap.add-page" +msgstr "Ajouter une page" + +#: src/app/main/ui/workspace/left_header.cljs:98 +msgid "workspace.sitemap" +msgstr "Plan du site" + +#: src/app/main/ui/workspace/tokens/themes/theme_selector.cljs:86 +msgid "workspace.tokens.active-themes" +msgstr "%s thèmes actifs" + +#: src/app/main/ui/workspace/tokens/sidebar.cljs +#, unused +msgid "workspace.tokens.add set" +msgstr "Ajouter un ensemble" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:66, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:151, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:347 +msgid "workspace.tokens.add-new-theme" +msgstr "Ajouter un nouveau thème" + +#: src/app/main/ui/workspace/tokens/sets/context_menu.cljs:62 +msgid "workspace.tokens.add-set-to-group" +msgstr "Ajouter un ensemble à ce groupe" + +#: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:196, src/app/main/ui/workspace/tokens/management/group.cljs:156 +msgid "workspace.tokens.add-token" +msgstr "Ajouter un token : %s" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:137 +msgid "workspace.tokens.applied-to" +msgstr "Appliqué à" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:330 +msgid "workspace.tokens.axis" +msgstr "Axe" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:356 +msgid "workspace.tokens.back-to-themes" +msgstr "Retour à la liste des thèmes" + +#: src/app/main/ui/workspace/tokens/settings/menu.cljs:89 +msgid "workspace.tokens.base-font-size" +msgstr "Taille de police de base" + +#: src/app/main/ui/workspace/tokens/settings/menu.cljs:43 +msgid "workspace.tokens.base-font-size.error" +msgstr "" +"La taille de police de base doit être une valeur en pixels ou sans unité." + +#: src/app/main/ui/workspace/tokens/modals/import.cljs:127 +#, unused +msgid "workspace.tokens.choose-file" +msgstr "Choisir un fichier" + +#: src/app/main/ui/workspace/tokens/modals/import.cljs:132 +#, unused +msgid "workspace.tokens.choose-folder" +msgstr "Choisir un dossier" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:299, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:130 +msgid "workspace.tokens.color" +msgstr "Couleur" + +#: src/app/main/data/workspace/tokens/errors.cljs:101, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:251 +msgid "workspace.tokens.composite-line-height-needs-font-size" +msgstr "" +"La hauteur de ligne dépend de la taille de police. Ajouter une taille de " +"police pour obtenir la valeur calculée." + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:57 +msgid "workspace.tokens.create-new-theme" +msgstr "Crée le premier thème dès maintenant." + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:96, src/app/main/ui/workspace/tokens/themes.cljs:44 +msgid "workspace.tokens.create-one" +msgstr "En créer un." + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:235 +msgid "workspace.tokens.create-token" +msgstr "Créer un nouveau token %s" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:353 +msgid "workspace.tokens.delete" +msgstr "Supprimer le token" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:140 +msgid "workspace.tokens.delete-theme-title" +msgstr "Supprimer le thème" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:350 +msgid "workspace.tokens.duplicate" +msgstr "Dupliquer le token" + +#: src/app/main/data/workspace/tokens/library_edit.cljs:240, src/app/main/data/workspace/tokens/library_edit.cljs:509 +msgid "workspace.tokens.duplicate-suffix" +msgstr "copier" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:337 +msgid "workspace.tokens.edit" +msgstr "Éditer le token" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:346 +msgid "workspace.tokens.edit-theme-title" +msgstr "Édition du thème" + +#: src/app/main/ui/workspace/tokens/themes/theme_selector.cljs:74 +msgid "workspace.tokens.edit-themes" +msgstr "Éditer les thèmes" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:234 +msgid "workspace.tokens.edit-token" +msgstr "Édition du token %s" + +#: src/app/main/data/workspace/tokens/errors.cljs:41 +msgid "workspace.tokens.empty-input" +msgstr "La valeur du token ne peut être nulle" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 +msgid "workspace.tokens.enter-token-name" +msgstr "Entrer le nom du token %s" + +#: src/app/main/data/workspace/tokens/errors.cljs:15 +msgid "workspace.tokens.error-parse" +msgstr "Erreur d'importation : impossible d'analyser le JSON." + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:49 +msgid "workspace.tokens.export" +msgstr "Exporter" + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:125 +msgid "workspace.tokens.export-tokens" +msgstr "Exporter les tokens" + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:118 +msgid "workspace.tokens.export.multiple-files" +msgstr "Plusieurs fichiers" + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:38 +msgid "workspace.tokens.export.no-tokens-themes-sets" +msgstr "Aucun token, thème ou ensemble à exporter." + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:35 +msgid "workspace.tokens.export.preview" +msgstr "Aperçu :" + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:116 +msgid "workspace.tokens.export.single-file" +msgstr "Un seul fichier" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:129 +msgid "workspace.tokens.font-size-value-enter" +msgstr "Taille de police ou {alias}" + +#: src/app/main/data/workspace/tokens/application.cljs:325 +msgid "workspace.tokens.font-variant-not-found" +msgstr "" +"Erreur de configuration de la graisse/style de la police. Ce style de police " +"n'existe pas pour la police courante" + +#: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:42, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:137 +msgid "workspace.tokens.font-weight-value-enter" +msgstr "Graisse de police (300, gras italique, etc.) ou un {alias}" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:240 +msgid "workspace.tokens.gaps" +msgstr "Gouttières" + +#: src/app/main/ui/workspace/tokens/style_dictionary.cljs +#, unused +msgid "workspace.tokens.generic-error" +msgstr "Erreur : " + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:93 +msgid "workspace.tokens.group-name" +msgstr "Nom du groupe" + +#: src/app/main/ui/workspace/tokens/sets.cljs +#, unused +msgid "workspace.tokens.grouping-set-alert" +msgstr "Le groupement des ensembles de tokens n'est pas encore supporté." + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:233 +msgid "workspace.tokens.import-button-prefix" +msgstr "Importer %s" + +#: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 +msgid "workspace.tokens.import-error" +msgstr "Erreur d'importation :" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:273 +msgid "workspace.tokens.import-menu-folder-option" +msgstr "Dossier" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:271 +msgid "workspace.tokens.import-menu-json-option" +msgstr "Fichier JSON seul" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:272 +msgid "workspace.tokens.import-menu-zip-option" +msgstr "Fichier ZIP" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:241 +msgid "workspace.tokens.import-multiple-files" +msgstr "" +"En fichiers multiples. Le nom / chemin des fichiers seront les noms " +"d'ensembles." + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:240 +msgid "workspace.tokens.import-single-file" +msgstr "" +"En un fichier JSON seul. Les clés de premier niveau doivent être les noms " +"des ensembles de tokens." + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:237 +msgid "workspace.tokens.import-tokens" +msgstr "Importation de tokens" + +#: src/app/main/ui/workspace/tokens/sidebar.cljs:414, src/app/main/ui/workspace/tokens/sidebar.cljs:415 +#, unused +msgid "workspace.tokens.import-tooltip" +msgstr "" +"Importer un fichier JSON écrasera tous les tokens, ensembles et thèmes " +"actuels" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:247 +msgid "workspace.tokens.import-warning" +msgstr "Importer des tokens écrasera tous les tokens, ensembles et thèmes." + +#: src/app/main/ui/workspace/tokens/management.cljs:78 +msgid "workspace.tokens.inactive-set" +msgstr "Inactif" + +#: src/app/main/ui/workspace/tokens/management.cljs:70 +msgid "workspace.tokens.inactive-set-description" +msgstr "" +"Cet ensemble n'est pas actif. Change de thème ou active cet ensemble pour " +"voir les changements dans l'espace de travail" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:240, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:195 +msgid "workspace.tokens.individual-tokens" +msgstr "Tokens individuels" + +#: src/app/main/data/workspace/tokens/errors.cljs:49 +msgid "workspace.tokens.invalid-color" +msgstr "Valeur de couleur invalide : %s" + +#: src/app/main/data/workspace/tokens/errors.cljs:93 +msgid "workspace.tokens.invalid-font-family-token-value" +msgstr "" +"Valeur de token invalide :le token de référence doit être de type famille de " +"police de caractères" + +#: src/app/main/data/workspace/tokens/errors.cljs:89 +msgid "workspace.tokens.invalid-font-weight-token-value" +msgstr "" +"Valeur de graisse de police invalide : utiliser une valeur numérique (100-" +"950) ou des noms standards (mince, léger, régulier, gras, etc.), " +"optionnellement suivis par « italique »" + +#: src/app/main/data/workspace/tokens/errors.cljs:23 +msgid "workspace.tokens.invalid-json" +msgstr "Erreur d'importation : token invalide dans le JSON." + +#: src/app/main/data/workspace/tokens/errors.cljs:27 +msgid "workspace.tokens.invalid-json-token-name" +msgstr "Erreur d'importation : nom de token invalide dans le JSON." + +#: src/app/main/data/workspace/tokens/errors.cljs:28 +msgid "workspace.tokens.invalid-json-token-name-detail" +msgstr "" +"« %s » n'est pas un nom de token valide.\n" +"Les noms de tokens ne doivent contenir que des lettres et des chiffres " +"séparés par le caractère . et ne peuvent commencer par le symbole $." + +#: src/app/main/data/workspace/tokens/errors.cljs:105 +msgid "workspace.tokens.invalid-shadow-type-token-value" +msgstr "" +"Type d'ombre invalide : seulement « innerShadow » et « dropShadow » sont " +"acceptés" + +#: src/app/main/data/workspace/tokens/errors.cljs:81 +msgid "workspace.tokens.invalid-text-case-token-value" +msgstr "" +"Valeut de token invalide : seulement « none », « Uppercase », « Lowercase » " +"ou « Capitalize » sont acceptés" + +#: src/app/main/data/workspace/tokens/errors.cljs:85 +msgid "workspace.tokens.invalid-text-decoration-token-value" +msgstr "" +"Valeur de token invalide : seulement « none », « underline » et « strike-" +"through » sont acceptés" + +#: src/app/main/data/workspace/tokens/errors.cljs:117 +msgid "workspace.tokens.invalid-token-value-shadow" +msgstr "Valeur invalide : doit référencer un token d'ombre composé." + +#: src/app/main/data/workspace/tokens/errors.cljs:97 +msgid "workspace.tokens.invalid-token-value-typography" +msgstr "Valeur invalide: doit référencer un token de typographie composé." + +#: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 +msgid "workspace.tokens.invalid-value" +msgstr "Valeur de token invalide : %s" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 +msgid "workspace.tokens.label.group" +msgstr "Groupe" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:207 +msgid "workspace.tokens.label.group-placeholder" +msgstr "Ajouter un groupe (ex. Mode)" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:214 +msgid "workspace.tokens.label.theme" +msgstr "Thème" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:215 +msgid "workspace.tokens.label.theme-placeholder" +msgstr "Ajouter un thème (ex. Clair)" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:153 +msgid "workspace.tokens.letter-spacing-value-enter-composite" +msgstr "Interlettrage ou {alias}" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:145 +msgid "workspace.tokens.line-height-value-enter" +msgstr "Hauteur de ligne (multiplicateur, px, %) ou {alias}" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:232 +msgid "workspace.tokens.margins" +msgstr "Marges" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:268 +msgid "workspace.tokens.max-size" +msgstr "Taille max." + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:262 +msgid "workspace.tokens.min-size" +msgstr "Taille min." + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:303 +msgid "workspace.tokens.missing-reference" +msgstr "Référence manquante" + +#: src/app/main/data/workspace/tokens/errors.cljs:57 +msgid "workspace.tokens.missing-references" +msgstr "Références de token manquantes : " + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 +msgid "workspace.tokens.more-options" +msgstr "Clic droit pour voir les options" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:135 +msgid "workspace.tokens.no-active-sets" +msgstr "Aucun ensemble actif" + +#: src/app/main/ui/workspace/tokens/themes/theme_selector.cljs:91 +msgid "workspace.tokens.no-active-theme" +msgstr "Aucun thème actif" + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:72 +msgid "workspace.tokens.no-permisions-set" +msgstr "Tu dois être un éditeur pour activer / désactiver des ensembles" + +#: src/app/main/ui/workspace/tokens/themes.cljs:54 +msgid "workspace.tokens.no-permission-themes" +msgstr "Tu dois être un éditeur pour utiliser les thèmes" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:87 +#, unused +msgid "workspace.tokens.no-references-found" +msgstr "Aucune référence trouvée" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs +#, unused +msgid "workspace.tokens.no-remap-needed" +msgstr "" +"Ce token n'est pas utilisé dans le design. Il n'est pas nécessaire de mettre " +"à jour sa référence." + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:485 +msgid "workspace.tokens.no-sets-create" +msgstr "Aucun ensemble n'est défini. Crée-en un pour les assigner à un thème." + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:93, src/app/main/ui/workspace/tokens/sets/lists.cljs:99 +msgid "workspace.tokens.no-sets-yet" +msgstr "Aucun ensemble n'existe." + +#: src/app/main/ui/workspace/tokens/themes.cljs:40 +msgid "workspace.tokens.no-themes" +msgstr "Aucun thème n'existe." + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:53 +msgid "workspace.tokens.no-themes-currently" +msgstr "Aucun thème n'existe actuellement." + +#: src/app/main/data/workspace/tokens/errors.cljs:19 +msgid "workspace.tokens.no-token-files-found" +msgstr "Aucun token, ensemble ou thème n'existe dans ce fichier." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:95 +msgid "workspace.tokens.not-remap" +msgstr "Ne pas remapper" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:134 +msgid "workspace.tokens.num-active-sets" +msgstr "%s ensembles actifs" + +#: src/app/main/data/workspace/tokens/errors.cljs:53 +msgid "workspace.tokens.number-too-large" +msgstr "Valeur de token invalide. La valeur calculée est trop grande : %s" + +#: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 +msgid "workspace.tokens.opacity-range" +msgstr "L'opacité doit être entre 0% et 100% ou entre 0 et 1 (ex. 50% ou 0.5)." + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:121 +msgid "workspace.tokens.original-value" +msgstr "Valeur d'origine : %s" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:216 +msgid "workspace.tokens.paddings" +msgstr "Marges intérieures" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:292 +msgid "workspace.tokens.radius" +msgstr "Rayon" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:129 +msgid "workspace.tokens.ref-not-valid" +msgstr "La référence est invalide ou n'existe dans aucun ensemble actif" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:178 +msgid "workspace.tokens.reference-composite" +msgstr "Entrer un alias de token typographique" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:204 +msgid "workspace.tokens.reference-composite-shadow" +msgstr "Entrer un alias de token d'ombre" + +#: src/app/main/ui/workspace/tokens/style_dictionary.cljs +#, unused +msgid "workspace.tokens.reference-error" +msgstr "Erreurs de référence : " + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:99 +msgid "workspace.tokens.remap" +msgstr "Remapper les tokens" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:86 +msgid "workspace.tokens.remap-token-references-title" +msgstr "Remapper tous les tokens qui utilisent `%s` à `%s` ?" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:88 +msgid "workspace.tokens.remap-warning-effects" +msgstr "" +"Cette action mettra à jour tous les calques et références qui utilisent " +"l'ancien nom du token." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:89 +msgid "workspace.tokens.remap-warning-time" +msgstr "Cette action peut durer longtemps." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:92 +#, unused +msgid "workspace.tokens.remapping-in-progress" +msgstr "Remappage de références de token..." + +#: src/app/main/data/workspace/tokens/warnings.cljs:15, src/app/main/data/workspace/tokens/warnings.cljs:19, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:56, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:84, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:103, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:285, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:459, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:176, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:311, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:251, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:364, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:465, src/app/main/ui/workspace/tokens/management/token_pill.cljs:122 +msgid "workspace.tokens.resolved-value" +msgstr "Valeur calculée : %s" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:251 +msgid "workspace.tokens.save-theme" +msgstr "Sauvegarder le thème" + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:204, src/app/main/ui/workspace/tokens/sets/lists.cljs:309 +msgid "workspace.tokens.select-set" +msgstr "Sélectionner l'ensemble." + +#: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 +msgid "workspace.tokens.self-reference" +msgstr "Le token s'auto-référence" + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 +msgid "workspace.tokens.set-edit-placeholder" +msgstr "Entrer un nom (utiliser « / » pour les groupes)" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:361 +msgid "workspace.tokens.set-selection-theme" +msgstr "Définir quels ensembles de tokens feront partie de ce thème :" + +#: src/app/main/ui/workspace/tokens/token_pill.cljs:47 +#, unused +msgid "workspace.tokens.set.not-active" +msgstr "L'ensemble de tokens n'est pas actif" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:129 +msgid "workspace.tokens.sets-hint" +msgstr "Éditer le thème et gérer les ensembles" + +#: src/app/main/ui/workspace/tokens/settings/menu.cljs:91 +msgid "workspace.tokens.setting-description" +msgstr "" +"Configuration de la taille de police de base, qui définit la valeur de 1rem :" + +#: src/app/main/ui/workspace/tokens/settings/menu.cljs:84 +msgid "workspace.tokens.settings" +msgstr "Paramètres des tokens" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:232 +msgid "workspace.tokens.shadow-add-shadow" +msgstr "Ajouter une ombre" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:161, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:162, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:165 +msgid "workspace.tokens.shadow-blur" +msgstr "Flou" + +#: src/app/main/data/workspace/tokens/errors.cljs:109 +msgid "workspace.tokens.shadow-blur-range" +msgstr "Le flou d'ombre doit être égal ou supérieur à 0." + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:987, src/app/main/ui/workspace/tokens/management/create/form.cljs:988 +#, unused +msgid "workspace.tokens.shadow-color" +msgstr "Couleur" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:114 +msgid "workspace.tokens.shadow-inset" +msgstr "Position de l'ombre" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:123 +msgid "workspace.tokens.shadow-remove-shadow" +msgstr "Supprimer l'ombre" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:173, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:174, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:177 +msgid "workspace.tokens.shadow-spread" +msgstr "Étalement" + +#: src/app/main/data/workspace/tokens/errors.cljs:113 +msgid "workspace.tokens.shadow-spread-range" +msgstr "L'étalement d'ombre doit être égal ou supérieur à 0." + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:1215 +#, unused +msgid "workspace.tokens.shadow-title" +msgstr "Ombres" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:281 +msgid "workspace.tokens.shadow-token-blur-value-error" +msgstr "La valeur de flou ne peut être négative" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:287 +#, unused +msgid "workspace.tokens.shadow-token-spread-value-error" +msgstr "La valeur d'étalement ne peut être négative" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:139, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:141 +msgid "workspace.tokens.shadow-x" +msgstr "X" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:150, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:152 +msgid "workspace.tokens.shadow-y" +msgstr "Y" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:256 +msgid "workspace.tokens.size" +msgstr "Taille" + +#: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 +msgid "workspace.tokens.stroke-width-range" +msgstr "L'épaisseur de trait doit être égale ou supérieure à 0." + +#: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:40, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:161 +msgid "workspace.tokens.text-case-value-enter" +msgstr "none | uppercase | lowercase | capitalize ou {alias}" + +#: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:41, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:169 +msgid "workspace.tokens.text-decoration-value-enter" +msgstr "none | underline | strike-through ou {alias}" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:154 +#, unused +msgid "workspace.tokens.theme-name" +msgstr "Thème %s" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:51 +#, unused +msgid "workspace.tokens.theme-name-already-exists" +msgstr "Un thème portant ce nom existe déjà" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:100 +#, unused +msgid "workspace.tokens.theme.disable" +msgstr "Désactiver" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:96 +#, unused +msgid "workspace.tokens.theme.enable" +msgstr "Activer" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:85 +msgid "workspace.tokens.themes-description" +msgstr "" +"Géstion des thèmes, activation / désactivation et configurer les ensembles " +"actifs." + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:49, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:83 +msgid "workspace.tokens.themes-list" +msgstr "Liste des thèmes" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:275, src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:276 +msgid "workspace.tokens.token-description" +msgstr "Description" + +#: src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:122, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:246 +msgid "workspace.tokens.token-font-family-select" +msgstr "Sélectionner une famille de polices de caractères" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:121 +msgid "workspace.tokens.token-font-family-value" +msgstr "Famille de polices" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:120 +msgid "workspace.tokens.token-font-family-value-enter" +msgstr "Famille de police ou liste de polices séparées par une virgule (,)" + +#: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:83, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:112, src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:240 +msgid "workspace.tokens.token-name" +msgstr "Nom" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:268, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:223 +msgid "workspace.tokens.token-name-duplication-validation-error" +msgstr "Un token existe déjà au chemin : %s" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:265, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:220 +msgid "workspace.tokens.token-name-length-validation-error" +msgstr "Le nom doit être au moins 1 caractère" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:267, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:222 +msgid "workspace.tokens.token-name-validation-error" +msgstr "" +" n'est pas un nom de token valide.\n" +"Les noms de token ne doivent contenir que des lettres et des chiffres " +"séparés par des . et doivent pas commencer par le symbole $." + +#: src/app/main/ui/workspace/tokens/style_dictionary.cljs:259 +#, unused +msgid "workspace.tokens.token-not-resolved" +msgstr "Impossible de résoudre la référence de token portant le nom : %s" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:267 +msgid "workspace.tokens.token-value" +msgstr "Valeur" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:266, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:129 +msgid "workspace.tokens.token-value-enter" +msgstr "Entrer une valeur ou un alias avec {alias}" + +#: src/app/main/ui/workspace/tokens/management.cljs:67 +msgid "workspace.tokens.tokens-section-title" +msgstr "TOKENS - %s" + +#: src/app/main/ui/workspace/tokens/sidebar.cljs:122 +msgid "workspace.tokens.tools" +msgstr "Outils" + +#: src/app/main/data/workspace/tokens/import_export.cljs:46 +msgid "workspace.tokens.unknown-token-type-message" +msgstr "L'importation a réussi. Certains tokens n'ont pas été inclus." + +#: src/app/main/data/workspace/tokens/import_export.cljs:48 +msgid "workspace.tokens.unknown-token-type-section" +msgstr "Le type « %s » n'est pas supporté (%s)\n" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:244, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:199 +msgid "workspace.tokens.use-reference" +msgstr "Utiliser une référence" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:132 +msgid "workspace.tokens.value-not-valid" +msgstr "La valeur n'est pas valide" + +#: src/app/main/data/workspace/tokens/errors.cljs:69 +msgid "workspace.tokens.value-with-percent" +msgstr "Valeur invalide : % n'est pas permis." + +#: src/app/main/data/workspace/tokens/errors.cljs:65 +msgid "workspace.tokens.value-with-units" +msgstr "Valeur invalide : les unités ne sont pas permises." + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs +#, unused +msgid "workspace.tokens.warning-name-change" +msgstr "Renommer ce token brisera toute référence à son ancien nom" + +#: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 +msgid "workspace.toolbar.assets" +msgstr "Atouts" + +#: src/app/main/ui/workspace/palette.cljs:184 +msgid "workspace.toolbar.color-palette" +msgstr "Palette de couleurs (%s)" + +#: src/app/main/ui/workspace/right_header.cljs:217 +msgid "workspace.toolbar.comments" +msgstr "Commentaires (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:195 +msgid "workspace.toolbar.curve" +msgstr "Courbe (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:231 +msgid "workspace.toolbar.debug" +msgstr "Outils de débogage" + +#: src/app/main/ui/workspace/top_toolbar.cljs:172 +msgid "workspace.toolbar.ellipse" +msgstr "Ellipse (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:130 +msgid "workspace.toolbar.frame" +msgstr "Tableau (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:129 +msgid "workspace.toolbar.frame-first-time" +msgstr "Créer un tableau. Cliquer et glisser pour définir sa taille. (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:60 +msgid "workspace.toolbar.image" +msgstr "Image (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:143 +msgid "workspace.toolbar.move" +msgstr "Déplacer (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:205 +msgid "workspace.toolbar.path" +msgstr "Chemin (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:216 +msgid "workspace.toolbar.plugins" +msgstr "Plugiciels (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:162 +msgid "workspace.toolbar.rect" +msgstr "Rectangle (%s)" + +#: src/app/main/ui/workspace/left_toolbar.cljs +#, unused +msgid "workspace.toolbar.shortcuts" +msgstr "Raccourcis (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:182 +msgid "workspace.toolbar.text" +msgstr "Texte (%s)" + +#: src/app/main/ui/workspace/palette.cljs:190 +msgid "workspace.toolbar.text-palette" +msgstr "Typographies (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:235, src/app/main/ui/workspace/top_toolbar.cljs:236 +msgid "workspace.toolbar.toggle-toolbar" +msgstr "Basculer la barre d'outils" + +#: src/app/main/ui/workspace/viewport/top_bar.cljs:41 +msgid "workspace.top-bar.read-only.done" +msgstr "Terminé" + +#: src/app/main/ui/workspace/viewport/top_bar.cljs:37 +#, markdown +msgid "workspace.top-bar.view-only" +msgstr "**Inspection de code** (Lecture seule)" + +#: src/app/main/ui/workspace/sidebar/history.cljs:333 +msgid "workspace.undo.empty" +msgstr "Il n'y a aucun changement à l'historique" + +#: src/app/main/ui/workspace/sidebar/history.cljs:147 +msgid "workspace.undo.entry.delete" +msgstr "%s supprimé" + +#: src/app/main/ui/workspace/sidebar/history.cljs:146 +msgid "workspace.undo.entry.modify" +msgstr "%s modifié" + +#: src/app/main/ui/workspace/sidebar/history.cljs:148 +msgid "workspace.undo.entry.move" +msgstr "Objets déplacés" + +#: src/app/main/ui/workspace/sidebar/history.cljs:111 +msgid "workspace.undo.entry.multiple.circle" +msgstr "cercles" + +#: src/app/main/ui/workspace/sidebar/history.cljs:112 +msgid "workspace.undo.entry.multiple.color" +msgstr "couleurs" + +#: src/app/main/ui/workspace/sidebar/history.cljs:113 +msgid "workspace.undo.entry.multiple.component" +msgstr "composants" + +#: src/app/main/ui/workspace/sidebar/history.cljs:114 +msgid "workspace.undo.entry.multiple.curve" +msgstr "courbes" + +#: src/app/main/ui/workspace/sidebar/history.cljs:115 +msgid "workspace.undo.entry.multiple.frame" +msgstr "tableau" + +#: src/app/main/ui/workspace/sidebar/history.cljs:116 +msgid "workspace.undo.entry.multiple.group" +msgstr "groupes" + +#: src/app/main/ui/workspace/sidebar/history.cljs:117 +msgid "workspace.undo.entry.multiple.media" +msgstr "atouts graphiques" + +#: src/app/main/ui/workspace/sidebar/history.cljs:118 +msgid "workspace.undo.entry.multiple.multiple" +msgstr "objets" + +#: src/app/main/ui/workspace/sidebar/history.cljs:119 +msgid "workspace.undo.entry.multiple.page" +msgstr "pages" + +#: src/app/main/ui/workspace/sidebar/history.cljs:120 +msgid "workspace.undo.entry.multiple.path" +msgstr "chemins" + +#: src/app/main/ui/workspace/sidebar/history.cljs:121 +msgid "workspace.undo.entry.multiple.rect" +msgstr "rectangles" + +#: src/app/main/ui/workspace/sidebar/history.cljs:122 +msgid "workspace.undo.entry.multiple.shape" +msgstr "formes" + +#: src/app/main/ui/workspace/sidebar/history.cljs:123 +msgid "workspace.undo.entry.multiple.text" +msgstr "textes" + +#: src/app/main/ui/workspace/sidebar/history.cljs:124 +msgid "workspace.undo.entry.multiple.typography" +msgstr "typographies" + +#: src/app/main/ui/workspace/sidebar/history.cljs:145 +msgid "workspace.undo.entry.new" +msgstr "Nouveau %s" + +#: src/app/main/ui/workspace/sidebar/history.cljs:125 +msgid "workspace.undo.entry.single.circle" +msgstr "cercle" + +#: src/app/main/ui/workspace/sidebar/history.cljs:126 +msgid "workspace.undo.entry.single.color" +msgstr "couleur" + +#: src/app/main/ui/workspace/sidebar/history.cljs:127 +msgid "workspace.undo.entry.single.component" +msgstr "composant" + +#: src/app/main/ui/workspace/sidebar/history.cljs:128 +msgid "workspace.undo.entry.single.curve" +msgstr "courbe" + +#: src/app/main/ui/workspace/sidebar/history.cljs:129 +msgid "workspace.undo.entry.single.frame" +msgstr "tableau" + +#: src/app/main/ui/workspace/sidebar/history.cljs:130 +msgid "workspace.undo.entry.single.group" +msgstr "groupe" + +#: src/app/main/ui/workspace/sidebar/history.cljs:131 +msgid "workspace.undo.entry.single.image" +msgstr "image" + +#: src/app/main/ui/workspace/sidebar/history.cljs:132 +msgid "workspace.undo.entry.single.media" +msgstr "atout graphique" + +#: src/app/main/ui/workspace/sidebar/history.cljs:133 +msgid "workspace.undo.entry.single.multiple" +msgstr "objet" + +#: src/app/main/ui/workspace/sidebar/history.cljs:134 +msgid "workspace.undo.entry.single.page" +msgstr "page" + +#: src/app/main/ui/workspace/sidebar/history.cljs:135 +msgid "workspace.undo.entry.single.path" +msgstr "chemin" + +#: src/app/main/ui/workspace/sidebar/history.cljs:136 +msgid "workspace.undo.entry.single.rect" +msgstr "rectangle" + +#: src/app/main/ui/workspace/sidebar/history.cljs:137 +msgid "workspace.undo.entry.single.shape" +msgstr "forme" + +#: src/app/main/ui/workspace/sidebar/history.cljs:138 +msgid "workspace.undo.entry.single.text" +msgstr "texte" + +#: src/app/main/ui/workspace/sidebar/history.cljs:139 +msgid "workspace.undo.entry.single.typography" +msgstr "typographie" + +#: src/app/main/ui/workspace/sidebar/history.cljs:149 +msgid "workspace.undo.entry.unknown" +msgstr "Opération sur %s" + +#: src/app/main/ui/workspace/sidebar/history.cljs:335 +#, unused +msgid "workspace.undo.title" +msgstr "Historique" + +#: src/app/main/data/workspace/libraries.cljs:1247, src/app/main/ui/workspace/sidebar/versions.cljs:85 +msgid "workspace.updates.dismiss" +msgstr "Ignorer" + +#: src/app/main/data/workspace/libraries.cljs:1245 +msgid "workspace.updates.more-info" +msgstr "Plus d'info" + +#: src/app/main/data/workspace/libraries.cljs:1243 +msgid "workspace.updates.there-are-updates" +msgstr "Mises à jour dans les bibliothèques partagées" + +#: src/app/main/data/workspace/libraries.cljs:1249 +msgid "workspace.updates.update" +msgstr "Mettre à jour" + +#: src/app/main/ui/ds/product/milestone_group.cljs:73 +msgid "workspace.versions.autosaved.entry" +msgstr "%s versions auto-sauvegardées" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:260 +msgid "workspace.versions.autosaved.version" +msgstr "Auto-sauvegardé %s" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:278 +msgid "workspace.versions.button.pin" +msgstr "Épingler cette version" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:273 +msgid "workspace.versions.button.restore" +msgstr "Restaurer cette version" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:396, src/app/main/ui/workspace/sidebar/versions.cljs:398 +msgid "workspace.versions.button.save" +msgstr "Enregistrer une version" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:405 +msgid "workspace.versions.empty" +msgstr "Aucune version" + +#: src/app/main/ui/ds/product/milestone_group.cljs:67 +msgid "workspace.versions.expand-snapshot" +msgstr "Afficher les versions" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:369 +msgid "workspace.versions.filter.all" +msgstr "Toutes les versions" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:383 +msgid "workspace.versions.filter.label" +msgstr "Filtrer les versions" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:370 +msgid "workspace.versions.filter.mine" +msgstr "Mes versions" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:374 +msgid "workspace.versions.filter.user" +msgstr "Versions de %s" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:391 +msgid "workspace.versions.loading" +msgstr "Chargement..." + +#, unused +msgid "workspace.versions.locked-by-other" +msgstr "Cette version a été verouillée par %s et ne peut être modifiée" + +#, unused +msgid "workspace.versions.locked-by-you" +msgstr "Cette version a été verouillée par toi" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:83 +msgid "workspace.versions.restore-warning" +msgstr "Restaurer cette version?" + +#, unused +msgid "workspace.versions.snapshot-menu" +msgstr "Ouvrir le menu des versions" + +#: src/app/main/ui/workspace/sidebar.cljs:257 +msgid "workspace.versions.tab.actions" +msgstr "Actions" + +#: src/app/main/ui/workspace/sidebar.cljs:255 +msgid "workspace.versions.tab.history" +msgstr "Historique" + +#, unused +msgid "workspace.versions.tooltip.locked-version" +msgstr "Version verrouillée - seule le créateur peut la modifier" + +#: src/app/main/ui/ds/product/milestone.cljs:84, src/app/main/ui/ds/product/milestone_group.cljs:86 +msgid "workspace.versions.version-menu" +msgstr "Ouvrir le menu des versions" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:60 +#, markdown +msgid "workspace.versions.warning.subtext" +msgstr "" +"Si tu aimerais augmenter cette limite, nous écrire au [support@penpot.app]" +"(%s)" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:431 +msgid "workspace.versions.warning.text" +msgstr "Les versions auto-sauvegardées seront conservées pendant %s jours." + +#, unused +msgid "workspace.viewport.click-to-close-path" +msgstr "Cliquer pour fermer le chemin" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1156 +msgid "workspace.shape.menu.remove-variant-property.last-property" +msgstr "Le variant doit avoir au moins une propriété" diff --git a/frontend/translations/he.po b/frontend/translations/he.po index b5e5953db5..6f6d5cfe57 100644 --- a/frontend/translations/he.po +++ b/frontend/translations/he.po @@ -1,16 +1,16 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-02-16 08:35+0000\n" -"Last-Translator: Anonymous \n" -"Language-Team: Hebrew " -"\n" +"PO-Revision-Date: 2026-02-20 03:09+0000\n" +"Last-Translator: Yaron Shahrabani \n" +"Language-Team: Hebrew \n" "Language: he\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=4; plural=(n == 1) ? 0 : ((n == 2) ? 1 : ((n > 10 && " "n % 10 == 0) ? 2 : 3));\n" -"X-Generator: Weblate 5.16-dev\n" +"X-Generator: Weblate 5.16.1-dev\n" #: src/app/main/ui/auth/register.cljs:215, src/app/main/ui/static.cljs:158, src/app/main/ui/viewer/login.cljs:100 msgid "auth.already-have-account" @@ -409,7 +409,7 @@ msgstr "הוספת קובץ" #: src/app/main/ui/dashboard/file_menu.cljs:322, src/app/main/ui/workspace/main_menu.cljs:650 msgid "dashboard.add-shared" -msgstr "הוספת ספריה משותפת" +msgstr "הוספת ספרייה משותפת" #: src/app/main/ui/settings/profile.cljs:75 msgid "dashboard.change-email" @@ -1898,7 +1898,6 @@ msgid "inspect.tabs.styles.active-themes" msgstr "ערכות עיצוב פעילות" #: src/app/main/ui/inspect/styles/style_box.cljs:68 -#, fuzzy msgid "inspect.tabs.styles.copy-shorthand" msgstr "העתקת קיצור CSS ללוח הגזירים" @@ -4773,15 +4772,14 @@ msgid "subscription.settings.success.dialog.thanks" msgstr "תודה על הבחירה בתוכנית %s של Penpot!" #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "מאחלים לך הנאה מהתוכנית שלך!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "התוכנית שלך היא %s!" #: src/app/main/ui/settings/subscription.cljs:526 -#, fuzzy msgid "subscription.settings.support-us-since" msgstr "תמכת בנו עם התוכנית הזאת מאז: %s" @@ -6444,19 +6442,19 @@ msgstr "אפשרויות מתקדמות" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:686, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:693 msgid "workspace.options.layout-item.layout-item-max-h" -msgstr "גובה מר.‏" +msgstr "גובה מרבי" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:624, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:631 msgid "workspace.options.layout-item.layout-item-max-w" -msgstr "רוחב מר.‏" +msgstr "רוחב מרבי" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:655, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:663 msgid "workspace.options.layout-item.layout-item-min-h" -msgstr "גובה מז.‏" +msgstr "גובה מזערי" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:591, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:600 msgid "workspace.options.layout-item.layout-item-min-w" -msgstr "רוחב מז.‏" +msgstr "רוחב מזערי" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs #, unused @@ -7516,7 +7514,7 @@ msgid "workspace.tokens.color" msgstr "צבע" #: src/app/main/data/workspace/tokens/errors.cljs:101, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:251 -msgid "workspace.tokens.composite-line-height-needs-font-size" +msgid "errors.tokens.composite-line-height-needs-font-size" msgstr "גובה השורה תלוי בגודל הגופן. יש להוסיף גודל גופן כדי לקבל את הערך הפתור." #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:57 @@ -7564,7 +7562,7 @@ msgid "workspace.tokens.edit-token" msgstr "עריכת אסימון %s" #: src/app/main/data/workspace/tokens/errors.cljs:41 -msgid "workspace.tokens.empty-input" +msgid "errors.tokens.empty-input" msgstr "ערך האסימון לא יכול להישאר ריק" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 @@ -7572,7 +7570,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "נא למלא את שם האסימון %s" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "שגיאת ייבוא: לא ניתן לפענח JSON." #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -7634,7 +7632,7 @@ msgid "workspace.tokens.import-button-prefix" msgstr "ייבוא %s" #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "שגיאת ייבוא:" #: src/app/main/ui/workspace/tokens/import/modal.cljs:273 @@ -7685,57 +7683,57 @@ msgid "workspace.tokens.individual-tokens" msgstr "להשתמש באסימונים עצמאיים" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "ערך צבע שגוי: %s" #: src/app/main/data/workspace/tokens/errors.cljs:93 -msgid "workspace.tokens.invalid-font-family-token-value" +msgid "errors.tokens.invalid-font-family-token-value" msgstr "ערך אסימון שגוי: אפשר להפנות לאסימון font-family (משפחת גופנים) בלבד" #: src/app/main/data/workspace/tokens/errors.cljs:89 -msgid "workspace.tokens.invalid-font-weight-token-value" +msgid "errors.tokens.invalid-font-weight-token-value" msgstr "" "ערך משקל גופן שגוי: יש להשתמש בערכים מספריים (100-‏950) או שמות תקניים " "(thin,‏ light,‏ regular,‏ bold ועוד), אפשר גם לצרף בסוף ‚Italic’ (נטוי) " "במקרה הצורך" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "שגיאת ייבוא: נתוני אסימון שגויים ב־JSON." #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "שגיאת ייבוא: שם אסימון שגוי ב־JSON." #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "„%s” אינו שם תקף לאסימון.\n" "שמות האסימונים יכולים להכיל אותיות וספרות מופרדים בתווי . ואסור שיתחילו " "בדולר ($)." #: src/app/main/data/workspace/tokens/errors.cljs:105 -msgid "workspace.tokens.invalid-shadow-type-token-value" +msgid "errors.tokens.invalid-shadow-type-token-value" msgstr "סוג הצללה שגוי: רק ‚innerShadow’ או ‚dropShadow’ מורשים" #: src/app/main/data/workspace/tokens/errors.cljs:81 -msgid "workspace.tokens.invalid-text-case-token-value" +msgid "errors.tokens.invalid-text-case-token-value" msgstr "ערך אסימון שגוי: רק none,‏ Uppercase,‏ Lowercase או Capitalize מורשים" #: src/app/main/data/workspace/tokens/errors.cljs:85 -msgid "workspace.tokens.invalid-text-decoration-token-value" +msgid "errors.tokens.invalid-text-decoration-token-value" msgstr "ערך אסימון שגוי: מותר רק none,‏ underline ו־strike-through" #: src/app/main/data/workspace/tokens/errors.cljs:117 -msgid "workspace.tokens.invalid-token-value-shadow" +msgid "errors.tokens.invalid-token-value-shadow" msgstr "ערך שגוי: יש להפנות לאסימון הצללה מורכבת." #: src/app/main/data/workspace/tokens/errors.cljs:97 -msgid "workspace.tokens.invalid-token-value-typography" +msgid "errors.tokens.invalid-token-value-typography" msgstr "ערך שגוי: חייב להפנות לאסימון טיפוגרפיה מרוכב." #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "ערך אסימון שגוי: %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -7775,7 +7773,7 @@ msgid "workspace.tokens.min-size" msgstr "גודל מזערי" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "חסרות הפניות אסימונים: " #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 @@ -7815,7 +7813,7 @@ msgid "workspace.tokens.no-themes-currently" msgstr "אין לך ערכות עיצוב עדיין." #: src/app/main/data/workspace/tokens/errors.cljs:19 -msgid "workspace.tokens.no-token-files-found" +msgid "errors.tokens.no-token-files-found" msgstr "לא נמצאו אסימונים, סדרות או ערכות עיצוב בקובץ הזה." #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:134 @@ -7823,15 +7821,14 @@ msgid "workspace.tokens.num-active-sets" msgstr "%s סדרות פעילות" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "ערך אסימון שגוי. הערך הפתור גדול מדי: %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "שקיפות צריכה להיות בין 0 ל־100% או 0 ו־1 (כלומר 50% או 0.5)." #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:121 -#, fuzzy msgid "workspace.tokens.original-value" msgstr "ערך מקורי: %s" @@ -7861,7 +7858,6 @@ msgid "workspace.tokens.reference-error" msgstr "שגיאות הפניה: " #: src/app/main/data/workspace/tokens/warnings.cljs:15, src/app/main/data/workspace/tokens/warnings.cljs:19, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:56, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:84, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:103, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:285, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:459, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:176, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:311, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:251, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:364, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:465, src/app/main/ui/workspace/tokens/management/token_pill.cljs:122 -#, fuzzy msgid "workspace.tokens.resolved-value" msgstr "ערך פתור: %s" @@ -7874,7 +7870,7 @@ msgid "workspace.tokens.select-set" msgstr "בחירה ערכה." #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "לאסימון יש הפניה עצמית" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 @@ -7911,7 +7907,7 @@ msgid "workspace.tokens.shadow-blur" msgstr "טשטוש" #: src/app/main/data/workspace/tokens/errors.cljs:109 -msgid "workspace.tokens.shadow-blur-range" +msgid "errors.tokens.shadow-blur-range" msgstr "טשטוש הצל חייב להיות גדול או שווה ל־0." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:987, src/app/main/ui/workspace/tokens/management/create/form.cljs:988 @@ -7932,7 +7928,7 @@ msgid "workspace.tokens.shadow-spread" msgstr "התפרסות" #: src/app/main/data/workspace/tokens/errors.cljs:113 -msgid "workspace.tokens.shadow-spread-range" +msgid "errors.tokens.shadow-spread-range" msgstr "התפרסות ההצללה חייב להיות גדולה או שווה ל־0." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:1215 @@ -7953,7 +7949,7 @@ msgid "workspace.tokens.size" msgstr "גודל" #: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 -msgid "workspace.tokens.stroke-width-range" +msgid "errors.tokens.stroke-width-range" msgstr "עובי הקו חייב להיות גדול או שווה ל־0." #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:154 @@ -7977,7 +7973,6 @@ msgid "workspace.tokens.themes-list" msgstr "רשימת ערכות עיצוב" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:275, src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:276 -#, fuzzy msgid "workspace.tokens.token-description" msgstr "תיאור" @@ -8046,11 +8041,11 @@ msgid "workspace.tokens.value-not-valid" msgstr "הערך לא תקף" #: src/app/main/data/workspace/tokens/errors.cljs:69 -msgid "workspace.tokens.value-with-percent" +msgid "errors.tokens.value-with-percent" msgstr "ערך שגוי: אסור %." #: src/app/main/data/workspace/tokens/errors.cljs:65 -msgid "workspace.tokens.value-with-units" +msgid "errors.tokens.value-with-units" msgstr "ערך שגוי: אסור יחידות." #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 @@ -8380,3 +8375,323 @@ msgstr "גרסאות שנשמרו אוטומטית תישמרנה למשך %s י #, unused msgid "workspace.viewport.click-to-close-path" msgstr "לחיצה תסגור את הנתיב" + +#: src/app/main/ui/dashboard/sidebar.cljs:347 +msgid "dashboard.create-new-org" +msgstr "יצירת ארגון חדש" + +#: src/app/main/ui/dashboard/deleted.cljs:262 +msgid "dashboard.delete-all-forever-confirmation.description" +msgstr "למחוק את כל המיזמים והקבצים שלך לצמיתות? זאת פעולה בלתי הפיכה." + +#: src/app/main/ui/dashboard/file_menu.cljs:221 +msgid "dashboard.delete-file-forever-confirmation.description" +msgstr "למחוק את %s לצמיתות? זאת פעולה בלתי הפיכה." + +#: src/app/main/data/dashboard.cljs:778 +msgid "dashboard.delete-files-success-notification" +msgstr "%s קבצים נמחקו בהצלחה." + +#: src/app/main/ui/dashboard/deleted.cljs:51, src/app/main/ui/dashboard/deleted.cljs:53, src/app/main/ui/dashboard/deleted.cljs:261, src/app/main/ui/dashboard/deleted.cljs:263, src/app/main/ui/dashboard/file_menu.cljs:220, src/app/main/ui/dashboard/file_menu.cljs:222 +msgid "dashboard.delete-forever-confirmation.title" +msgstr "מחיקה לצמיתות" + +#: src/app/main/ui/dashboard/deleted.cljs:85 +msgid "dashboard.delete-project-button" +msgstr "מחיקת מיזם" + +#: src/app/main/ui/dashboard/deleted.cljs:52 +msgid "dashboard.delete-project-forever-confirmation.description" +msgstr "" +"למחוק את המיזם %s לצמיתות? הפעולה הזאת תמחק לצמיתות את כל הקבצים שבו. זאת " +"פעולה בלתי הפיכה." + +#: src/app/main/data/dashboard.cljs:777, src/app/main/data/dashboard.cljs:811 +msgid "dashboard.delete-success-notification" +msgstr "%s נמחק בהצלחה." + +#: src/app/main/ui/dashboard/deleted.cljs:327 +msgid "dashboard.deleted.empty-state-description" +msgstr "האשפה שלך ריקה. קבצים ומיזמים שנמחקו יופיעו כאן." + +#: src/app/main/ui/dashboard/grid.cljs:248 +msgid "dashboard.deleted.will-be-deleted-at" +msgstr "%s יימחק" + +#, unused +msgid "dashboard.errors.error-on-delete-file" +msgstr "אירעה שגיאה במחיקת הקובץ %s." + +#: src/app/main/data/dashboard.cljs:781 +msgid "dashboard.errors.error-on-delete-files" +msgstr "אירעה שגיאה במחיקת הקבצים." + +#: src/app/main/data/dashboard.cljs:814 +msgid "dashboard.errors.error-on-delete-project" +msgstr "אירעה שגיאה במחיקת המיזם %s." + +#: src/app/main/data/dashboard.cljs:909, src/app/main/ui/dashboard/file_menu.cljs:201 +msgid "dashboard.errors.error-on-restore-file" +msgstr "אירעה שגיאה בשחזור הקובץ %s." + +#: src/app/main/data/dashboard.cljs:910 +msgid "dashboard.errors.error-on-restore-files" +msgstr "אירעה שגיאה בשחזור הקבצים." + +#: src/app/main/data/dashboard.cljs:942 +msgid "dashboard.errors.error-on-restoring-project" +msgstr "אירעה שגיאה בשחזור המיזם %s עם הקבצים שלו." + +#: src/app/main/ui/dashboard/file_menu.cljs:266 +msgid "dashboard.file-menu.delete-files-permanently-option" +msgid_plural "dashboard.file-menu.delete-files-permanently-option" +msgstr[0] "מחיקת קובץ" +msgstr[1] "מחיקת קבצים" +msgstr[2] "מחיקת קבצים" +msgstr[3] "מחיקת קבצים" + +#: src/app/main/ui/dashboard/file_menu.cljs:263 +msgid "dashboard.file-menu.restore-files-option" +msgid_plural "dashboard.file-menu.restore-files-option" +msgstr[0] "שחזור קובץ" +msgstr[1] "שחזור קבצים" +msgstr[2] "שחזור קבצים" +msgstr[3] "שחזור קבצים" + +#: src/app/main/data/dashboard.cljs:722 +msgid "dashboard.progress-notification.deleting-files" +msgstr "קבצים נמחקים…" + +#: src/app/main/data/dashboard.cljs:843 +msgid "dashboard.progress-notification.restoring-files" +msgstr "קבצים משוחזרים…" + +#: src/app/main/data/dashboard.cljs:723 +msgid "dashboard.progress-notification.slow-delete" +msgstr "המחיקה איטית להחריד" + +#: src/app/main/data/dashboard.cljs:844 +msgid "dashboard.progress-notification.slow-restore" +msgstr "השחזור איטי להחריד" + +#: src/app/main/ui/dashboard/deleted.cljs:274 +msgid "dashboard.restore-all-confirmation.description" +msgstr "" +"הפעולה הזאת תשחזר את כל המיזמים והקבצים שלך. זאת פעולה שיכולה לארוך זמן מה." + +#: src/app/main/ui/dashboard/deleted.cljs:273 +msgid "dashboard.restore-all-confirmation.title" +msgstr "שחזור כל המיזמים והקבצים" + +#: src/app/main/ui/dashboard/deleted.cljs:308 +msgid "dashboard.restore-all-deleted-button" +msgstr "לשחזר הכול" + +#: src/app/main/data/dashboard.cljs:903 +msgid "dashboard.restore-files-success-notification" +msgstr "%s קבצים שוחזרו בהצלחה." + +#: src/app/main/ui/dashboard/deleted.cljs:82 +msgid "dashboard.restore-project-button" +msgstr "שחזור מיזם" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:465 +msgid "workspace.layout-item.fix-height" +msgstr "גובה קבוע" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:439 +msgid "workspace.layout-item.fix-width" +msgstr "רוחב קבוע" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:471 +msgid "workspace.layout-item.height-100" +msgstr "גובה 100%" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:444 +msgid "workspace.layout-item.width-100" +msgstr "רוחב 100%" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:616 +msgid "workspace.options.interaction-animation-direction-down" +msgstr "למטה" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:593 +msgid "workspace.options.interaction-animation-direction-in" +msgstr "פנימה" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:612 +msgid "workspace.options.interaction-animation-direction-left" +msgstr "שמאלה" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:596 +msgid "workspace.options.interaction-animation-direction-out" +msgstr "החוצה" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:608 +msgid "workspace.options.interaction-animation-direction-right" +msgstr "ימינה" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:620 +msgid "workspace.options.interaction-animation-direction-up" +msgstr "למעלה" + +#: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:108, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:401 +msgid "workspace.options.orientation.horizontal" +msgstr "אופקי" + +#: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:104, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:397 +msgid "workspace.options.orientation.vertical" +msgstr "אנכי" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:297 +msgid "workspace.sidebar.layers.filter" +msgstr "מסנן" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:303 +msgid "workspace.tokens.missing-reference" +msgstr "הפניה חסרה" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:87 +#, unused +msgid "workspace.tokens.no-references-found" +msgstr "לא נמצאו הפניות" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs +#, unused +msgid "workspace.tokens.no-remap-needed" +msgstr "האסימון (token) הזה לא בשימוש בעיצוב שלך, לא צריך מיפוי מחדש." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:95 +msgid "workspace.tokens.not-remap" +msgstr "לא למפות מחדש" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:99 +msgid "workspace.tokens.remap" +msgstr "מיפוי אסימונים מחדש" + +#: src/app/main/ui/dashboard/deleted.cljs:41 +msgid "dashboard.restore-project-confirmation.description" +msgstr "הפעולה הזאת תשחזר את המיזם %s ואת כל הקבצים שבו." + +#: src/app/main/ui/dashboard/deleted.cljs:40 +msgid "dashboard.restore-project-confirmation.title" +msgstr "שחזור מיזם" + +#: src/app/main/data/dashboard.cljs:875, src/app/main/data/dashboard.cljs:902, src/app/main/data/dashboard.cljs:939, src/app/main/ui/dashboard/file_menu.cljs:198 +msgid "dashboard.restore-success-notification" +msgstr "%s שוחזר בהצלחה." + +#: src/app/main/ui/dashboard/deleted.cljs:298 +msgid "dashboard.trash-info-text-part1" +msgstr "קבצים שנמחקו יישארו באשפה למשך" + +#: src/app/main/ui/dashboard/deleted.cljs:300 +msgid "dashboard.trash-info-text-part2" +msgstr " %s ימים. " + +#: src/app/main/ui/dashboard/deleted.cljs:301 +msgid "dashboard.trash-info-text-part3" +msgstr "לאחר מכן, הם יימחקו לצמיתות." + +#: src/app/main/ui/dashboard/deleted.cljs:303 +msgid "dashboard.trash-info-text-part4" +msgstr "" +"אם התחרטת, אפשר לשחזר אותם או למחוק אותם לצמיתות מהתפריט של כל אחד מהקבצים." + +#: src/app/main/errors.cljs:105 +msgid "errors.unexpected-exception" +msgstr "שגיאה מפתיעה: %s" + +#: src/app/main/ui/static.cljs:315 +msgid "errors.webgl-context-lost.desc-message" +msgstr "WebGL הפסיק לעבוד. נא לרענן את העמוד כדי לאפס אותו" + +#: src/app/main/ui/static.cljs:314 +msgid "errors.webgl-context-lost.main-message" +msgstr "אופס! הקשר לוח הציור אבד" + +#: src/app/main/ui/exports/files.cljs:124 +msgid "files-download-modal.title" +msgstr "הורדת קבצים" + +#: src/app/main/ui/dashboard/deleted.cljs:215 +msgid "labels.deleted" +msgstr "נמחקה" + +#: src/app/main/ui/dashboard/deleted.cljs:208 +msgid "labels.recent" +msgstr "אחרונות" + +#: src/app/main/ui/static.cljs:318 +msgid "labels.reload-page" +msgstr "ריענון העמוד" + +#: src/app/main/ui/viewer/header.cljs:187 +msgid "viewer.header.edit-in-workspace" +msgstr "עריכה במרחב העבודה" + +#: src/app/main/ui/workspace/colorpicker.cljs:434 +msgid "workspace.colorpicker.get-color" +msgstr "משיכת צבע" + +#: src/app/main/ui/workspace/sidebar/debug.cljs:38 +msgid "workspace.debug.title" +msgstr "כלי ניפוי שגיאות" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:422 +msgid "workspace.layout-grid.editor.margin.expand" +msgstr "הצגת אפשרויות גבול מ־4 צדדים" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:449 +msgid "workspace.layout-item.fit-content-horizontal" +msgstr "התאמת תוכן (אופקית)" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:477 +msgid "workspace.layout-item.fit-content-vertical" +msgstr "התאמת תוכן (אנכית)" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:86 +msgid "workspace.tokens.remap-token-references-title" +msgstr "למפות את כל האסימונים שמשתמשים ב־`%s` ל־`%s`?" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:88 +msgid "workspace.tokens.remap-warning-effects" +msgstr "" +"הפעולה הזאת תשנה את כל השכבות וההפניות שמשתמשות בשם הישן של האסימון (token)." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:89 +msgid "workspace.tokens.remap-warning-time" +msgstr "הפעולה הזאת עלולה לארוך זמן מה." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:92 +#, unused +msgid "workspace.tokens.remapping-in-progress" +msgstr "הפניות האסימון (token) ממופות מחדש…" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:281 +msgid "workspace.tokens.shadow-token-blur-value-error" +msgstr "ערך הטשטוש לא יכול להיות שלילי" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:287 +#, unused +msgid "workspace.tokens.shadow-token-spread-value-error" +msgstr "ערך הפריסה לא יכול להיות שלילי" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:100 +#, unused +msgid "workspace.tokens.theme.disable" +msgstr "כיבוי" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:96 +#, unused +msgid "workspace.tokens.theme.enable" +msgstr "הפעלה" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs +#, unused +msgid "workspace.tokens.warning-name-change" +msgstr "שינוי השם של האסימון (token) הזה יפגום בהפניות לשם הישן שלו" + +#: src/app/main/ui/workspace/top_toolbar.cljs:231 +msgid "workspace.toolbar.debug" +msgstr "כלי ניפוי שגיאות" diff --git a/frontend/translations/hi.po b/frontend/translations/hi.po index 25d94de048..a13ef70427 100644 --- a/frontend/translations/hi.po +++ b/frontend/translations/hi.po @@ -4904,11 +4904,11 @@ msgid "subscription.settings.success.dialog.thanks" msgstr "पेनपोट %s योजना चुनने के लिए धन्यवाद!" #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "अपनी योजना का आनंद लें!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "आप %s हैं!" #: src/app/main/ui/settings/subscription.cljs:526 @@ -7651,7 +7651,7 @@ msgid "workspace.tokens.color" msgstr "रंग" #: src/app/main/data/workspace/tokens/errors.cljs:101, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:251 -msgid "workspace.tokens.composite-line-height-needs-font-size" +msgid "errors.tokens.composite-line-height-needs-font-size" msgstr "" "पंक्ति की ऊँचाई फ़ॉन्ट आकार पर निर्भर करती है। हल किया गया मान प्राप्त करने " "के लिए फ़ॉन्ट आकार जोड़ें।" @@ -7701,7 +7701,7 @@ msgid "workspace.tokens.edit-token" msgstr "%s token संपादित करें" #: src/app/main/data/workspace/tokens/errors.cljs:41 -msgid "workspace.tokens.empty-input" +msgid "errors.tokens.empty-input" msgstr "टोकन मान रिक्त नहीं हो सकता" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 @@ -7709,7 +7709,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "%s टोकन नाम दर्ज करें" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "आयात त्रुटि: JSON को पार्स नहीं किया जा सका।" #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -7773,7 +7773,7 @@ msgid "workspace.tokens.import-button-prefix" msgstr "%s आयात करें" #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "आयात त्रुटि:" #: src/app/main/ui/workspace/tokens/import/modal.cljs:273 @@ -7824,60 +7824,60 @@ msgid "workspace.tokens.individual-tokens" msgstr "व्यक्तिगत टोकन का प्रयोग करें" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "अमान्य रंग मान: %s" #: src/app/main/data/workspace/tokens/errors.cljs:93 -msgid "workspace.tokens.invalid-font-family-token-value" +msgid "errors.tokens.invalid-font-family-token-value" msgstr "Invalid token value: आप केवल फ़ॉन्ट-परिवार token का संदर्भ ले सकते हैं" #: src/app/main/data/workspace/tokens/errors.cljs:89 -msgid "workspace.tokens.invalid-font-weight-token-value" +msgid "errors.tokens.invalid-font-weight-token-value" msgstr "" "अमान्य फ़ॉन्ट भार मान: संख्यात्मक मान (100-950) या मानक नाम (पतला, हल्का, " "नियमित, बोल्ड, आदि) का उपयोग करें, वैकल्पिक रूप से उसके बाद 'इटैलिक' लिखें" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "आयात त्रुटि: JSON में अमान्य टोकन डेटा।" #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "आयात त्रुटि: JSON में अमान्य टोकन नाम।" #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "\"%s\" एक मान्य टोकन नाम नहीं है।\n" "टोकन नामों में केवल अक्षर और अंक होने चाहिए, जो डॉट (.) से अलग किए गए हों, " "और $ चिह्न से शुरू नहीं होने चाहिए।" #: src/app/main/data/workspace/tokens/errors.cljs:105 -msgid "workspace.tokens.invalid-shadow-type-token-value" +msgid "errors.tokens.invalid-shadow-type-token-value" msgstr "अमान्य छाया प्रकार: केवल 'innerShadow' या 'dropShadow' स्वीकार किए जाते हैं" #: src/app/main/data/workspace/tokens/errors.cljs:81 -msgid "workspace.tokens.invalid-text-case-token-value" +msgid "errors.tokens.invalid-text-case-token-value" msgstr "" "अमान्य token मान: केवल कोई नहीं, अपरकेस, लोअरकेस या कैपिटलाइज़ स्वीकार किए " "जाते हैं" #: src/app/main/data/workspace/tokens/errors.cljs:85 -msgid "workspace.tokens.invalid-text-decoration-token-value" +msgid "errors.tokens.invalid-text-decoration-token-value" msgstr "" "अमान्य token मान: केवल कोई नहीं, रेखांकित और स्ट्राइक-थ्रू स्वीकार किए जाते " "हैं" #: src/app/main/data/workspace/tokens/errors.cljs:117 -msgid "workspace.tokens.invalid-token-value-shadow" +msgid "errors.tokens.invalid-token-value-shadow" msgstr "अमान्य मूल्य: एक समग्र छाया टोकन का संदर्भ देना चाहिए।।" #: src/app/main/data/workspace/tokens/errors.cljs:97 -msgid "workspace.tokens.invalid-token-value-typography" +msgid "errors.tokens.invalid-token-value-typography" msgstr "अमान्य मान: एक संयुक्त टाइपोग्राफी token का संदर्भ होना चाहिए।" #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "अमान्य टोकन मान: %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -7917,7 +7917,7 @@ msgid "workspace.tokens.min-size" msgstr "न्यूनतम. आकार" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "गुम टोकन संदर्भ: " #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 @@ -7957,7 +7957,7 @@ msgid "workspace.tokens.no-themes-currently" msgstr "आपके पास वर्तमान में कोई थीम नहीं है।" #: src/app/main/data/workspace/tokens/errors.cljs:19 -msgid "workspace.tokens.no-token-files-found" +msgid "errors.tokens.no-token-files-found" msgstr "इस फ़ाइल में कोई टोकन, सेट या थीम नहीं मिला।" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:134 @@ -7965,11 +7965,11 @@ msgid "workspace.tokens.num-active-sets" msgstr "%s सक्रिय सेट" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "अमान्य टोकन मान. हल किया गया मान बहुत बड़ा है: %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "अपारदर्शिता 0 और 100% या 0 और 1 (जैसे 50% या 0.5) के बीच होनी चाहिए।" #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:121 @@ -8016,7 +8016,7 @@ msgid "workspace.tokens.select-set" msgstr "सेट का चयन करें।" #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "टोकन में स्व-संदर्भ होता है" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 @@ -8057,7 +8057,7 @@ msgid "workspace.tokens.shadow-blur" msgstr "धुंधला" #: src/app/main/data/workspace/tokens/errors.cljs:109 -msgid "workspace.tokens.shadow-blur-range" +msgid "errors.tokens.shadow-blur-range" msgstr "छाया धुंधलापन 0 से अधिक या उसके बराबर होना चाहिए।" #: src/app/main/ui/workspace/tokens/management/create/form.cljs:987, src/app/main/ui/workspace/tokens/management/create/form.cljs:988 @@ -8078,7 +8078,7 @@ msgid "workspace.tokens.shadow-spread" msgstr "फैलाना" #: src/app/main/data/workspace/tokens/errors.cljs:113 -msgid "workspace.tokens.shadow-spread-range" +msgid "errors.tokens.shadow-spread-range" msgstr "छाया प्रसार 0 से अधिक या उसके बराबर होना चाहिए।" #: src/app/main/ui/workspace/tokens/management/create/form.cljs:1215 @@ -8099,7 +8099,7 @@ msgid "workspace.tokens.size" msgstr "आकार" #: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 -msgid "workspace.tokens.stroke-width-range" +msgid "errors.tokens.stroke-width-range" msgstr "स्ट्रोक की चौड़ाई 0 से बड़ी या उसके बराबर होनी चाहिए।" #: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:40, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:161 @@ -8200,11 +8200,11 @@ msgid "workspace.tokens.value-not-valid" msgstr "मान मान्य नहीं है" #: src/app/main/data/workspace/tokens/errors.cljs:69 -msgid "workspace.tokens.value-with-percent" +msgid "errors.tokens.value-with-percent" msgstr "अमान्य मान: % की अनुमति नहीं है।" #: src/app/main/data/workspace/tokens/errors.cljs:65 -msgid "workspace.tokens.value-with-units" +msgid "errors.tokens.value-with-units" msgstr "अमान्य मान: इकाइयाँ अनुमति नहीं हैं।" #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 diff --git a/frontend/translations/id.po b/frontend/translations/id.po index 8e610f193e..3c1d278095 100644 --- a/frontend/translations/id.po +++ b/frontend/translations/id.po @@ -6620,7 +6620,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "Masukkan nama token %s" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "Kesalahan Pengimporan: Tidak dapat mengurai JSON." #: src/app/main/ui/workspace/tokens/management/context_menu.cljs:240 @@ -6642,7 +6642,7 @@ msgid "workspace.tokens.grouping-set-alert" msgstr "Pengelompokan Set Token belum didukung." #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "Kesalahan Pengimporan:" #: src/app/main/ui/workspace/tokens/sidebar.cljs:414, src/app/main/ui/workspace/tokens/sidebar.cljs:415 @@ -6651,15 +6651,15 @@ msgid "workspace.tokens.import-tooltip" msgstr "Mengimpor berkas JSON akan menimpa semua token, set, dan tema Anda saat ini" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "Nilai warna tidak valid: %s" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "Kesalahan pengimporan: Data token tidak valid dalam JSON." #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "Nilai token tidak valid: %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -6691,7 +6691,7 @@ msgid "workspace.tokens.min-size" msgstr "Ukuran minimal" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "Referensi token hilang: " #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:135 @@ -6731,11 +6731,11 @@ msgid "workspace.tokens.num-active-sets" msgstr "%s set aktif" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "Nilai token tidak valid. Nilai terurai terlalu besar: %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "Opasitas harus antara 0 dan 100% atau 0 dan 1 (mis. 50% atau 0.5)." #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:121 @@ -6774,7 +6774,7 @@ msgid "workspace.tokens.select-set" msgstr "Pilih set." #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "Token memiliki referensi diri" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 diff --git a/frontend/translations/it.po b/frontend/translations/it.po index 550a39073a..dbede0245a 100644 --- a/frontend/translations/it.po +++ b/frontend/translations/it.po @@ -1,15 +1,15 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-02-16 08:35+0000\n" +"PO-Revision-Date: 2026-02-17 10:09+0000\n" "Last-Translator: Nicola Bortoletto \n" -"Language-Team: Italian " -"\n" +"Language-Team: Italian \n" "Language: it\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 5.16-dev\n" +"X-Generator: Weblate 5.16\n" #: src/app/main/ui/auth/register.cljs:215, src/app/main/ui/static.cljs:158, src/app/main/ui/viewer/login.cljs:100 msgid "auth.already-have-account" @@ -5012,11 +5012,11 @@ msgid "subscription.settings.success.dialog.thanks" msgstr "Grazie per aver scelto il piano Penpot %s!" #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "Goditi il tuo piano!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "Sei %s!" #: src/app/main/ui/settings/subscription.cljs:526 @@ -7840,7 +7840,7 @@ msgid "workspace.tokens.color" msgstr "Colore" #: src/app/main/data/workspace/tokens/errors.cljs:101, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:251 -msgid "workspace.tokens.composite-line-height-needs-font-size" +msgid "errors.tokens.composite-line-height-needs-font-size" msgstr "" "L'interlinea dipende dalla dimensione del carattere. Aggiungi una " "dimensione carattere per ottenere il valore risolto." @@ -7890,7 +7890,7 @@ msgid "workspace.tokens.edit-token" msgstr "Modifica token %s" #: src/app/main/data/workspace/tokens/errors.cljs:41 -msgid "workspace.tokens.empty-input" +msgid "errors.tokens.empty-input" msgstr "Il valore del token non può essere vuoto" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 @@ -7898,7 +7898,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "Inserisci il nome del token %s" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "Errore di importazione: Impossibile analizzare il JSON." #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -7962,7 +7962,7 @@ msgid "workspace.tokens.import-button-prefix" msgstr "Importa %s" #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "Errore di importazione:" #: src/app/main/ui/workspace/tokens/import/modal.cljs:273 @@ -8019,65 +8019,65 @@ msgid "workspace.tokens.individual-tokens" msgstr "Utilizza token individuali" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "Valore colore invalido: %s" #: src/app/main/data/workspace/tokens/errors.cljs:93 -msgid "workspace.tokens.invalid-font-family-token-value" +msgid "errors.tokens.invalid-font-family-token-value" msgstr "" "Valore del token non valido: puoi fare riferimento solo a un token " "font-family" #: src/app/main/data/workspace/tokens/errors.cljs:89 -msgid "workspace.tokens.invalid-font-weight-token-value" +msgid "errors.tokens.invalid-font-weight-token-value" msgstr "" "Valore del peso del font non valido: usa valori numerici (100-950) o nomi " "standard (thin, light, regular, bold, ecc.) eventualmente seguiti da " "'Italic'" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "Errore di importazione: Dati del token non validi nel JSON." #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "Errore di importazione: nome token non valido nel JSON." #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "\"%s\" non è un nome token valido\n" "I nomi dei token devono contenere solo lettere e cifre, separate dal " "carattere . e non devono iniziare con il simbolo $." #: src/app/main/data/workspace/tokens/errors.cljs:105 -msgid "workspace.tokens.invalid-shadow-type-token-value" +msgid "errors.tokens.invalid-shadow-type-token-value" msgstr "" "Tipologia ombra non valida: sono consentite solo 'innerShadow' o " "'dropShadow'" #: src/app/main/data/workspace/tokens/errors.cljs:81 -msgid "workspace.tokens.invalid-text-case-token-value" +msgid "errors.tokens.invalid-text-case-token-value" msgstr "" "Valore del token non valido: sono accettati solo none, Uppercase, Lowercase " "o Capitalize" #: src/app/main/data/workspace/tokens/errors.cljs:85 -msgid "workspace.tokens.invalid-text-decoration-token-value" +msgid "errors.tokens.invalid-text-decoration-token-value" msgstr "" "Valore del token non valido: sono accettati solo none, underline e " "strike-through" #: src/app/main/data/workspace/tokens/errors.cljs:117 -msgid "workspace.tokens.invalid-token-value-shadow" +msgid "errors.tokens.invalid-token-value-shadow" msgstr "Valore non valido: deve fare riferimento a un token ombra composito." #: src/app/main/data/workspace/tokens/errors.cljs:97 -msgid "workspace.tokens.invalid-token-value-typography" +msgid "errors.tokens.invalid-token-value-typography" msgstr "Valore non valido: deve fare riferimento a un token tipografico composito." #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "Valore token non valido: %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -8117,7 +8117,7 @@ msgid "workspace.tokens.min-size" msgstr "Dimensione min" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "Riferimenti al token mancanti: " #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 @@ -8150,7 +8150,7 @@ msgstr "Nessun riferimento trovato" msgid "workspace.tokens.no-remap-needed" msgstr "" "Questo token al momento non è utilizzato nel tuo progetto, quindi non è " -"necessario alcun rimappaggio." +"necessaria alcuna riassegnazione." #: src/app/main/ui/workspace/tokens/sets/lists.cljs:485 msgid "workspace.tokens.no-sets-create" @@ -8169,7 +8169,7 @@ msgid "workspace.tokens.no-themes-currently" msgstr "Al momento non hai temi." #: src/app/main/data/workspace/tokens/errors.cljs:19 -msgid "workspace.tokens.no-token-files-found" +msgid "errors.tokens.no-token-files-found" msgstr "Nessun token, set o tema trovato in questo file." #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:134 @@ -8177,11 +8177,11 @@ msgid "workspace.tokens.num-active-sets" msgstr "% set attivi" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "Valore token non valido. Il valore risolto è troppo grande: %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "" "L'opacità deve essere compresa tra 0 e 100% o tra 0 e 1 (ad esempio 50% o " "0.5)." @@ -8231,7 +8231,7 @@ msgid "workspace.tokens.select-set" msgstr "Seleziona set." #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "Il token ha un riferimento a se stesso" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 @@ -8270,7 +8270,7 @@ msgid "workspace.tokens.shadow-blur" msgstr "Sfocatura" #: src/app/main/data/workspace/tokens/errors.cljs:109 -msgid "workspace.tokens.shadow-blur-range" +msgid "errors.tokens.shadow-blur-range" msgstr "La sfocatura dell'ombra deve essere maggiore o uguale a 0." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:987, src/app/main/ui/workspace/tokens/management/create/form.cljs:988 @@ -8291,7 +8291,7 @@ msgid "workspace.tokens.shadow-spread" msgstr "Diffusione" #: src/app/main/data/workspace/tokens/errors.cljs:113 -msgid "workspace.tokens.shadow-spread-range" +msgid "errors.tokens.shadow-spread-range" msgstr "La diffusione dell'ombra deve essere maggiore o uguale a 0." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:1215 @@ -8321,7 +8321,7 @@ msgid "workspace.tokens.size" msgstr "Dimensione" #: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 -msgid "workspace.tokens.stroke-width-range" +msgid "errors.tokens.stroke-width-range" msgstr "La larghezza della traccia deve essere maggiore o uguale a 0." #: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:40, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:161 @@ -8432,11 +8432,11 @@ msgid "workspace.tokens.value-not-valid" msgstr "Il valore non è valido" #: src/app/main/data/workspace/tokens/errors.cljs:69 -msgid "workspace.tokens.value-with-percent" +msgid "errors.tokens.value-with-percent" msgstr "Valore non valido: % non è consentito." #: src/app/main/data/workspace/tokens/errors.cljs:65 -msgid "workspace.tokens.value-with-units" +msgid "errors.tokens.value-with-units" msgstr "Valore non valido: le unità non sono consentite." #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 @@ -8797,3 +8797,67 @@ msgstr "Clicca per chiudere il tracciato" #~ msgid "onboarding.slide.1.desc1" #~ msgstr "Crea interazioni complete per imitare al meglio il prodotto finale." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:99 +msgid "workspace.tokens.remap" +msgstr "Riassegna token" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:95 +msgid "workspace.tokens.not-remap" +msgstr "Non riassegnare" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:86 +msgid "workspace.tokens.remap-token-references-title" +msgstr "Riassegnare tutti i token che usano `%s` a `%s`?" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:88 +msgid "workspace.tokens.remap-warning-effects" +msgstr "" +"Questo cambierà tutti i livelli e i riferimenti che utilizzano il vecchio " +"nome del token." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:89 +msgid "workspace.tokens.remap-warning-time" +msgstr "Questa azione può richiedere un po' di tempo." + +#: src/app/main/ui/dashboard/sidebar.cljs:347 +msgid "dashboard.create-new-org" +msgstr "Crea nuova organizzazione" + +#: src/app/main/errors.cljs:105 +msgid "errors.unexpected-exception" +msgstr "Errore inaspettato: %s" + +#: src/app/main/ui/static.cljs:315 +msgid "errors.webgl-context-lost.desc-message" +msgstr "WebGL ha smesso di funzionare. Ricarica la pagina per ripristinarlo" + +#: src/app/main/ui/static.cljs:314 +msgid "errors.webgl-context-lost.main-message" +msgstr "Oops! Il contesto della canvas è stato perso" + +#: src/app/main/ui/static.cljs:318 +msgid "labels.reload-page" +msgstr "Ricarica pagina" + +#: src/app/main/ui/workspace/sidebar/debug.cljs:38 +msgid "workspace.debug.title" +msgstr "Strumenti di debug" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:303 +msgid "workspace.tokens.missing-reference" +msgstr "Riferimento mancante" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:204 +msgid "workspace.tokens.reference-composite-shadow" +msgstr "Inserisci un alias per il token ombra" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs +#, unused +msgid "workspace.tokens.warning-name-change" +msgstr "" +"Rinominare questo token interromperà ogni riferimento al suo nome precedente" + +#: src/app/main/ui/workspace/top_toolbar.cljs:231 +msgid "workspace.toolbar.debug" +msgstr "Strumenti di debug" diff --git a/frontend/translations/ko.po b/frontend/translations/ko.po index f27c397ff8..f7d4881b6f 100644 --- a/frontend/translations/ko.po +++ b/frontend/translations/ko.po @@ -2,8 +2,8 @@ msgid "" msgstr "" "PO-Revision-Date: 2026-01-30 05:01+0000\n" "Last-Translator: Dogyeong \n" -"Language-Team: Korean " -"\n" +"Language-Team: Korean \n" "Language: ko\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" @@ -11,60 +11,64 @@ msgstr "" "Plural-Forms: nplurals=1; plural=0;\n" "X-Generator: Weblate 5.16-dev\n" -#: src/app/main/ui/auth/register.cljs:215, src/app/main/ui/static.cljs:158, src/app/main/ui/viewer/login.cljs:100 +#: src/app/main/ui/auth/register.cljs:215, src/app/main/ui/static.cljs:158, +#: src/app/main/ui/viewer/login.cljs:100 msgid "auth.already-have-account" msgstr "이미 계정이 있으신가요?" -#: src/app/main/ui/auth/recovery_request.cljs:113, src/app/main/ui/auth/register.cljs:238 +#: src/app/main/ui/auth/recovery_request.cljs:113, +#: src/app/main/ui/auth/register.cljs:238 msgid "auth.check-mail" msgstr "이메일을 확인하세요" #: src/app/main/ui/auth/register.cljs:277 -#, unused msgid "auth.check-your-email" -msgstr "이메일에 포함된 링크를 클릭하여 계정을 인증하고 펜팟의 사용을 시작하십시오." +msgstr "" +"이메일에 포함된 링크를 클릭하여 계정을 인증하고 Penpot의 사용을 시작하세요." #: src/app/main/ui/auth/recovery.cljs:67 msgid "auth.confirm-password" -msgstr "비밀번호 확인하기" +msgstr "비밀번호 확인" #: src/app/main/ui/auth/register.cljs:227 msgid "auth.create-demo-account" -msgstr "데모 계정을 생성하세요" +msgstr "데모 계정 생성" #: src/app/main/ui/auth/register.cljs, src/app/main/ui/auth/login.cljs -#, unused msgid "auth.create-demo-profile" -msgstr "그냥 해볼까요?" +msgstr "체험해 보고 싶으신가요?" #: src/app/main/ui/auth/login.cljs:42 msgid "auth.demo-warning" -msgstr "데모 서비스입니다. 실제 작업에 사용하지 마십시오. 생성된 프로젝트는 주기적으로 삭제될 것입니다." +msgstr "" +"이것은 데모 서비스이며, 절대 실제 작업에 사용하지 마십시오. 프로젝트는 주기적" +"으로 삭제됩니다." #: src/app/main/ui/auth/login.cljs:198, src/app/main/ui/viewer/login.cljs:86 msgid "auth.forgot-password" -msgstr "비밀번호를 잊어버리셨나요?" +msgstr "비밀번호를 잊으셨나요?" -#: src/app/main/ui/auth/register.cljs:160, src/app/main/ui/auth/register.cljs:328 +#: src/app/main/ui/auth/register.cljs:160, +#: src/app/main/ui/auth/register.cljs:328 msgid "auth.fullname" msgstr "이름 (성명)" #: src/app/main/ui/auth/login.cljs:271 msgid "auth.login-account-title" -msgstr "내 계정에 로그인하기" +msgstr "내 계정에 로그인" -#: src/app/main/ui/auth/register.cljs:219, src/app/main/ui/static.cljs:161, src/app/main/ui/viewer/login.cljs:103 +#: src/app/main/ui/auth/register.cljs:219, src/app/main/ui/static.cljs:161, +#: src/app/main/ui/viewer/login.cljs:103 msgid "auth.login-here" msgstr "여기서 로그인하세요." #: src/app/main/ui/auth/login.cljs:195 -#, unused msgid "auth.login-submit" msgstr "로그인" #: src/app/main/ui/auth/login.cljs:274 msgid "auth.login-tagline" -msgstr "펜팟은 디자인과 코딩의 협업을 위한 무료 오픈소스 디자인 도구입니다" +msgstr "Penpot은 디자인과 코드 협업을 위한 무료 오픈소스 디자인 도구입니다" #: src/app/main/ui/auth/login.cljs:231 msgid "auth.login-with-github-submit" @@ -92,19 +96,19 @@ msgstr "새 비밀번호를 입력하세요" #: src/app/main/ui/auth/recovery.cljs:36 msgid "auth.notifications.password-changed-successfully" -msgstr "비밀번호가 성공적으로 변경되었어요" +msgstr "비밀번호가 성공적으로 변경되었습니다" #: src/app/main/ui/auth/recovery_request.cljs:50 msgid "auth.notifications.profile-not-verified" -msgstr "프로필이 검증되지 않았어요. 계속 하려면 검증절차를 완료해주세요." +msgstr "프로필이 인증되지 않았습니다. 계속하기 전에 프로필을 인증하세요." #: src/app/main/ui/auth/recovery_request.cljs:33 msgid "auth.notifications.recovery-token-sent" -msgstr "비밀번호 복구를 위한 링크를 메일함으로 보냈어요." +msgstr "비밀번호 복구 링크가 메일함으로 전송되었습니다." #: src/app/main/ui/auth/verify_token.cljs:49 msgid "auth.notifications.team-invitation-accepted" -msgstr "팀에 성공적으로 합류했어요" +msgstr "팀에 성공적으로 참가했습니다" #: src/app/main/ui/auth/login.cljs:188, src/app/main/ui/auth/register.cljs:174 msgid "auth.password" @@ -112,19 +116,19 @@ msgstr "비밀번호" #: src/app/main/ui/auth/register.cljs:173 msgid "auth.password-length-hint" -msgstr "최소 8개의 문자" +msgstr "최소 8자 이상" #: src/app/main/ui/auth/register.cljs:261 msgid "auth.privacy-policy" -msgstr "개인 정보 정책" +msgstr "개인정보 처리방침" #: src/app/main/ui/auth/recovery_request.cljs:82 msgid "auth.recovery-request-submit" -msgstr "비밀번호 복구하기" +msgstr "비밀번호 복구" #: src/app/main/ui/auth/recovery_request.cljs:95 msgid "auth.recovery-request-subtitle" -msgstr "이용지침을 메일로 전달해드릴거에요" +msgstr "안내 사항이 담긴 이메일을 보내드리겠습니다" #: src/app/main/ui/auth/recovery_request.cljs:94 msgid "auth.recovery-request-title" @@ -132,65 +136,71 @@ msgstr "비밀번호를 잊으셨나요?" #: src/app/main/ui/auth/recovery.cljs:71 msgid "auth.recovery-submit" -msgstr "비밀번호를 바꾸세요" +msgstr "비밀번호 변경" -#: src/app/main/ui/auth/login.cljs:287, src/app/main/ui/static.cljs:144, src/app/main/ui/viewer/login.cljs:89 +#: src/app/main/ui/auth/login.cljs:287, src/app/main/ui/static.cljs:144, +#: src/app/main/ui/viewer/login.cljs:89 msgid "auth.register" -msgstr "아직 계정이 없으신가요?" +msgstr "계정이 없으신가요?" #: src/app/main/ui/auth/register.cljs:351 msgid "auth.register-account-tagline" -msgstr "대시보드와 이메일 에서 당신을 어떻게 호칭할지 저희에게 알려주세요." +msgstr "대시보드와 이메일에 표시될 이름을 입력해주세요." #: src/app/main/ui/auth/register.cljs:350 msgid "auth.register-account-title" msgstr "당신의 이름" -#: src/app/main/ui/auth/login.cljs:291, src/app/main/ui/auth/register.cljs:185, src/app/main/ui/auth/register.cljs:337, src/app/main/ui/static.cljs:148, src/app/main/ui/viewer/login.cljs:93 +#: src/app/main/ui/auth/login.cljs:291, src/app/main/ui/auth/register.cljs:185, +#: src/app/main/ui/auth/register.cljs:337, src/app/main/ui/static.cljs:148, +#: src/app/main/ui/viewer/login.cljs:93 msgid "auth.register-submit" -msgstr "계정을 생성하세요" +msgstr "계정 생성" #: src/app/main/ui/auth/register.cljs:124 -#, unused msgid "auth.register-tagline" -msgstr "펜팟 무료 계정과 함께라면, 무제한으로 팀을 만들고 다른 디자이너 및 개발자와 원하는 만큼 프로젝트에서 협업할 수 있습니다. " +msgstr "" +"무료 Penpot 계정으로 팀을 무제한 생성하고, 원하는 만큼 많은 프로젝트에서 다" +"른 디자이너 및 개발자와 협업할 수 있습니다. " #: src/app/main/ui/auth/register.cljs:206 msgid "auth.register-title" -msgstr "계정을 생성하세요" +msgstr "계정 생성" #: src/app/main/ui/auth.cljs -#, unused msgid "auth.sidebar-tagline" -msgstr "디자인과 프로토타이핑을 위한 오픈소스 솔루션." +msgstr "디자인 및 프로토타이핑을 위한 오픈소스 솔루션." #: src/app/main/ui/auth/register.cljs:51 -#, markdown msgid "auth.terms-and-privacy-agreement" -msgstr "[서비스 약관](%s) 및 [개인정보 처리방침](%s)에 동의합니다." +msgstr "[서비스 이용약관](%s) 및 [개인정보 처리방침](%s)에 동의합니다." -#: src/app/main/ui/auth/register.cljs:253, src/app/main/ui/dashboard/sidebar.cljs:979, src/app/main/ui/workspace/main_menu.cljs:184 +#: src/app/main/ui/auth/register.cljs:253, +#: src/app/main/ui/dashboard/sidebar.cljs:979, +#: src/app/main/ui/workspace/main_menu.cljs:184 msgid "auth.terms-of-service" -msgstr "서비스 정책" +msgstr "서비스 이용약관" -#, unused msgid "auth.terms-privacy-agreement" -msgstr "새로운 계정을 생성하시면, 사용자는 펜팟의 서비스 정책과 개인 정보 정책에 동의하는 것으로 간주됩니다." +msgstr "" +"새 계정을 만들면 당사의 이용약관 및 개인정보 처리방침에 동의하게 됩니다." #: src/app/main/ui/auth/register.cljs:239 msgid "auth.verification-email-sent" -msgstr "검증 메일을 ~에 보냈어요" +msgstr "다음 이메일로 인증 메일을 보냈습니다:" -#: src/app/main/ui/auth/login.cljs:179, src/app/main/ui/auth/recovery_request.cljs:77, src/app/main/ui/auth/register.cljs:167 +#: src/app/main/ui/auth/login.cljs:179, +#: src/app/main/ui/auth/recovery_request.cljs:77, +#: src/app/main/ui/auth/register.cljs:167 msgid "auth.work-email" -msgstr "작업용 이메일" +msgstr "업무 이메일" #: src/app/main/ui/onboarding/questions.cljs -#, unused msgid "branding-illustrations-marketing-pieces" msgstr "...브랜딩, 일러스트레이션, 마케팅 자료 등." -#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:101, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:108 +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:101, +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:108 msgid "options.deleted-token" msgstr "이 토큰은 존재하지 않거나 삭제되었습니다." @@ -200,76 +210,80 @@ msgstr "@%s 사용자를 찾을 수 없습니다" #: src/app/main/ui/workspace/libraries.cljs:323 msgid "common.publish" -msgstr "발행하기" +msgstr "게시하기" -#: src/app/main/ui/viewer/share_link.cljs:304, src/app/main/ui/viewer/share_link.cljs:315 +#: src/app/main/ui/viewer/share_link.cljs:304, +#: src/app/main/ui/viewer/share_link.cljs:315 msgid "common.share-link.all-users" -msgstr "모든 펜팟 유저들" +msgstr "모든 Penpot 사용자" #: src/app/main/ui/viewer/share_link.cljs:204 msgid "common.share-link.confirm-deletion-link-description" -msgstr "정말로 링크를 제거하고 싶으세요? 제거하시면, 더이상 아무도 이용할 수 없어요" +msgstr "이 링크를 삭제하시겠습니까? 삭제하면 더 이상 누구도 접근할 수 없습니다" -#: src/app/main/ui/viewer/share_link.cljs:259, src/app/main/ui/viewer/share_link.cljs:289 +#: src/app/main/ui/viewer/share_link.cljs:259, +#: src/app/main/ui/viewer/share_link.cljs:289 msgid "common.share-link.current-tag" msgstr "(현재)" -#: src/app/main/ui/viewer/share_link.cljs:211, src/app/main/ui/viewer/share_link.cljs:216 +#: src/app/main/ui/viewer/share_link.cljs:211, +#: src/app/main/ui/viewer/share_link.cljs:216 msgid "common.share-link.destroy-link" -msgstr "링크 제거하기" +msgstr "링크 삭제" #: src/app/main/ui/viewer/share_link.cljs:221 msgid "common.share-link.get-link" -msgstr "링크 얻기" +msgstr "링크 가져오기" #: src/app/main/ui/viewer/share_link.cljs:142 msgid "common.share-link.link-copied-success" -msgstr "링크를 성공적으로 복사했어요" +msgstr "링크가 성공적으로 복사되었습니다" #: src/app/main/ui/viewer/share_link.cljs:231 msgid "common.share-link.manage-ops" -msgstr "권한을 관리하세요" +msgstr "권한 관리" #: src/app/main/ui/viewer/share_link.cljs:277 msgid "common.share-link.page-shared" msgid_plural "common.share-link.page-shared" -msgstr[0] "%s 페이지가 공유되었습니다" +msgstr[0] "%s개의 페이지가 공유되었습니다" #: src/app/main/ui/viewer/share_link.cljs:298 msgid "common.share-link.permissions-can-comment" -msgstr "코멘트를 달 수 있어요" +msgstr "댓글 작성 가능" #: src/app/main/ui/viewer/share_link.cljs:309 msgid "common.share-link.permissions-can-inspect" -msgstr "코드를 검사할 수 있어요" +msgstr "코드 검사 가능" #: src/app/main/ui/viewer/share_link.cljs:199 msgid "common.share-link.permissions-hint" -msgstr "링크를 가진 누구나 접근할 수 있어요" +msgstr "링크가 있는 누구나 접근 가능합니다" #: src/app/main/ui/viewer/share_link.cljs:241 msgid "common.share-link.permissions-pages" -msgstr "페이지가 공유됐어요" +msgstr "공유된 페이지" #: src/app/main/ui/viewer/share_link.cljs:189 msgid "common.share-link.placeholder" -msgstr "공유할 수 있는 링크는 여기 나타날거에요" +msgstr "공유 가능한 링크가 여기에 표시됩니다" -#: src/app/main/ui/viewer/share_link.cljs:303, src/app/main/ui/viewer/share_link.cljs:314 +#: src/app/main/ui/viewer/share_link.cljs:303, +#: src/app/main/ui/viewer/share_link.cljs:314 msgid "common.share-link.team-members" -msgstr "오직 팀원들을 위해" +msgstr "팀 구성원만" #: src/app/main/ui/viewer/share_link.cljs:176 msgid "common.share-link.title" -msgstr "프로토타입을 공유해요" +msgstr "프로토타입 공유" #: src/app/main/ui/viewer/share_link.cljs:269 msgid "common.share-link.view-all" -msgstr "모두 선택해요" +msgstr "모두 선택" #: src/app/main/ui/workspace/libraries.cljs:320 msgid "common.unpublish" -msgstr "발행취소하기" +msgstr "게시 취소" #: src/app/main/ui/dashboard/projects.cljs:93 msgid "dasboard.team-hero.management" @@ -277,45 +291,41 @@ msgstr "팀 관리" #: src/app/main/ui/dashboard/projects.cljs:92 msgid "dasboard.team-hero.text" -msgstr "펜팟은 팀을 위한 도구입니다. 팀원들을 초대하여 프로젝트 및 파일 단위로 협업하십시오" +msgstr "" +"Penpot은 팀을 위한 도구입니다. 프로젝트와 파일에서 함께 작업할 수 있도록 구성" +"원을 초대하세요" #: src/app/main/ui/dashboard/projects.cljs:90 msgid "dasboard.team-hero.title" -msgstr "팀을 이뤄요!" +msgstr "팀을 구성하세요!" #: src/app/main/ui/dashboard/projects.cljs -#, unused msgid "dasboard.tutorial-hero.info" -msgstr "본 실습용 튜토리얼을 통해 펜팟의 기본 기능에 대하여 재미있게 학습하십시오." +msgstr "Penpot의 기본 기능을 실습 튜토리얼로 재미있게 배워보세요." #: src/app/main/ui/dashboard/projects.cljs -#, unused msgid "dasboard.tutorial-hero.start" -msgstr "튜토리얼을 시작하세요" +msgstr "튜토리얼 시작" #: src/app/main/ui/dashboard/projects.cljs -#, unused msgid "dasboard.tutorial-hero.title" -msgstr "실습용 튜토리얼" +msgstr "실습 튜토리얼" #: src/app/main/ui/dashboard/projects.cljs -#, unused msgid "dasboard.walkthrough-hero.info" -msgstr "펜팟을 둘러보고 주요 기능에 대한 정보를 습득하십시오." +msgstr "Penpot을 둘러보고 주요 기능을 살펴보세요." #: src/app/main/ui/dashboard/projects.cljs -#, unused msgid "dasboard.walkthrough-hero.start" -msgstr "투어를 시작해요" +msgstr "투어 시작" #: src/app/main/ui/dashboard/projects.cljs -#, unused msgid "dasboard.walkthrough-hero.title" msgstr "인터페이스 둘러보기" #: src/app/main/ui/dashboard/file_menu.cljs:208 msgid "dashboard-restore-file-confirmation.description" -msgstr "%s 파일을 복원하려 합니다." +msgstr "%s 파일이 복원됩니다." #: src/app/main/ui/dashboard/file_menu.cljs:207 msgid "dashboard-restore-file-confirmation.title" @@ -323,23 +333,23 @@ msgstr "파일 복원" #: src/app/main/ui/settings/access_tokens.cljs:103 msgid "dashboard.access-tokens.copied-success" -msgstr "복사된 토큰" +msgstr "토큰 복사됨" #: src/app/main/ui/settings/access_tokens.cljs:189 msgid "dashboard.access-tokens.create" -msgstr "새로운 토큰 생성하기" +msgstr "새 토큰 생성" #: src/app/main/ui/settings/access_tokens.cljs:64 msgid "dashboard.access-tokens.create.success" -msgstr "엑세스 토큰이 성공적으로 생성되었습니다." +msgstr "액세스 토큰이 성공적으로 생성되었습니다." #: src/app/main/ui/settings/access_tokens.cljs:286 msgid "dashboard.access-tokens.empty.add-one" -msgstr "\"새로운 토큰 생성하기\" 버튼을 눌러 토큰을 생성하십시오." +msgstr "\"새 토큰 생성\" 버튼을 눌러 토큰을 생성하세요." #: src/app/main/ui/settings/access_tokens.cljs:285 msgid "dashboard.access-tokens.empty.no-access-tokens" -msgstr "현재 가지고 있는 토큰이 없습니다." +msgstr "아직 생성된 토큰이 없습니다." #: src/app/main/ui/settings/access_tokens.cljs:135 msgid "dashboard.access-tokens.expiration-180-days" @@ -359,73 +369,78 @@ msgstr "90일" #: src/app/main/ui/settings/access_tokens.cljs:131 msgid "dashboard.access-tokens.expiration-never" -msgstr "기한 없음" +msgstr "만료 없음" #: src/app/main/ui/settings/access_tokens.cljs:268 msgid "dashboard.access-tokens.expired-on" -msgstr "%s에 만료되었습니다" +msgstr "%s에 만료됨" #: src/app/main/ui/settings/access_tokens.cljs:269 msgid "dashboard.access-tokens.expires-on" -msgstr "%s에 만료됩니다" +msgstr "%s에 만료 예정" #: src/app/main/ui/settings/access_tokens.cljs:267 msgid "dashboard.access-tokens.no-expiration" -msgstr "만료 기한 없음" +msgstr "만료일 없음" #: src/app/main/ui/settings/access_tokens.cljs:184 msgid "dashboard.access-tokens.personal" -msgstr "개인용 엑세스 토큰" +msgstr "개인용 액세스 토큰" #: src/app/main/ui/settings/access_tokens.cljs:185 msgid "dashboard.access-tokens.personal.description" msgstr "" -"개인용 엑세스 토큰은 펜팟의 로그인/암호 인증 시스템의 대안으로 사용되며, 어플리케이션의 펜팟 내부 API 엑세스를 위해 사용될 수 " -"있습니다" +"개인용 엑세스 토큰은 로그인/비밀번호 기반 인증을 대신할 수 있는 인증 수단이" +"고, 이를 통해 애플리케이션이 내부 Penpot API에 접근할 수 있습니다" #: src/app/main/ui/settings/access_tokens.cljs:142 msgid "dashboard.access-tokens.token-will-expire" -msgstr "토큰은 %s에 만료 예정입니다" +msgstr "해당 토큰은 %s에 만료됩니다" #: src/app/main/ui/settings/access_tokens.cljs:143 msgid "dashboard.access-tokens.token-will-not-expire" -msgstr "토큰의 만료 기한이 없습니다" +msgstr "해당 토큰은 만료일이 없습니다" #: src/app/main/ui/dashboard/placeholder.cljs:41 msgid "dashboard.add-file" msgstr "파일 추가" -#: src/app/main/ui/dashboard/file_menu.cljs:322, src/app/main/ui/workspace/main_menu.cljs:650 +#: src/app/main/ui/dashboard/file_menu.cljs:322, +#: src/app/main/ui/workspace/main_menu.cljs:650 msgid "dashboard.add-shared" -msgstr "공유 라이브러리로 추가하기" +msgstr "공유 라이브러리로 추가" #: src/app/main/ui/settings/profile.cljs:75 msgid "dashboard.change-email" -msgstr "이메일을 변경해요" +msgstr "이메일 변경" #: src/app/main/ui/dashboard/deleted.cljs:313 msgid "dashboard.clear-trash-button" msgstr "휴지통 비우기" -#: src/app/main/data/dashboard.cljs:330, src/app/main/data/dashboard.cljs:565, src/app/main/data/workspace/pages.cljs:198 +#: src/app/main/data/dashboard.cljs:330, src/app/main/data/dashboard.cljs:565, +#: src/app/main/data/workspace/pages.cljs:198 msgid "dashboard.copy-suffix" msgstr "(복사)" #: src/app/main/ui/dashboard/sidebar.cljs:340 msgid "dashboard.create-new-team" -msgstr "새 팀을 생성해요" +msgstr "새 팀 생성" #: src/app/main/ui/workspace/main_menu.cljs:661 msgid "dashboard.create-version-menu" msgstr "이 버전 고정" -#: src/app/main/ui/components/context_menu_a11y.cljs:300, src/app/main/ui/dashboard/sidebar.cljs:638 +#: src/app/main/ui/components/context_menu_a11y.cljs:300, +#: src/app/main/ui/dashboard/sidebar.cljs:638 msgid "dashboard.default-team-name" -msgstr "당신의 펜팟" +msgstr "내 Penpot" #: src/app/main/ui/dashboard/deleted.cljs:262 msgid "dashboard.delete-all-forever-confirmation.description" -msgstr "삭제된 모든 프로젝트와 파일을 영구적으로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." +msgstr "" +"삭제된 모든 프로젝트와 파일을 영구적으로 삭제하시겠습니까? 이 작업은 되돌릴 " +"수 없습니다." #: src/app/main/ui/dashboard/file_menu.cljs:221 msgid "dashboard.delete-file-forever-confirmation.description" @@ -433,50 +448,57 @@ msgstr "%s 파일을 영구적으로 삭제하시겠습니까? 이 작업은 되 #: src/app/main/data/dashboard.cljs:778 msgid "dashboard.delete-files-success-notification" -msgstr "%s 파일이 성공적으로 삭제되었습니다." +msgstr "%s개 파일이 성공적으로 삭제되었습니다." -#: src/app/main/ui/dashboard/deleted.cljs:51, src/app/main/ui/dashboard/deleted.cljs:53, src/app/main/ui/dashboard/deleted.cljs:261, src/app/main/ui/dashboard/deleted.cljs:263, src/app/main/ui/dashboard/file_menu.cljs:220, src/app/main/ui/dashboard/file_menu.cljs:222 +#: src/app/main/ui/dashboard/deleted.cljs:51, +#: src/app/main/ui/dashboard/deleted.cljs:53, +#: src/app/main/ui/dashboard/deleted.cljs:261, +#: src/app/main/ui/dashboard/deleted.cljs:263, +#: src/app/main/ui/dashboard/file_menu.cljs:220, +#: src/app/main/ui/dashboard/file_menu.cljs:222 msgid "dashboard.delete-forever-confirmation.title" -msgstr "영구적으로 삭제" +msgstr "영구 삭제" #: src/app/main/ui/dashboard/deleted.cljs:85 msgid "dashboard.delete-project-button" -msgstr "프로젝트 제거" +msgstr "프로젝트 삭제" #: src/app/main/ui/dashboard/deleted.cljs:52 msgid "dashboard.delete-project-forever-confirmation.description" msgstr "" -"%s 프로젝트를 영구적으로 삭제하시겠습니까? 이 프로젝트와 안에 포함된 모든 파일이 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 " -"없습니다." +"%s 프로젝트를 영구적으로 삭제하시겠습니까? 해당 프로젝트와 그 안에 포함된 모" +"든 파일이 함께 영구 삭제됩니다. 이 작업은 되돌릴 수 없습니다." #: src/app/main/data/dashboard.cljs:777, src/app/main/data/dashboard.cljs:811 msgid "dashboard.delete-success-notification" -msgstr "%s가 영구적으로 삭제되었습니다." +msgstr "%s가 성공적으로 삭제되었습니다." #: src/app/main/ui/dashboard/sidebar.cljs:495 msgid "dashboard.delete-team" -msgstr "팀을 해체해요" +msgstr "팀 삭제" #: src/app/main/ui/dashboard/deleted.cljs:327 msgid "dashboard.deleted.empty-state-description" -msgstr "휴지통이 비어 있습니다. 삭제된 파일과 프로젝트가 여기에 표시됩니다." +msgstr "휴지통이 비어있습니다. 삭제된 파일 및 프로젝트가 여기에 표시됩니다." -#: src/app/main/ui/dashboard/file_menu.cljs:328, src/app/main/ui/workspace/main_menu.cljs:690 +#: src/app/main/ui/dashboard/file_menu.cljs:328, +#: src/app/main/ui/workspace/main_menu.cljs:690 msgid "dashboard.download-binary-file" -msgstr "펜팟 파일(.penpot)을 다운로드해요" +msgstr "Penpot 파일(.penpot) 다운로드" -#: src/app/main/ui/dashboard/file_menu.cljs:321, src/app/main/ui/workspace/main_menu.cljs:712 -#, unused +#: src/app/main/ui/dashboard/file_menu.cljs:321, +#: src/app/main/ui/workspace/main_menu.cljs:712 msgid "dashboard.download-standard-file" -msgstr "표준 파일(.svg + .json)을 다운로드해요" +msgstr "표준 파일(.svg + .json) 다운로드" -#: src/app/main/ui/dashboard/file_menu.cljs:304, src/app/main/ui/dashboard/project_menu.cljs:92 +#: src/app/main/ui/dashboard/file_menu.cljs:304, +#: src/app/main/ui/dashboard/project_menu.cljs:92 msgid "dashboard.duplicate" -msgstr "복제해요" +msgstr "복제" #: src/app/main/ui/dashboard/file_menu.cljs:271 msgid "dashboard.duplicate-multi" -msgstr "%파일을 복제해요" +msgstr "%s개 파일 복제" #: src/app/main/ui/dashboard/placeholder.cljs:111 msgid "dashboard.empty-placeholder-libraries-title" @@ -484,43 +506,46 @@ msgstr "아직 라이브러리가 없습니다." #: src/app/main/ui/dashboard/file_menu.cljs:280 msgid "dashboard.export-binary-multi" -msgstr "%s 펜팟 파일 (.penpot) 다운로드 하기" +msgstr "%s Penpot 파일 (.penpot) 다운로드" #: src/app/main/ui/workspace/main_menu.cljs:698 msgid "dashboard.export-frames" -msgstr "대지를 PDF로 내보내요" +msgstr "보드를 PDF로 내보내기" #: src/app/main/ui/exports/assets.cljs:201 msgid "dashboard.export-frames.title" -msgstr "PDF로 내보내요" +msgstr "PDF로 내보내기" #: src/app/main/ui/workspace/main_menu.cljs:679 msgid "dashboard.export-shapes" -msgstr "내보내요" +msgstr "내보내기" #: src/app/main/ui/dashboard/sidebar.cljs:858 msgid "dashboard.no-projects-placeholder" msgstr "고정된 프로젝트가 여기에 표시됩니다" -#: src/app/main/ui/dashboard/deleted.cljs:62, src/app/main/ui/dashboard/projects.cljs:57 +#: src/app/main/ui/dashboard/deleted.cljs:62, +#: src/app/main/ui/dashboard/projects.cljs:57 msgid "dashboard.projects-title" msgstr "프로젝트" #: src/app/main/ui/dashboard/deleted.cljs:274 msgid "dashboard.restore-all-confirmation.description" -msgstr "모든 프로젝트와 파일을 복원하려 합니다. 시간이 다소 소요될 수 있습니다." +msgstr "" +"모든 프로젝트와 파일을 복원하려 합니다. 시간이 다소 소요될 수 있습니다." #: src/app/main/ui/dashboard/deleted.cljs:273 msgid "dashboard.restore-all-confirmation.title" -msgstr "모든 파일과 프로젝트 복원" +msgstr "모든 프로젝트 및 파일 복원" -#: src/app/main/ui/dashboard/sidebar.cljs:259, src/app/main/ui/dashboard/sidebar.cljs:260 +#: src/app/main/ui/dashboard/sidebar.cljs:259, +#: src/app/main/ui/dashboard/sidebar.cljs:260 msgid "dashboard.search-placeholder" msgstr "검색…" #: src/app/main/ui/dashboard/search.cljs:72 msgid "dashboard.searching-for" -msgstr "“%s” 찾는 중…" +msgstr "\"%s\" 검색 중…" #: src/app/main/ui/dashboard/team.cljs:1344 msgid "dashboard.team-projects" @@ -540,21 +565,24 @@ msgstr "복구 토큰이 유효하지 않습니다." #: src/app/main/ui/inspect/attributes/blur.cljs:26 msgid "inspect.attributes.blur" -msgstr "흐림" +msgstr "블러" #: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:126 msgid "inspect.attributes.blur.value" msgstr "값" -#: src/app/main/ui/inspect/attributes/common.cljs:93, src/app/main/ui/inspect/right_sidebar.cljs:98 +#: src/app/main/ui/inspect/attributes/common.cljs:93, +#: src/app/main/ui/inspect/right_sidebar.cljs:98 msgid "inspect.attributes.color.hex" msgstr "HEX" -#: src/app/main/ui/inspect/attributes/common.cljs:95, src/app/main/ui/inspect/right_sidebar.cljs:102 +#: src/app/main/ui/inspect/attributes/common.cljs:95, +#: src/app/main/ui/inspect/right_sidebar.cljs:102 msgid "inspect.attributes.color.hsla" msgstr "HSLA" -#: src/app/main/ui/inspect/attributes/common.cljs:94, src/app/main/ui/inspect/right_sidebar.cljs:100 +#: src/app/main/ui/inspect/attributes/common.cljs:94, +#: src/app/main/ui/inspect/right_sidebar.cljs:100 msgid "inspect.attributes.color.rgba" msgstr "RGBA" @@ -562,126 +590,116 @@ msgstr "RGBA" msgid "inspect.attributes.fill" msgstr "채우기" -#: src/app/main/ui/inspect/attributes/common.cljs:78, src/app/main/ui/inspect/styles/rows/color_properties_row.cljs:126 +#: src/app/main/ui/inspect/attributes/common.cljs:78, +#: src/app/main/ui/inspect/styles/rows/color_properties_row.cljs:126 msgid "inspect.attributes.image.download" -msgstr "소스 이미지 다운로드" +msgstr "원본 이미지 다운로드" #: src/app/main/ui/inspect/attributes/image.cljs:39 -#, unused msgid "inspect.attributes.image.height" msgstr "높이" #: src/app/main/ui/inspect/attributes/image.cljs:32 -#, unused msgid "inspect.attributes.image.width" -msgstr "폭" +msgstr "너비" #: src/app/main/ui/inspect/attributes/layout.cljs -#, unused msgid "inspect.attributes.layout" msgstr "레이아웃" #: src/app/main/ui/inspect/attributes/layout.cljs -#, unused msgid "inspect.attributes.layout.height" msgstr "높이" #: src/app/main/ui/inspect/attributes/layout.cljs -#, unused msgid "inspect.attributes.layout.left" msgstr "왼쪽" -#: src/app/main/ui/inspect/attributes/layout.cljs, src/app/main/ui/inspect/attributes/layout.cljs -#, unused +#: src/app/main/ui/inspect/attributes/layout.cljs, +#: src/app/main/ui/inspect/attributes/layout.cljs msgid "inspect.attributes.layout.radius" msgstr "반지름" #: src/app/main/ui/inspect/attributes/layout.cljs -#, unused msgid "inspect.attributes.layout.rotation" msgstr "회전" #: src/app/main/ui/inspect/attributes/layout.cljs -#, unused msgid "inspect.attributes.layout.top" -msgstr "위" +msgstr "위쪽" #: src/app/main/ui/inspect/attributes/layout.cljs -#, unused msgid "inspect.attributes.layout.width" -msgstr "폭" +msgstr "너비" #: src/app/main/ui/inspect/attributes/shadow.cljs:65 msgid "inspect.attributes.shadow" msgstr "그림자" -#: src/app/main/ui/inspect/attributes/geometry.cljs:46, src/app/main/ui/inspect/styles/style_box.cljs:22 +#: src/app/main/ui/inspect/attributes/geometry.cljs:46, +#: src/app/main/ui/inspect/styles/style_box.cljs:22 msgid "inspect.attributes.size" -msgstr "사이즈와 위치" +msgstr "크기 및 위치" #: src/app/main/ui/inspect/attributes/stroke.cljs:90 msgid "inspect.attributes.stroke" msgstr "선" -#, permanent, unused msgid "inspect.attributes.stroke.alignment.center" msgstr "중앙" -#, permanent, unused msgid "inspect.attributes.stroke.alignment.inner" msgstr "안쪽" -#, permanent, unused msgid "inspect.attributes.stroke.alignment.outer" msgstr "바깥쪽" -#, unused msgid "inspect.attributes.stroke.style.dotted" msgstr "점선" -#, unused msgid "inspect.attributes.stroke.style.mixed" msgstr "혼합" -#, unused msgid "inspect.attributes.stroke.style.solid" -msgstr "단색" +msgstr "실선" #: src/app/main/ui/inspect/attributes/stroke.cljs -#, unused msgid "inspect.attributes.stroke.width" -msgstr "폭" +msgstr "두께" -#: src/app/main/ui/inspect/attributes/text.cljs:53, src/app/main/ui/inspect/attributes/text.cljs:159 +#: src/app/main/ui/inspect/attributes/text.cljs:53, +#: src/app/main/ui/inspect/attributes/text.cljs:159 msgid "inspect.attributes.typography" msgstr "타이포그래피" #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:308 msgid "inspect.attributes.typography.font-family" -msgstr "폰트 패밀리" +msgstr "글꼴 모음" -#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:326, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:332 +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:326, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:332 msgid "inspect.attributes.typography.font-size" -msgstr "폰트 사이즈" +msgstr "글꼴 크기" #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:343 msgid "inspect.attributes.typography.font-style" -msgstr "폰트 스타일" +msgstr "글꼴 스타일" #: src/app/main/ui/inspect/attributes/text.cljs:113 msgid "inspect.attributes.typography.text-decoration.underline" msgstr "밑줄" #: src/app/main/ui/inspect/attributes/text.cljs:153 -#, unused msgid "inspect.attributes.typography.text-transform" -msgstr "텍스트 변형" +msgstr "텍스트 변환" -#: src/app/main/ui/inspect/attributes/text.cljs:123, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:441 +#: src/app/main/ui/inspect/attributes/text.cljs:123, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:441 msgid "inspect.attributes.typography.text-transform.lowercase" msgstr "소문자" -#: src/app/main/ui/inspect/attributes/text.cljs:126, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:433 +#: src/app/main/ui/inspect/attributes/text.cljs:126, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:433 msgid "inspect.attributes.typography.text-transform.uppercase" msgstr "대문자" @@ -695,7 +713,7 @@ msgstr "컴포넌트" #: src/app/main/ui/inspect/right_sidebar.cljs:145 msgid "inspect.tabs.code.selected.curve" -msgstr "커브" +msgstr "곡선" #: src/app/main/ui/inspect/right_sidebar.cljs:146 msgid "inspect.tabs.code.selected.frame" @@ -715,7 +733,7 @@ msgstr "마스크" #: src/app/main/ui/inspect/right_sidebar.cljs:150 msgid "inspect.tabs.code.selected.path" -msgstr "패스" +msgstr "경로" #: src/app/main/ui/inspect/right_sidebar.cljs:151 msgid "inspect.tabs.code.selected.rect" @@ -735,152 +753,236 @@ msgstr "단축키" #: src/app/main/data/common.cljs:90, src/app/main/ui/dashboard/import.cljs:530 msgid "labels.accept" -msgstr "허가" +msgstr "수락" #: src/app/main/ui/dashboard/team.cljs:1223 msgid "labels.active" -msgstr "활성화" +msgstr "활성" #: src/app/main/ui/dashboard/fonts.cljs:186 msgid "labels.add-custom-font" msgstr "커스텀 폰트 추가" -#: src/app/main/ui/dashboard/team.cljs:134, src/app/main/ui/dashboard/team.cljs:320, src/app/main/ui/dashboard/team.cljs:565, src/app/main/ui/dashboard/team.cljs:595, src/app/main/ui/onboarding/team_choice.cljs:58 +#: src/app/main/ui/dashboard/team.cljs:134, +#: src/app/main/ui/dashboard/team.cljs:320, +#: src/app/main/ui/dashboard/team.cljs:565, +#: src/app/main/ui/dashboard/team.cljs:595, +#: src/app/main/ui/onboarding/team_choice.cljs:58 msgid "labels.admin" -msgstr "관리자" +msgstr "관리자(Admin)" -#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:92, src/app/main/ui/workspace/tokens/management/context_menu.cljs:129, src/app/main/ui/workspace/tokens/management/token_pill.cljs:117 +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:92, +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:129, +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:117 msgid "labels.all" msgstr "전체" #: src/app/main/ui/auth/register.cljs:257 msgid "labels.and" -msgstr "그리고" +msgstr "및" #: src/app/main/ui/onboarding/team_choice.cljs:186 -#, unused msgid "labels.back" msgstr "뒤로" #: src/app/main/ui/static.cljs:296 msgid "labels.bad-gateway.main-message" -msgstr "잘못된 경로" +msgstr "게이트웨이 오류가 발생했습니다" -#: src/app/main/data/common.cljs:119, src/app/main/ui/dashboard/change_owner.cljs:64, src/app/main/ui/dashboard/import.cljs:515, src/app/main/ui/dashboard/team.cljs:780, src/app/main/ui/dashboard/team.cljs:1122, src/app/main/ui/delete_shared.cljs:38, src/app/main/ui/exports/assets.cljs:163, src/app/main/ui/exports/files.cljs:168, src/app/main/ui/settings/access_tokens.cljs:175, src/app/main/ui/viewer/share_link.cljs:208, src/app/main/ui/workspace/sidebar/assets/groups.cljs:159, src/app/main/ui/workspace/tokens/export/modal.cljs:44, src/app/main/ui/workspace/tokens/import/modal.cljs:269, src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:297, src/app/main/ui/workspace/tokens/settings/menu.cljs:105, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:245 +#: src/app/main/data/common.cljs:119, +#: src/app/main/ui/dashboard/change_owner.cljs:64, +#: src/app/main/ui/dashboard/import.cljs:515, +#: src/app/main/ui/dashboard/team.cljs:780, +#: src/app/main/ui/dashboard/team.cljs:1122, +#: src/app/main/ui/delete_shared.cljs:38, +#: src/app/main/ui/exports/assets.cljs:163, +#: src/app/main/ui/exports/files.cljs:168, +#: src/app/main/ui/settings/access_tokens.cljs:175, +#: src/app/main/ui/viewer/share_link.cljs:208, +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:159, +#: src/app/main/ui/workspace/tokens/export/modal.cljs:44, +#: src/app/main/ui/workspace/tokens/import/modal.cljs:269, +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:297, +#: src/app/main/ui/workspace/tokens/settings/menu.cljs:105, +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:245 msgid "labels.cancel" msgstr "취소" -#: src/app/main/data/common.cljs:96, src/app/main/ui/dashboard/comments.cljs:103, src/app/main/ui/dashboard/projects.cljs:101, src/app/main/ui/delete_shared.cljs:105, src/app/main/ui/ds/product/panel_title.cljs:32, src/app/main/ui/exports/files.cljs:186, src/app/main/ui/settings/access_tokens.cljs:170, src/app/main/ui/settings/subscription.cljs:353, src/app/main/ui/viewer/login.cljs:71, src/app/main/ui/viewer/share_link.cljs:179, src/app/main/ui/workspace/libraries.cljs:643, src/app/main/ui/workspace/sidebar/layers.cljs:301, src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:43, src/app/main/ui/workspace/sidebar.cljs:264, src/app/main/ui/workspace/tokens/export.cljs:25, src/app/main/ui/workspace/tokens/import.cljs:19, src/app/main/ui/workspace/tokens/management/forms/modals.cljs:99, src/app/main/ui/workspace/tokens/remapping_modal.cljs:79, src/app/main/ui/workspace/tokens/settings/menu.cljs:78, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:62, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:147, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:461 +#: src/app/main/data/common.cljs:96, +#: src/app/main/ui/dashboard/comments.cljs:103, +#: src/app/main/ui/dashboard/projects.cljs:101, +#: src/app/main/ui/delete_shared.cljs:105, +#: src/app/main/ui/ds/product/panel_title.cljs:32, +#: src/app/main/ui/exports/files.cljs:186, +#: src/app/main/ui/settings/access_tokens.cljs:170, +#: src/app/main/ui/settings/subscription.cljs:353, +#: src/app/main/ui/viewer/login.cljs:71, +#: src/app/main/ui/viewer/share_link.cljs:179, +#: src/app/main/ui/workspace/libraries.cljs:643, +#: src/app/main/ui/workspace/sidebar/layers.cljs:301, +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:43, +#: src/app/main/ui/workspace/sidebar.cljs:264, +#: src/app/main/ui/workspace/tokens/export.cljs:25, +#: src/app/main/ui/workspace/tokens/import.cljs:19, +#: src/app/main/ui/workspace/tokens/management/forms/modals.cljs:99, +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:79, +#: src/app/main/ui/workspace/tokens/settings/menu.cljs:78, +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:62, +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:147, +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:461 msgid "labels.close" msgstr "닫기" -#: src/app/main/ui/inspect/right_sidebar.cljs:112, src/app/main/ui/inspect/right_sidebar.cljs:117 +#: src/app/main/ui/inspect/right_sidebar.cljs:112, +#: src/app/main/ui/inspect/right_sidebar.cljs:117 msgid "labels.code" msgstr "코드" -#: src/app/main/ui/viewer/comments.cljs:70, src/app/main/ui/workspace/comments.cljs:128 +#: src/app/main/ui/viewer/comments.cljs:70, +#: src/app/main/ui/workspace/comments.cljs:128 msgid "labels.comments" -msgstr "코멘트" +msgstr "댓글" -#: src/app/main/ui/dashboard/sidebar.cljs:935, src/app/main/ui/workspace/main_menu.cljs:144 +#: src/app/main/ui/dashboard/sidebar.cljs:935, +#: src/app/main/ui/workspace/main_menu.cljs:144 msgid "labels.community" msgstr "커뮤니티" #: src/app/main/ui/settings/password.cljs:93 msgid "labels.confirm-password" -msgstr "비밀번호 확인하기" +msgstr "비밀번호 확인" -#: src/app/main/ui/auth/login.cljs:204, src/app/main/ui/dashboard/deleted.cljs:43, src/app/main/ui/dashboard/deleted.cljs:275, src/app/main/ui/dashboard/file_menu.cljs:209, src/app/main/ui/dashboard/import.cljs:521, src/app/main/ui/dashboard/team.cljs:787, src/app/main/ui/exports/files.cljs:173, src/app/main/ui/onboarding/newsletter.cljs:106, src/app/main/ui/settings/subscription.cljs:279, src/app/main/ui/settings/subscription.cljs:313 +#: src/app/main/ui/auth/login.cljs:204, +#: src/app/main/ui/dashboard/deleted.cljs:43, +#: src/app/main/ui/dashboard/deleted.cljs:275, +#: src/app/main/ui/dashboard/file_menu.cljs:209, +#: src/app/main/ui/dashboard/import.cljs:521, +#: src/app/main/ui/dashboard/team.cljs:787, +#: src/app/main/ui/exports/files.cljs:173, +#: src/app/main/ui/onboarding/newsletter.cljs:106, +#: src/app/main/ui/settings/subscription.cljs:279, +#: src/app/main/ui/settings/subscription.cljs:313 msgid "labels.continue" msgstr "계속하기" #: src/app/main/ui/dashboard/team.cljs:650 msgid "labels.copy-invitation-link" -msgstr "링크 복사하기" +msgstr "링크 복사" -#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:167, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:203 +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:167, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:203 msgid "labels.create" -msgstr "생성하기" +msgstr "생성" -#: src/app/main/ui/dashboard/team_form.cljs:100, src/app/main/ui/dashboard/team_form.cljs:120 +#: src/app/main/ui/dashboard/team_form.cljs:100, +#: src/app/main/ui/dashboard/team_form.cljs:120 msgid "labels.create-team" -msgstr "새로운 팀 만들기" +msgstr "새 팀 생성" #: src/app/main/ui/dashboard/team_form.cljs:112 msgid "labels.create-team.placeholder" -msgstr "새로운 팀명 입력하세요" +msgstr "새 팀 이름을 입력하세요" -#, unused msgid "labels.custom-fonts" -msgstr "커스텀 폰트" +msgstr "사용자 지정 글꼴" #: src/app/main/ui/settings/sidebar.cljs:84 msgid "labels.dashboard" msgstr "대시보드" -#: src/app/main/ui/dashboard/file_menu.cljs:336, src/app/main/ui/dashboard/fonts.cljs:267, src/app/main/ui/dashboard/fonts.cljs:343, src/app/main/ui/dashboard/fonts.cljs:357, src/app/main/ui/dashboard/project_menu.cljs:115, src/app/main/ui/dashboard/team.cljs:1158, src/app/main/ui/settings/access_tokens.cljs:196, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:223, src/app/main/ui/workspace/sidebar/versions.cljs:216, src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:290, src/app/main/ui/workspace/tokens/management/node_context_menu.cljs:82, src/app/main/ui/workspace/tokens/sets/context_menu.cljs:66, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:381 +#: src/app/main/ui/dashboard/file_menu.cljs:336, +#: src/app/main/ui/dashboard/fonts.cljs:267, +#: src/app/main/ui/dashboard/fonts.cljs:343, +#: src/app/main/ui/dashboard/fonts.cljs:357, +#: src/app/main/ui/dashboard/project_menu.cljs:115, +#: src/app/main/ui/dashboard/team.cljs:1158, +#: src/app/main/ui/settings/access_tokens.cljs:196, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:223, +#: src/app/main/ui/workspace/sidebar/versions.cljs:216, +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:290, +#: src/app/main/ui/workspace/tokens/management/node_context_menu.cljs:82, +#: src/app/main/ui/workspace/tokens/sets/context_menu.cljs:66, +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:381 msgid "labels.delete" -msgstr "삭제하기" +msgstr "삭제" #: src/app/main/ui/comments.cljs:997 msgid "labels.delete-comment" -msgstr "코멘트 삭제하기" +msgstr "댓글 삭제" #: src/app/main/ui/comments.cljs:919 msgid "labels.delete-comment-thread" -msgstr "스레드 제거하기" +msgstr "스레드 삭제" #: src/app/main/ui/dashboard/team.cljs:941 msgid "labels.delete-invitation" -msgstr "초대장 제거하기" +msgstr "초대 삭제" -#: src/app/main/ui/dashboard/file_menu.cljs:30, src/app/main/ui/dashboard/files.cljs:80, src/app/main/ui/dashboard/files.cljs:179, src/app/main/ui/dashboard/projects.cljs:229, src/app/main/ui/dashboard/projects.cljs:233, src/app/main/ui/dashboard/sidebar.cljs:820 +#: src/app/main/ui/dashboard/file_menu.cljs:30, +#: src/app/main/ui/dashboard/files.cljs:80, +#: src/app/main/ui/dashboard/files.cljs:179, +#: src/app/main/ui/dashboard/projects.cljs:229, +#: src/app/main/ui/dashboard/projects.cljs:233, +#: src/app/main/ui/dashboard/sidebar.cljs:820 msgid "labels.drafts" msgstr "초안" -#: src/app/main/ui/comments.cljs:993, src/app/main/ui/dashboard/fonts.cljs:264, src/app/main/ui/dashboard/team.cljs:1156, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:218, src/app/main/ui/workspace/tokens/themes.cljs:52 +#: src/app/main/ui/comments.cljs:993, src/app/main/ui/dashboard/fonts.cljs:264, +#: src/app/main/ui/dashboard/team.cljs:1156, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:218, +#: src/app/main/ui/workspace/tokens/themes.cljs:52 msgid "labels.edit" msgstr "편집" -#, unused msgid "labels.edit-file" msgstr "파일 편집" -#: src/app/main/ui/dashboard/team.cljs:132, src/app/main/ui/dashboard/team.cljs:317, src/app/main/ui/dashboard/team.cljs:566, src/app/main/ui/dashboard/team.cljs:599, src/app/main/ui/onboarding/team_choice.cljs:57 +#: src/app/main/ui/dashboard/team.cljs:132, +#: src/app/main/ui/dashboard/team.cljs:317, +#: src/app/main/ui/dashboard/team.cljs:566, +#: src/app/main/ui/dashboard/team.cljs:599, +#: src/app/main/ui/onboarding/team_choice.cljs:57 msgid "labels.editor" msgstr "작성자" #: src/app/main/ui/dashboard/team.cljs:668 msgid "labels.expired-invitation" -msgstr "기한이 만료된" +msgstr "만료됨" -#: src/app/main/ui/exports/assets.cljs:172, src/app/main/ui/workspace/tokens/sidebar.cljs:134 +#: src/app/main/ui/exports/assets.cljs:172, +#: src/app/main/ui/workspace/tokens/sidebar.cljs:134 msgid "labels.export" msgstr "내보내기" #: src/app/main/ui/dashboard/fonts.cljs:432 msgid "labels.font-family" -msgstr "폰트 패밀리" +msgstr "글꼴 모음" -#, unused msgid "labels.font-providers" -msgstr "폰트 공급자" +msgstr "글꼴 제공자" #: src/app/main/ui/dashboard/fonts.cljs:433 msgid "labels.font-variants" msgstr "스타일" -#: src/app/main/ui/dashboard/fonts.cljs:61, src/app/main/ui/dashboard/sidebar.cljs:833 +#: src/app/main/ui/dashboard/fonts.cljs:61, +#: src/app/main/ui/dashboard/sidebar.cljs:833 msgid "labels.fonts" -msgstr "폰트" +msgstr "글꼴" -#: src/app/main/ui/auth/recovery_request.cljs:104, src/app/main/ui/auth/register.cljs:359, src/app/main/ui/static.cljs:175, src/app/main/ui/viewer/login.cljs:113 +#: src/app/main/ui/auth/recovery_request.cljs:104, +#: src/app/main/ui/auth/register.cljs:359, src/app/main/ui/static.cljs:175, +#: src/app/main/ui/viewer/login.cljs:113 msgid "labels.go-back" msgstr "뒤로 가기" -#: src/app/main/ui/dashboard/sidebar.cljs:887, src/app/main/ui/workspace/main_menu.cljs:136, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1317, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1345, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1533 +#: src/app/main/ui/dashboard/sidebar.cljs:887, +#: src/app/main/ui/workspace/main_menu.cljs:136, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1317, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1345, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1533 msgid "labels.help-center" -msgstr "고객센터" +msgstr "도움말 센터" #: src/app/main/ui/dashboard/team.cljs:1224 msgid "labels.inactive" @@ -900,9 +1002,10 @@ msgstr "프로젝트" #: src/app/main/ui/dashboard/deleted.cljs:208 msgid "labels.recent" -msgstr "최근" +msgstr "최근 항목" -#: src/app/main/ui/workspace/sidebar/layers.cljs:420, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:787 +#: src/app/main/ui/workspace/sidebar/layers.cljs:420, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:787 msgid "labels.search" msgstr "검색" @@ -910,33 +1013,41 @@ msgstr "검색" msgid "labels.view-only" msgstr "보기 전용" -#: src/app/main/ui/dashboard/team.cljs:131, src/app/main/ui/dashboard/team.cljs:314, src/app/main/ui/dashboard/team.cljs:567, src/app/main/ui/dashboard/team.cljs:603, src/app/main/ui/onboarding/team_choice.cljs:56 +#: src/app/main/ui/dashboard/team.cljs:131, +#: src/app/main/ui/dashboard/team.cljs:314, +#: src/app/main/ui/dashboard/team.cljs:567, +#: src/app/main/ui/dashboard/team.cljs:603, +#: src/app/main/ui/onboarding/team_choice.cljs:56 msgid "labels.viewer" msgstr "뷰어" -#: src/app/main/ui/dashboard/sidebar.cljs:459, src/app/main/ui/dashboard/team.cljs:103, src/app/main/ui/dashboard/team.cljs:113, src/app/main/ui/dashboard/team.cljs:1134 +#: src/app/main/ui/dashboard/sidebar.cljs:459, +#: src/app/main/ui/dashboard/team.cljs:103, +#: src/app/main/ui/dashboard/team.cljs:113, +#: src/app/main/ui/dashboard/team.cljs:1134 msgid "labels.webhooks" msgstr "웹훅" #: src/app/main/ui/comments.cljs:838 msgid "labels.write-new-comment" -msgstr "새 코멘트 쓰기" +msgstr "새 댓글 작성" -#: src/app/main/data/media.cljs:51, src/app/main/data/workspace/media.cljs:228, src/app/main/data/workspace/media.cljs:443 +#: src/app/main/data/media.cljs:51, src/app/main/data/workspace/media.cljs:228, +#: src/app/main/data/workspace/media.cljs:443 msgid "media.loading" -msgstr "이미지 로딩중…" +msgstr "이미지 로드 중…" #: src/app/main/data/common.cljs:120 msgid "modals.add-shared-confirm.accept" -msgstr "공유된 라이브러리로 추가" +msgstr "공유 라이브러리로 추가" #: src/app/main/data/common.cljs:117 msgid "modals.add-shared-confirm.message" -msgstr " " +msgstr "\"%s\"를 공유 라이브러리로 추가" #: src/app/main/ui/settings/change_email.cljs:109 msgid "modals.change-email.confirm-email" -msgstr "새 이메일 인증하기" +msgstr "새 이메일 확인" #: src/app/main/ui/settings/change_email.cljs:102 msgid "modals.change-email.new-email" @@ -944,83 +1055,86 @@ msgstr "새 이메일" #: src/app/main/ui/settings/change_email.cljs:117 msgid "modals.change-email.submit" -msgstr "이메일 변경하기" +msgstr "이메일 변경" #: src/app/main/ui/settings/change_email.cljs:90 msgid "modals.change-email.title" -msgstr "이메일을 변경하세요" +msgstr "이메일 주소 변경" #: src/app/main/ui/dashboard/team.cljs:1127 msgid "modals.create-webhook.submit-label" -msgstr "웹훅 만들기" +msgstr "웹훅 생성" #: src/app/main/ui/dashboard/team.cljs:1092 msgid "modals.create-webhook.title" -msgstr "웹훅 생성하기" +msgstr "웹훅 생성" #: src/app/main/ui/comments.cljs:889 msgid "modals.delete-comment-thread.accept" -msgstr "대회 지우기" +msgstr "대화 삭제" #: src/app/main/ui/comments.cljs:887 msgid "modals.delete-comment-thread.title" -msgstr "대화 지우기" +msgstr "대화 삭제" #: src/app/main/ui/dashboard/file_menu.cljs:125 msgid "modals.delete-file-confirm.accept" -msgstr "파일 지우기" +msgstr "파일 삭제" #: src/app/main/ui/dashboard/file_menu.cljs:124 msgid "modals.delete-file-confirm.message" -msgstr "이 파일을 정말로 지우시겠습니까?" +msgstr "이 파일을 삭제하시겠습니까?" #: src/app/main/ui/dashboard/file_menu.cljs:123 msgid "modals.delete-file-confirm.title" -msgstr "파일 삭제중" +msgstr "파일 삭제 중" #: src/app/main/ui/dashboard/file_menu.cljs:119 msgid "modals.delete-file-multi-confirm.accept" -msgstr "여러 파일 지우기" +msgstr "여러 파일 삭제" #: src/app/main/ui/dashboard/fonts.cljs:355 msgid "modals.delete-font-variant.title" -msgstr "폰트 스타일 지우는 중" +msgstr "글꼴 스타일 지우는 중" #: src/app/main/ui/dashboard/fonts.cljs:341 msgid "modals.delete-font.title" -msgstr "폰트 지우는 중" +msgstr "글꼴 삭제 중" -#: src/app/main/ui/workspace/context_menu.cljs:675, src/app/main/ui/workspace/sidebar/sitemap.cljs:95 +#: src/app/main/ui/workspace/context_menu.cljs:675, +#: src/app/main/ui/workspace/sidebar/sitemap.cljs:95 msgid "modals.delete-page.body" -msgstr "정말로 해당 페이지를 지우시겠습니까?" +msgstr "이 페이지를 삭제하시겠습니까?" -#: src/app/main/ui/workspace/context_menu.cljs:674, src/app/main/ui/workspace/sidebar/sitemap.cljs:94 +#: src/app/main/ui/workspace/context_menu.cljs:674, +#: src/app/main/ui/workspace/sidebar/sitemap.cljs:94 msgid "modals.delete-page.title" msgstr "페이지 삭제" #: src/app/main/ui/dashboard/project_menu.cljs:73 msgid "modals.delete-project-confirm.accept" -msgstr "프로젝트 제거" +msgstr "프로젝트 삭제" #: src/app/main/ui/dashboard/project_menu.cljs:72 msgid "modals.delete-project-confirm.message" -msgstr "정말로 해당 프로젝트를 지우시겠습니까?" +msgstr "이 프로젝트를 삭제하시겠습니까?" #: src/app/main/ui/dashboard/project_menu.cljs:71 msgid "modals.delete-project-confirm.title" -msgstr "프로젝트 제거" +msgstr "프로젝트 삭제" -#: src/app/main/ui/settings/options.cljs:27, src/app/main/ui/settings/profile.cljs:30 +#: src/app/main/ui/settings/options.cljs:27, +#: src/app/main/ui/settings/profile.cljs:30 msgid "notifications.profile-saved" msgstr "프로필이 성공적으로 저장되었습니다!" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:115 msgid "shortcuts.flip-horizontal" -msgstr "가로로 뒤집기" +msgstr "좌우 반전" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:116 msgid "shortcuts.flip-vertical" -msgstr "세로로 뒤집기" +msgstr "상하 반전" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:119 msgid "shortcuts.go-to-drafts" @@ -1028,16 +1142,8019 @@ msgstr "초안으로 가기" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:120 msgid "shortcuts.go-to-libs" -msgstr "공유된 라이브러리로 가기" +msgstr "공유 라이브러리로 이동" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:121 msgid "shortcuts.go-to-search" -msgstr "찾기" +msgstr "검색" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:122 msgid "shortcuts.group" -msgstr "그룹" +msgstr "그룹화" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:123 msgid "shortcuts.h-distribute" -msgstr "가로로 분배하기" +msgstr "수평 간격 동일하게" + +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:101, +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:108 +msgid "color-row.token-color-row.deleted-token" +msgstr "해당 token이 존재하지 않거나 삭제되었습니다." + +#: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:35 +msgid "color-token.empty-state" +msgstr "" +"사용 가능한 색상 token이 없습니다. 활성 세트/테마를 확인하거나 새 token을 추" +"가하세요." + +#: src/app/main/ui/dashboard/sidebar.cljs:347 +msgid "dashboard.create-new-org" +msgstr "새 조직 생성" + +#: src/app/main/ui/dashboard/grid.cljs:248 +msgid "dashboard.deleted.will-be-deleted-at" +msgstr "%s에 삭제될 예정" + +#: src/app/main/ui/dashboard/files.cljs:203, +#: src/app/main/ui/dashboard/projects.cljs:290 +msgid "dashboard.empty-placeholder-drafts-subtitle" +msgstr "프로젝트 구성원이 초안을 생성하면 이곳에 표시됩니다." + +#: src/app/main/ui/dashboard/files.cljs:198, +#: src/app/main/ui/dashboard/projects.cljs:285 +msgid "dashboard.empty-placeholder-drafts-title" +msgstr "아직 초안이 없습니다." + +#: src/app/main/ui/dashboard/deleted.cljs:176, +#: src/app/main/ui/dashboard/files.cljs:204, +#: src/app/main/ui/dashboard/projects.cljs:291 +msgid "dashboard.empty-placeholder-files-subtitle" +msgstr "프로젝트 구성원이 파일을 생성하면 이곳에 표시됩니다." + +#: src/app/main/ui/dashboard/deleted.cljs:173, +#: src/app/main/ui/dashboard/files.cljs:199, +#: src/app/main/ui/dashboard/projects.cljs:286 +msgid "dashboard.empty-placeholder-files-title" +msgstr "아직 파일이 없습니다." + +#: src/app/main/ui/dashboard/placeholder.cljs:118 +msgid "dashboard.empty-placeholder-libraries" +msgstr "" +"프로젝트에 추가된 라이브러리가 여기에 표시됩니다. 파일을 공유해보거나, " +"[Libraries & templates](https://penpot.app/libraries-templates)에서 추가해보" +"세요." + +#: src/app/main/ui/dashboard/placeholder.cljs +msgid "dashboard.empty-placeholder-libraries-subtitle" +msgstr "" +"프로젝트에 추가된 라이브러리가 여기에 표시됩니다. 파일을 공유하거나 라이브러리 " +"및 템플릿에서 추가해 보세요." + +#: src/app/main/ui/dashboard/placeholder.cljs:114 +msgid "dashboard.empty-placeholder-libraries-subtitle-viewer-role" +msgstr "프로젝트에 추가된 라이브러리가 여기에 표시됩니다." + +#: src/app/main/ui/dashboard/placeholder.cljs:59 +msgid "dashboard.empty-project.add-library" +msgstr "라이브러리 또는 템플릿 추가" + +#: src/app/main/ui/dashboard/placeholder.cljs:43, +#: src/app/main/ui/dashboard/placeholder.cljs:134 +msgid "dashboard.empty-project.create" +msgstr "새 파일 생성" + +#: src/app/main/ui/dashboard/placeholder.cljs:61 +msgid "dashboard.empty-project.explore" +msgstr "몇 가지를 둘러보고 추가해보세요" + +#: src/app/main/ui/dashboard/placeholder.cljs:57 +msgid "dashboard.empty-project.go-to-libraries" +msgstr "Libraries and Templates로 이동" + +#: src/app/main/ui/dashboard/placeholder.cljs:49, +#: src/app/main/ui/dashboard/placeholder.cljs:51 +msgid "dashboard.empty-project.import" +msgstr "파일 가져오기" + +#: src/app/main/ui/dashboard/placeholder.cljs:53 +msgid "dashboard.empty-project.import-penpot" +msgstr ".penpot 파일 가져오기" + +#: src/app/main/ui/dashboard/placeholder.cljs:45 +msgid "dashboard.empty-project.start" +msgstr "놀라운 것들을 만들기 시작하세요" + +msgid "dashboard.errors.error-on-delete-file" +msgstr "%s 파일 삭제 중 오류가 발생했습니다." + +#: src/app/main/data/dashboard.cljs:781 +msgid "dashboard.errors.error-on-delete-files" +msgstr "파일을 삭제하는 중 오류가 발생했습니다." + +#: src/app/main/data/dashboard.cljs:814 +msgid "dashboard.errors.error-on-delete-project" +msgstr "%s 프로젝트를 삭제하는 중 오류가 발생했습니다." + +#: src/app/main/data/dashboard.cljs:909, +#: src/app/main/ui/dashboard/file_menu.cljs:201 +msgid "dashboard.errors.error-on-restore-file" +msgstr "%s 파일을 복원하는 중 오류가 발생했습니다." + +#: src/app/main/data/dashboard.cljs:910 +msgid "dashboard.errors.error-on-restore-files" +msgstr "파일을 복원하는 중 오류가 발생했습니다." + +#: src/app/main/data/dashboard.cljs:942 +msgid "dashboard.errors.error-on-restoring-project" +msgstr "%s 프로젝트와 해당 파일을 복원하는 중 오류가 발생했습니다." + +msgid "dashboard.export-multi" +msgstr "Penpot 파일 %s개 내보내기" + +#: src/app/main/ui/exports/assets.cljs:108 +msgid "dashboard.export-multiple.selected" +msgstr "전체 %s개 중 %s개 요소가 선택됨" + +#: src/app/main/ui/exports/assets.cljs:179 +msgid "dashboard.export-shapes.how-to" +msgstr "" +"디자인 속성(오른쪽 사이드바 하단)에서 요소에 내보내기 설정을 추가할 수 있습니" +"다." + +#: src/app/main/ui/exports/assets.cljs:183 +msgid "dashboard.export-shapes.how-to-link" +msgstr "Penpot에서 내보내기 설정 방법 안내." + +#: src/app/main/ui/exports/assets.cljs:178 +msgid "dashboard.export-shapes.no-elements" +msgstr "내보내기 설정이 지정된 요소가 없습니다." + +#: src/app/main/ui/exports/assets.cljs:189 +msgid "dashboard.export-shapes.title" +msgstr "선택 영역 내보내기" + +#: src/app/main/ui/dashboard/file_menu.cljs:262 +msgid "dashboard.export-standard-multi" +msgstr "표준 파일 %s개(.svg + .json) 다운로드" + +#: src/app/main/ui/exports/files.cljs:155 +msgid "dashboard.export.explain" +msgstr "" +"다운로드하려는 하나 이상의 파일이 공유 라이브러리를 사용 중입니다. 해당 에셋*" +"을 어떻게 처리하시겠습니까?" + +#: src/app/main/ui/dashboard/file_menu.cljs:266 +msgid "dashboard.file-menu.delete-files-permanently-option" +msgid_plural "dashboard.file-menu.delete-files-permanently-option" +msgstr[0] "파일 삭제" + +#: src/app/main/ui/dashboard/file_menu.cljs:263 +msgid "dashboard.file-menu.restore-files-option" +msgid_plural "dashboard.file-menu.restore-files-option" +msgstr[0] "파일 복원" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:322 +msgid "dashboard.fonts.deleted-placeholder" +msgstr "누락된 글꼴" + +#: src/app/main/ui/dashboard/fonts.cljs:218 +msgid "dashboard.fonts.dismiss-all" +msgstr "모두 무시" + +#: src/app/main/ui/dashboard/fonts.cljs:455 +msgid "dashboard.fonts.empty-placeholder" +msgstr "업로드한 사용자 지정 글꼴이 여기에 표시됩니다." + +#: src/app/main/ui/dashboard/fonts.cljs:458 +msgid "dashboard.fonts.empty-placeholder-viewer" +msgstr "아직 사용자 지정 폰트가 없습니다." + +#: src/app/main/ui/dashboard/fonts.cljs:459 +msgid "dashboard.fonts.empty-placeholder-viewer-sub" +msgstr "프로젝트 구성원이 사용자 지정 폰트를 업로드하면 여기에 표시됩니다." + +#: src/app/main/ui/dashboard/fonts.cljs:206 +msgid "dashboard.fonts.fonts-added" +msgid_plural "dashboard.fonts.fonts-added" +msgstr[0] "글꼴 %s개 추가됨" + +#: src/app/main/ui/dashboard/fonts.cljs:181 +msgid "dashboard.fonts.hero-text1" +msgstr "" +"여기에 업로드하는 모든 웹 글꼴은 이 팀의 파일 텍스트 속성에서 사용할 수 있는 " +"글꼴 모음 목록에 추가됩니다. 동일한 글꼴 모음 이름을 가진 글꼴들은**하나의 글" +"꼴 모음**으로 그룹화됩니다. 지원 형식: **TTF, OTF, WOFF** (하나만 필요)." + +#: src/app/main/ui/dashboard/fonts.cljs:194 +msgid "dashboard.fonts.hero-text2" +msgstr "" +"Penpot에서 사용하기 위해서는 본인이 소유하거나 적법한 사용 라이선스를 보유한 " +"글꼴만 업로드해야 합니다. 관련 내용은 [Penpot의 서비스 이용약관](%s)의 콘텐" +"츠 권리 섹션에서 확인할 수 있습니다. 추가로 [글꼴 라이선스 안내](https://" +"www.typography.com/faq)를 참고하시기 바랍니다." + +#: src/app/main/ui/dashboard/fonts.cljs:214 +msgid "dashboard.fonts.upload-all" +msgstr "모두 업로드" + +#: src/app/main/ui/dashboard/fonts.cljs:199 +msgid "dashboard.fonts.warning-text" +msgstr "" +"운영 체제별 수직 메트릭과 관련된 폰트 문제를 감지했습니다. 확인을 위해 [이 서" +"비스](https://vertical-metrics.netlify.app/)와 같은 수직 메트릭 서비스를 사용" +"할 수 있습니다. 또한, 웹 폰트 생성 및 오류 수정을 위해 [Transfonter](https://" +"transfonter.org/) 사용을 권장합니다. " + +#: src/app/main/ui/dashboard/import.cljs:464, +#: src/app/main/ui/dashboard/project_menu.cljs:109 +msgid "dashboard.import" +msgstr "Penpot 파일 가져오기" + +#: src/app/main/ui/dashboard/import.cljs:293, src/app/worker/import.cljs:121, +#: src/app/worker/import.cljs:124 +msgid "dashboard.import.analyze-error" +msgstr "이런! 이 파일을 가져올 수 없습니다" + +msgid "dashboard.import.analyze-error.components-v2" +msgstr "" +"컴포넌트 v2가 활성화된 파일이지만, 이 팀은 아직 이를 지원하지 않습니다." + +#: src/app/main/ui/dashboard.cljs:259 +msgid "dashboard.import.bad-url" +msgstr "가져오기 실패. 템플릿 URL이 올바르지 않습니다" + +#: src/app/main/ui/dashboard.cljs:241 +msgid "dashboard.import.error" +msgstr "가져오기 실패. 다시 시도해주세요" + +#: src/app/main/ui/dashboard/import.cljs:292 +msgid "dashboard.import.import-error" +msgstr "파일을 가져오는 중 문제가 발생했습니다. 파일이 누락되었습니다." + +#: src/app/main/ui/dashboard/import.cljs:485 +msgid "dashboard.import.import-error.disclaimer" +msgstr "일부 파일이 누락되었습니다" + +#: src/app/main/ui/dashboard/import.cljs:489 +msgid "dashboard.import.import-error.message1" +msgstr "다음 파일에 오류가 있습니다:" + +#: src/app/main/ui/dashboard/import.cljs:494 +msgid "dashboard.import.import-error.message2" +msgstr "오류가 있는 파일은 업로드되지 않습니다." + +#: src/app/main/ui/dashboard/import.cljs:479 +msgid "dashboard.import.import-message" +msgid_plural "dashboard.import.import-message" +msgstr[0] "파일 %s개를 성공적으로 가져왔습니다." + +#: src/app/main/ui/dashboard/import.cljs:474 +msgid "dashboard.import.import-warning" +msgstr "일부 파일에 포함된 유효하지 않은 객체가 제거되었습니다." + +#: src/app/main/ui/dashboard.cljs:260 +msgid "dashboard.import.no-perms" +msgstr "이 팀으로 가져올 권한이 없습니다" + +#: src/app/main/ui/dashboard/import.cljs:128 +msgid "dashboard.import.progress.process-colors" +msgstr "컬러 처리 중" + +#: src/app/main/ui/dashboard/import.cljs:137, +#: src/app/main/ui/dashboard/import.cljs:140 +msgid "dashboard.import.progress.process-components" +msgstr "컴포넌트 처리 중" + +#: src/app/main/ui/dashboard/import.cljs:134 +msgid "dashboard.import.progress.process-media" +msgstr "미디어 처리 중" + +#: src/app/main/ui/dashboard/import.cljs:125 +msgid "dashboard.import.progress.process-page" +msgstr "페이지 처리 중: %s" + +#: src/app/main/ui/dashboard/import.cljs:131 +msgid "dashboard.import.progress.process-typographies" +msgstr "타이포그래피 처리 중" + +#: src/app/main/ui/dashboard/import.cljs:119 +msgid "dashboard.import.progress.upload-data" +msgstr "서버로 데이터 업로드 중 (%s/%s)" + +#: src/app/main/ui/dashboard/import.cljs:122 +msgid "dashboard.import.progress.upload-media" +msgstr "파일 업로드 중: %s" + +#: src/app/main/ui/dashboard/team.cljs:765 +msgid "dashboard.invitation-modal.delete" +msgstr "다음에 대한 초대를 삭제합니다:" + +#: src/app/main/ui/dashboard/team.cljs:766 +msgid "dashboard.invitation-modal.resend" +msgstr "다음에 대한 초대를 재전송합니다:" + +#: src/app/main/ui/dashboard/team.cljs:756 +msgid "dashboard.invitation-modal.title.delete-invitations" +msgstr "초대 삭제" + +#: src/app/main/ui/dashboard/team.cljs:757 +msgid "dashboard.invitation-modal.title.resend-invitations" +msgstr "초대 재전송" + +#: src/app/main/ui/dashboard/team.cljs:122, +#: src/app/main/ui/dashboard/team.cljs:744 +msgid "dashboard.invite-profile" +msgstr "사람 초대" + +#: src/app/main/ui/dashboard/sidebar.cljs:477, +#: src/app/main/ui/dashboard/sidebar.cljs:484, +#: src/app/main/ui/dashboard/sidebar.cljs:489, +#: src/app/main/ui/dashboard/team.cljs:351 +msgid "dashboard.leave-team" +msgstr "팀 나가기" + +#: src/app/main/ui/dashboard/templates.cljs:84, +#: src/app/main/ui/dashboard/templates.cljs:169 +msgid "dashboard.libraries-and-templates" +msgstr "라이브러리 및 템플릿" + +#: src/app/main/ui/dashboard/templates.cljs:267 +msgid "dashboard.libraries-and-templates.description" +msgstr "프로젝트에 추가할 수 있는 라이브러리와 템플릿입니다" + +#: src/app/main/ui/dashboard/templates.cljs:170 +msgid "dashboard.libraries-and-templates.explore" +msgstr "더 많은 항목을 둘러보고 기여하는 방법을 알아보세요" + +#: src/app/main/ui/dashboard/import.cljs:365, +#: src/app/main/ui/workspace/libraries.cljs:145 +msgid "dashboard.libraries-and-templates.import-error" +msgstr "템플릿을 가져오는 중 문제가 발생했습니다. 템플릿이 누락되었습니다." + +#: src/app/main/ui/dashboard/libraries.cljs:69 +msgid "dashboard.libraries-title" +msgstr "라이브러리" + +#: src/app/main/ui/dashboard/placeholder.cljs:143 +msgid "dashboard.loading-files" +msgstr "파일 로드 중…" + +#: src/app/main/ui/dashboard/fonts.cljs:449 +msgid "dashboard.loading-fonts" +msgstr "폰트 로드 중…" + +#: src/app/main/data/comments.cljs:473 +msgid "dashboard.mark-all-as-read.success" +msgstr "모든 알림을 읽음으로 표시했습니다" + +#: src/app/main/ui/dashboard/file_menu.cljs:312, +#: src/app/main/ui/dashboard/project_menu.cljs:101 +msgid "dashboard.move-to" +msgstr "다음으로 이동" + +#: src/app/main/ui/dashboard/file_menu.cljs:276 +msgid "dashboard.move-to-multi" +msgstr "%s개 파일을 다음으로 이동" + +#: src/app/main/ui/dashboard/file_menu.cljs:248 +msgid "dashboard.move-to-other-team" +msgstr "다른 팀으로 이동" + +#: src/app/main/ui/dashboard/files.cljs:107, +#: src/app/main/ui/dashboard/projects.cljs:257, +#: src/app/main/ui/dashboard/projects.cljs:258 +msgid "dashboard.new-file" +msgstr "+ 새 파일" + +#: src/app/main/data/dashboard.cljs:536, src/app/main/data/dashboard.cljs:648 +msgid "dashboard.new-file-prefix" +msgstr "새 파일" + +#: src/app/main/ui/dashboard/projects.cljs:62 +msgid "dashboard.new-project" +msgstr "+ 새 프로젝트" + +#: src/app/main/data/dashboard.cljs:289, src/app/main/data/dashboard.cljs:651 +msgid "dashboard.new-project-prefix" +msgstr "새 프로젝트" + +#: src/app/main/ui/dashboard/search.cljs:77 +msgid "dashboard.no-matches-for" +msgstr "\"%s\"에 대한 검색 결과가 없습니다" + +#: src/app/main/ui/dashboard/comments.cljs:91 +msgid "dashboard.notifications" +msgstr "알림" + +#: src/app/main/ui/auth/verify_token.cljs:34 +msgid "dashboard.notifications.email-changed-successfully" +msgstr "이메일 주소가 성공적으로 업데이트되었습니다" + +#: src/app/main/ui/auth/verify_token.cljs:28 +msgid "dashboard.notifications.email-verified-successfully" +msgstr "이메일 주소가 성공적으로 인증되었습니다" + +#: src/app/main/data/profile.cljs:280 +msgid "dashboard.notifications.notifications-saved" +msgstr "알림 설정이 업데이트되었습니다" + +#: src/app/main/ui/settings/password.cljs:38 +msgid "dashboard.notifications.password-saved" +msgstr "비밀번호가 성공적으로 저장되었습니다!" + +#: src/app/main/ui/dashboard/comments.cljs:45 +msgid "dashboard.notifications.view" +msgstr "알림 보기" + +#: src/app/main/ui/dashboard/team.cljs:1340 +msgid "dashboard.num-of-members" +msgstr "멤버 %s명" + +#: src/app/main/ui/dashboard/file_menu.cljs:295 +msgid "dashboard.open-in-new-tab" +msgstr "새 탭에서 파일 열기" + +#: src/app/main/ui/dashboard/deleted.cljs:157, +#: src/app/main/ui/dashboard/deleted.cljs:158, +#: src/app/main/ui/dashboard/files.cljs:120, +#: src/app/main/ui/dashboard/grid.cljs:442, +#: src/app/main/ui/dashboard/projects.cljs:266, +#: src/app/main/ui/dashboard/projects.cljs:267 +msgid "dashboard.options" +msgstr "옵션" + +#: src/app/main/ui/dashboard/team.cljs:949 +msgid "dashboard.order-invitations-by-role" +msgstr "역할순 정렬" + +#: src/app/main/ui/dashboard/team.cljs:958 +msgid "dashboard.order-invitations-by-status" +msgstr "상태순 정렬" + +#: src/app/main/ui/settings/password.cljs:96, +#: src/app/main/ui/settings/password.cljs:109 +msgid "dashboard.password-change" +msgstr "비밀번호 변경" + +#: src/app/main/data/common.cljs:192 +msgid "dashboard.permissions-change.admin" +msgstr "이제 이 팀의 관리자(Admin)입니다." + +#: src/app/main/data/common.cljs:191 +msgid "dashboard.permissions-change.editor" +msgstr "이제 이 팀의 에디터(Editor)입니다." + +#: src/app/main/data/common.cljs:193 +msgid "dashboard.permissions-change.owner" +msgstr "이제 이 팀의 소유자(Owner)입니다." + +#: src/app/main/data/common.cljs:190 +msgid "dashboard.permissions-change.viewer" +msgstr "이제 이 팀의 뷰어(Viewer)입니다." + +#: src/app/main/ui/dashboard/pin_button.cljs:23, +#: src/app/main/ui/dashboard/project_menu.cljs:96 +msgid "dashboard.pin-unpin" +msgstr "고정/고정 해제" + +#: src/app/main/ui/dashboard.cljs:223 +msgid "dashboard.plugins.bad-url" +msgstr "플러그인 URL이 올바르지 않습니다" + +#: src/app/main/ui/dashboard.cljs:221 +msgid "dashboard.plugins.parse-error" +msgstr "플러그인 매니페스트를 구문 분석할 수 없습니다" + +#: src/app/main/ui/dashboard.cljs:184 +msgid "dashboard.plugins.try-plugin" +msgstr "플러그인 체험하기: " + +#: src/app/main/data/dashboard.cljs:722 +msgid "dashboard.progress-notification.deleting-files" +msgstr "파일 삭제 중…" + +#: src/app/main/data/dashboard.cljs:843 +msgid "dashboard.progress-notification.restoring-files" +msgstr "파일 복원 중…" + +#: src/app/main/data/dashboard.cljs:723 +msgid "dashboard.progress-notification.slow-delete" +msgstr "삭제가 예상보다 느립니다" + +#: src/app/main/data/dashboard.cljs:844 +msgid "dashboard.progress-notification.slow-restore" +msgstr "복원이 예상보다 느립니다" + +#: src/app/main/ui/settings/profile.cljs:86 +msgid "dashboard.remove-account" +msgstr "계정을 삭제하시겠습니까?" + +#: src/app/main/ui/workspace/header.cljs, +#: src/app/main/ui/dashboard/file_menu.cljs +msgid "dashboard.remove-shared" +msgstr "공유 라이브러리에서 제거" + +#: src/app/main/data/common.cljs:225 +msgid "dashboard.removed-from-team" +msgstr "더 이상 \"%s\" 팀의 멤버가 아닙니다." + +#: src/app/main/ui/dashboard/deleted.cljs:308 +msgid "dashboard.restore-all-deleted-button" +msgstr "모두 복원" + +#: src/app/main/data/dashboard.cljs:903 +msgid "dashboard.restore-files-success-notification" +msgstr "%s개 파일이 성공적으로 복원되었습니다." + +#: src/app/main/ui/dashboard/deleted.cljs:82 +msgid "dashboard.restore-project-button" +msgstr "프로젝트 복원" + +#: src/app/main/ui/dashboard/deleted.cljs:41 +msgid "dashboard.restore-project-confirmation.description" +msgstr "%s 프로젝트와 그 안에 포함된 모든 파일을 복원하려고 합니다." + +#: src/app/main/ui/dashboard/deleted.cljs:40 +msgid "dashboard.restore-project-confirmation.title" +msgstr "프로젝트 복원" + +#: src/app/main/data/dashboard.cljs:875, src/app/main/data/dashboard.cljs:902, +#: src/app/main/data/dashboard.cljs:939, +#: src/app/main/ui/dashboard/file_menu.cljs:198 +msgid "dashboard.restore-success-notification" +msgstr "%s이(가) 성공적으로 복원되었습니다." + +#: src/app/main/ui/settings/profile.cljs:78 +msgid "dashboard.save-settings" +msgstr "설정 저장" + +#: src/app/main/ui/settings/options.cljs:58 +msgid "dashboard.select-ui-language" +msgstr "UI 언어 선택" + +#: src/app/main/ui/settings/options.cljs:65 +msgid "dashboard.select-ui-theme" +msgstr "테마 선택" + +#: src/app/main/ui/settings/options.cljs:68 +msgid "dashboard.select-ui-theme.dark" +msgstr "Penpot 다크 (기본)" + +#: src/app/main/ui/settings/options.cljs:69 +msgid "dashboard.select-ui-theme.light" +msgstr "Penpot 라이트" + +#: src/app/main/ui/settings/options.cljs:70 +msgid "dashboard.select-ui-theme.system" +msgstr "시스템 테마" + +#: src/app/main/ui/settings/notifications.cljs:57 +msgid "dashboard.settings.notifications.dashboard-comments.all" +msgstr "모든 댓글, 멘션 및 답글" + +#: src/app/main/ui/settings/notifications.cljs:59 +msgid "dashboard.settings.notifications.dashboard-comments.none" +msgstr "없음" + +#: src/app/main/ui/settings/notifications.cljs:58 +msgid "dashboard.settings.notifications.dashboard-comments.partial" +msgstr "멘션 및 답글만" + +#: src/app/main/ui/settings/notifications.cljs:54 +msgid "dashboard.settings.notifications.dashboard-comments.title" +msgstr "파일 댓글" + +#: src/app/main/ui/settings/notifications.cljs:53 +msgid "dashboard.settings.notifications.dashboard.title" +msgstr "대시보드 알림" + +#: src/app/main/ui/settings/notifications.cljs:67 +msgid "dashboard.settings.notifications.email-comments.all" +msgstr "모든 댓글, 멘션 및 답글" + +#: src/app/main/ui/settings/notifications.cljs:69 +msgid "dashboard.settings.notifications.email-comments.none" +msgstr "없음" + +#: src/app/main/ui/settings/notifications.cljs:68 +msgid "dashboard.settings.notifications.email-comments.partial" +msgstr "멘션 및 답글만" + +#: src/app/main/ui/settings/notifications.cljs:64 +msgid "dashboard.settings.notifications.email-comments.title" +msgstr "파일 댓글" + +#: src/app/main/ui/settings/notifications.cljs:76 +msgid "dashboard.settings.notifications.email-invites.all" +msgstr "모든 종류의 초대 및 요청" + +#: src/app/main/ui/settings/notifications.cljs:79 +msgid "dashboard.settings.notifications.email-invites.none" +msgstr "없음" + +#: src/app/main/ui/settings/notifications.cljs:73 +msgid "dashboard.settings.notifications.email-invites.title" +msgstr "초대 및 요청" + +#: src/app/main/ui/settings/notifications.cljs:63 +msgid "dashboard.settings.notifications.email.title" +msgstr "이메일 알림" + +#: src/app/main/ui/settings/notifications.cljs:84 +msgid "dashboard.settings.notifications.submit" +msgstr "설정 업데이트" + +#: src/app/main/ui/settings/notifications.cljs:52 +msgid "dashboard.settings.notifications.title" +msgstr "알림" + +#: src/app/main/ui/dashboard/projects.cljs:309 +msgid "dashboard.show-all-files" +msgstr "모든 파일 보기" + +#: src/app/main/ui/workspace/main_menu.cljs:668 +msgid "dashboard.show-version-history" +msgstr "버전 히스토리" + +#: src/app/main/ui/dashboard/file_menu.cljs:98 +msgid "dashboard.success-delete-file" +msgid_plural "dashboard.success-delete-file" +msgstr[0] "파일이 성공적으로 삭제되었습니다" + +#: src/app/main/ui/dashboard/project_menu.cljs:63 +msgid "dashboard.success-delete-project" +msgstr "프로젝트가 성공적으로 삭제되었습니다" + +#: src/app/main/ui/dashboard/file_menu.cljs:93 +msgid "dashboard.success-duplicate-file" +msgid_plural "dashboard.success-delete-file" +msgstr[0] "파일이 성공적으로 복사되었습니다" + +#: src/app/main/ui/dashboard/project_menu.cljs:35 +msgid "dashboard.success-duplicate-project" +msgstr "프로젝트가 성공적으로 복제되었습니다" + +#: src/app/main/ui/dashboard/file_menu.cljs:132, +#: src/app/main/ui/dashboard/grid.cljs:634, +#: src/app/main/ui/dashboard/sidebar.cljs:166 +msgid "dashboard.success-move-file" +msgstr "파일이 성공적으로 이동되었습니다" + +#: src/app/main/ui/dashboard/file_menu.cljs:131 +msgid "dashboard.success-move-files" +msgstr "파일들이 성공적으로 이동되었습니다" + +#: src/app/main/ui/dashboard/project_menu.cljs:57 +msgid "dashboard.success-move-project" +msgstr "프로젝트가 성공적으로 이동되었습니다" + +#: src/app/main/ui/dashboard/team.cljs:1323 +msgid "dashboard.team-info" +msgstr "팀 정보" + +#: src/app/main/ui/dashboard/team.cljs:1329 +msgid "dashboard.team-members" +msgstr "팀 구성원" + +#: src/app/main/ui/dashboard/templates.cljs:134 +msgid "dashboard.template.add-to-project" +msgstr "프로젝트에 추가" + +#: src/app/main/ui/settings/options.cljs:63 +msgid "dashboard.theme-change" +msgstr "UI 테마" + +#: src/app/main/ui/dashboard/deleted.cljs:298 +msgid "dashboard.trash-info-text-part1" +msgstr "삭제된 파일은 휴지통에" + +#: src/app/main/ui/dashboard/deleted.cljs:300 +msgid "dashboard.trash-info-text-part2" +msgstr " %s일 동안 보관됩니다. " + +#: src/app/main/ui/dashboard/deleted.cljs:301 +msgid "dashboard.trash-info-text-part3" +msgstr "그 이후에는 영구적으로 삭제됩니다." + +#: src/app/main/ui/dashboard/deleted.cljs:303 +msgid "dashboard.trash-info-text-part4" +msgstr "필요한 경우, 각 파일 메뉴에서 복원하거나 영구 삭제할 수 있습니다." + +#: src/app/main/ui/dashboard/file_menu.cljs:319, +#: src/app/main/ui/workspace/main_menu.cljs:642 +msgid "dashboard.unpublish-shared" +msgstr "라이브러리 게시 취소" + +#: src/app/main/ui/settings/options.cljs:74 +msgid "dashboard.update-settings" +msgstr "설정 업데이트" + +#: src/app/main/ui/dashboard/sidebar.cljs:1071 +msgid "dashboard.upgrade-plan.no-limits" +msgstr "창의력에 한계를 두지 마세요" + +#: src/app/main/ui/dashboard/sidebar.cljs:1069 +msgid "dashboard.upgrade-plan.penpot-free" +msgstr "Penpot 무료 버전" + +#: src/app/main/ui/dashboard/team.cljs:1115 +msgid "dashboard.webhooks.active" +msgstr "활성화 상태" + +#: src/app/main/ui/dashboard/team.cljs:1116 +msgid "dashboard.webhooks.active.explain" +msgstr "이 훅이 실행되면 이벤트 상세 정보가 전달됩니다" + +#: src/app/main/ui/dashboard/team.cljs:1160 +msgid "dashboard.webhooks.cant-edit" +msgstr "본인이 생성한 웹훅만 삭제하거나 수정할 수 있습니다." + +#: src/app/main/ui/dashboard/team.cljs:1106 +msgid "dashboard.webhooks.content-type" +msgstr "콘텐츠 유형" + +#: src/app/main/ui/dashboard/team.cljs:1139 +msgid "dashboard.webhooks.create" +msgstr "웹훅 생성" + +#: src/app/main/ui/dashboard/team.cljs:1031 +msgid "dashboard.webhooks.create.success" +msgstr "웹훅이 성공적으로 생성되었습니다." + +#: src/app/main/ui/dashboard/team.cljs:1136 +msgid "dashboard.webhooks.description" +msgstr "" +"웹훅은 Penpot에서 특정 이벤트가 발생했을 때 다른 웹사이트나 앱이 알림을 받을 " +"수 있는 간단한 방법입니다. 제공하신 각 URL로 POST 요청을 보냅니다." + +#: src/app/main/ui/dashboard/team.cljs:1265 +msgid "dashboard.webhooks.empty.add-one" +msgstr "새 웹훅을 추가하려면 \"웹훅 추가\" 버튼을 누르세요." + +#: src/app/main/ui/dashboard/team.cljs:1264 +msgid "dashboard.webhooks.empty.no-webhooks" +msgstr "아직 생성된 웹훅이 없습니다." + +msgid "dashboard.webhooks.update.success" +msgstr "웹훅이 성공적으로 업데이트되었습니다." + +#: src/app/main/ui/settings.cljs:34 +msgid "dashboard.your-account-title" +msgstr "내 계정" + +#: src/app/main/ui/settings/profile.cljs:70 +msgid "dashboard.your-email" +msgstr "이메일" + +#: src/app/main/ui/settings/profile.cljs:62 +msgid "dashboard.your-name" +msgstr "이름" + +#: src/app/main/ui/dashboard/file_menu.cljs:40, +#: src/app/main/ui/dashboard/fonts.cljs:42, +#: src/app/main/ui/dashboard/libraries.cljs:56, +#: src/app/main/ui/dashboard/projects.cljs:355, +#: src/app/main/ui/dashboard/search.cljs:48, +#: src/app/main/ui/dashboard/sidebar.cljs:312, +#: src/app/main/ui/dashboard/team.cljs:537, +#: src/app/main/ui/dashboard/team.cljs:983, +#: src/app/main/ui/dashboard/team.cljs:1251, +#: src/app/main/ui/dashboard/team.cljs:1298 +msgid "dashboard.your-penpot" +msgstr "내 Penpot" + +#: src/app/main/ui/alert.cljs:35 +msgid "ds.alert-ok" +msgstr "확인" + +#: src/app/main/ui/alert.cljs:34, src/app/main/ui/alert.cljs:37 +msgid "ds.alert-title" +msgstr "주의" + +#: src/app/main/ui/confirm.cljs:86 +msgid "ds.component-subtitle" +msgstr "업데이트할 컴포넌트:" + +#: src/app/main/ui/workspace/plugins.cljs:340, +#: src/app/main/ui/workspace/plugins.cljs:394 +msgid "ds.confirm-allow" +msgstr "허용" + +#: src/app/main/ui/comments.cljs:674, src/app/main/ui/confirm.cljs:37, +#: src/app/main/ui/settings/subscription.cljs:273, +#: src/app/main/ui/settings/subscription.cljs:306, +#: src/app/main/ui/workspace/plugins.cljs:334, +#: src/app/main/ui/workspace/plugins.cljs:388 +msgid "ds.confirm-cancel" +msgstr "취소" + +#: src/app/main/ui/confirm.cljs:38, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:157, +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:102 +msgid "ds.confirm-ok" +msgstr "확인" + +#: src/app/main/ui/confirm.cljs:36, src/app/main/ui/confirm.cljs:40 +msgid "ds.confirm-title" +msgstr "정말 진행하시겠습니까?" + +#: src/app/main/ui/ds/controls/numeric_input.cljs:98 +msgid "ds.inputs.numeric-input.no-applicable-tokens" +msgstr "활성 세트나 테마에 적용 가능한 token이 없습니다." + +#: src/app/main/ui/ds/controls/numeric_input.cljs:99 +msgid "ds.inputs.numeric-input.no-matches" +msgstr "일치하는 항목이 없습니다." + +#: src/app/main/ui/ds/controls/numeric_input.cljs:652, +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:141 +msgid "ds.inputs.numeric-input.open-token-list-dropdown" +msgstr "token 목록 열기" + +#: src/app/main/ui/ds/controls/utilities/token_field.cljs:91, +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:136 +msgid "ds.inputs.token-field.detach-token" +msgstr "token 해제" + +#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106 +msgid "ds.inputs.token-field.no-active-token-option" +msgstr "이 token은 활성 세트에 없거나 유효하지 않은 값을 가지고 있습니다." + +#: src/app/main/data/auth.cljs:339 +msgid "errors.auth-provider-not-allowed" +msgstr "이 프로필에 허용되지 않는 인증 제공자입니다" + +#: src/app/main/data/auth.cljs:189 +msgid "errors.auth-provider-not-configured" +msgstr "인증 제공자가 구성되지 않았습니다." + +#: src/app/main/errors.cljs:126 +msgid "errors.auth.unable-to-login" +msgstr "인증되지 않았거나 세션이 만료된 것 같습니다." + +#: src/app/main/data/fonts.cljs:206, src/app/main/ui/dashboard/fonts.cljs:120 +msgid "errors.bad-font" +msgstr "%s 글꼴을 로드할 수 없습니다" + +#: src/app/main/data/fonts.cljs:205 +msgid "errors.bad-font-plural" +msgstr "%s 글꼴들을 로드할 수 없습니다" + +#: src/app/main/data/workspace/media.cljs:204 +msgid "errors.cannot-upload" +msgstr "미디어 파일을 업로드할 수 없습니다." + +#: src/app/main/ui/comments.cljs:719, src/app/main/ui/comments.cljs:749, +#: src/app/main/ui/comments.cljs:846 +msgid "errors.character-limit-exceeded" +msgstr "글자 수 제한 초과" + +#: src/app/main/data/workspace/clipboard.cljs:481 +msgid "errors.clipboard-not-implemented" +msgstr "브라우저에서 이 작업을 지원하지 않습니다" + +#: src/app/main/errors.cljs:235 +msgid "errors.comment-error" +msgstr "댓글 처리 중 오류가 발생했습니다" + +#: src/app/main/errors.cljs:302 +msgid "errors.deprecated" +msgstr "" +"죄송합니다! 이 파일은 더 이상 지원되지 않는 이전 버전의 Penpot 에셋을 사용하" +"고 있어 열 수 없습니다." + +#: src/app/main/errors.cljs:305 +msgid "errors.deprecated.contact.after" +msgstr "문의해 주시면 도와드리겠습니다." + +#: src/app/main/errors.cljs:303 +msgid "errors.deprecated.contact.before" +msgstr "Penpot은 더 이상 이 유형의 에셋을 지원하지 않지만," + +#: src/app/main/errors.cljs:304 +msgid "errors.deprecated.contact.text" +msgstr "고객 지원팀에 문의" + +#: src/app/main/data/workspace/tokens/library_edit.cljs:338 +msgid "errors.drop-token-set-parent-to-child" +msgstr "상위 세트를 하위 세트의 경로로 옮길 수 없습니다." + +#: src/app/main/ui/auth/verify_token.cljs:84, +#: src/app/main/ui/settings/change_email.cljs:29 +msgid "errors.email-already-exists" +msgstr "이미 사용 중인 이메일입니다" + +#: src/app/main/ui/auth/verify_token.cljs:89 +msgid "errors.email-already-validated" +msgstr "이미 인증된 이메일입니다." + +#: src/app/main/ui/auth/register.cljs:105, +#: src/app/main/ui/settings/password.cljs:29 +msgid "errors.email-as-password" +msgstr "이메일을 비밀번호로 사용할 수 없습니다" + +#: src/app/main/ui/auth/register.cljs:89 +msgid "errors.email-does-not-match-invitation" +msgstr "초대받은 이메일과 일치하지 않습니다." + +#: src/app/main/data/auth.cljs:341, src/app/main/ui/auth/register.cljs:95 +msgid "errors.email-domain-not-allowed" +msgstr "허용되지 않는 도메인입니다" + +#: src/app/main/ui/auth/recovery_request.cljs:57, +#: src/app/main/ui/auth/register.cljs:98, +#: src/app/main/ui/auth/register.cljs:101, +#: src/app/main/ui/dashboard/team.cljs:627, +#: src/app/main/ui/settings/change_email.cljs:37 +msgid "errors.email-has-permanent-bounces" +msgstr "«%s» 이메일은 지속적인 반송(Bounce) 리포트가 발생하고 있습니다." + +#: src/app/main/ui/dashboard/team.cljs:196, +#: src/app/main/ui/dashboard/team.cljs:858, +#: src/app/main/ui/onboarding/team_choice.cljs:110 +msgid "errors.email-spam-or-permanent-bounces" +msgstr "«%s» 이메일은 스팸으로 신고되었거나 지속적으로 반송되고 있습니다." + +#: src/app/main/errors.cljs:279 +msgid "errors.feature-mismatch" +msgstr "" +"'%s' 기능이 활성화된 파일을 열려고 하지만, 현재 Penpot 버전에서 지원하지 않거" +"나 비활성화되어 있습니다." + +#: src/app/main/errors.cljs:283, src/app/main/errors.cljs:297 +msgid "errors.feature-not-supported" +msgstr "'%s' 기능은 지원되지 않습니다." + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:296, +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:240 +msgid "errors.field-max-length" +msgstr "최대 %s자까지 입력 가능합니다." + +msgid "errors.field-min-length" +msgstr "최소 1자 이상 입력해야 합니다." + +#: src/app/util/forms.cljs:66 +msgid "errors.field-missing" +msgstr "필수 입력 항목입니다" + +#: src/app/main/ui/settings/team-form.cljs, src/app/main/ui/auth/register.cljs, +#: src/app/main/ui/dashboard/team_form.cljs, +#: src/app/main/ui/onboarding/team_choice.cljs, +#: src/app/main/ui/settings/access_tokens.cljs, +#: src/app/main/ui/settings/feedback.cljs, +#: src/app/main/ui/settings/profile.cljs, +#: src/app/main/ui/workspace/sidebar/assets.cljs +msgid "errors.field-not-all-whitespace" +msgstr "이름은 공백 외에 다른 문자를 포함해야 합니다." + +#: src/app/main/errors.cljs:275 +msgid "errors.file-feature-mismatch" +msgstr "" +"활성화된 기능과 열려는 파일의 기능이 일치하지 않습니다. 파일을 열기 전에 " +"'%s'에 대한 마이그레이션이 필요합니다." + +#: src/app/main/data/auth.cljs:347, src/app/main/ui/auth/login.cljs:104, +#: src/app/main/ui/auth/register.cljs:110, +#: src/app/main/ui/auth/register.cljs:304, +#: src/app/main/ui/auth/verify_token.cljs:94, +#: src/app/main/ui/dashboard/team.cljs:199, +#: src/app/main/ui/dashboard/team.cljs:861, +#: src/app/main/ui/onboarding/team_choice.cljs:113, +#: src/app/main/ui/settings/access_tokens.cljs:79, +#: src/app/main/ui/settings/feedback.cljs:84 +msgid "errors.generic" +msgstr "문제가 발생했습니다." + +#: src/app/main/errors.cljs:200 +msgid "errors.internal-assertion-error" +msgstr "내부 검증 오류(Internal Assertion Error)" + +#: src/app/main/errors.cljs:214 +msgid "errors.internal-worker-error" +msgstr "웹 워커에서 문제가 발생했습니다." + +#: src/app/main/ui/components/color_input.cljs:51 +msgid "errors.invalid-color" +msgstr "유효하지 않은 컬러" + +#: src/app/util/forms.cljs:35, src/app/util/forms.cljs:89 +msgid "errors.invalid-data" +msgstr "유효하지 않은 데이터" + +#: src/app/main/ui/auth/register.cljs, src/app/main/ui/auth/login.cljs, +#: src/app/main/ui/auth/recovery_request.cljs +msgid "errors.invalid-email" +msgstr "올바른 이메일 주소를 입력해주세요" + +#: src/app/main/ui/settings/change_email.cljs:62 +msgid "errors.invalid-email-confirmation" +msgstr "확인용 이메일이 일치하지 않습니다" + +#: src/app/util/forms.cljs +msgid "errors.invalid-text" +msgstr "유효하지 않은 텍스트" + +#: src/app/main/ui/static.cljs:74 +msgid "errors.invite-invalid" +msgstr "유효하지 않은 초대" + +#: src/app/main/ui/static.cljs:75 +msgid "errors.invite-invalid.info" +msgstr "이 초대는 취소되었거나 만료되었을 수 있습니다." + +#: src/app/main/ui/auth/login.cljs:89 +msgid "errors.ldap-disabled" +msgstr "LDAP 인증이 비활성화되었습니다." + +#: src/app/main/errors.cljs:291, src/app/main/ui/dashboard/team.cljs:191, +#: src/app/main/ui/onboarding/team_choice.cljs:105 +msgid "errors.max-quota-reached" +msgstr "'%s' 할당량에 도달했습니다. 고객 지원팀에 문의하세요." + +#: src/app/main/ui/dashboard/team.cljs:187, +#: src/app/main/ui/dashboard/team.cljs:849, +#: src/app/main/ui/onboarding/team_choice.cljs:101 +msgid "errors.maximum-invitations-by-request-reached" +msgstr "한 번의 요청으로 초대할 수 있는 최대 이메일 수(%s개)에 도달했습니다" + +#: src/app/main/data/workspace/media.cljs:190 +msgid "errors.media-too-large" +msgstr "이미지 크기가 너무 커서 삽입할 수 없습니다." + +#: src/app/main/data/media.cljs:70, src/app/main/data/workspace/media.cljs:193 +msgid "errors.media-type-mismatch" +msgstr "이미지 내용이 파일 확장자와 일치하지 않는 것 같습니다." + +#: src/app/main/data/media.cljs:67, src/app/main/data/workspace/media.cljs:178, +#: src/app/main/data/workspace/media.cljs:181, +#: src/app/main/data/workspace/media.cljs:184, +#: src/app/main/data/workspace/media.cljs:187 +msgid "errors.media-type-not-allowed" +msgstr "유효한 이미지가 아닌 것 같습니다." + +#: src/app/main/ui/dashboard/team.cljs:622 +msgid "errors.member-is-muted" +msgstr "" +"초대하려는 프로필의 이메일 수신이 거부되었습니다(스팸 신고 또는 높은 반송률)." + +#: src/app/main/errors.cljs:265 +msgid "errors.migration-in-progress" +msgstr "마이그레이션 진행 중" + +#: src/app/main/errors.cljs:174 +msgid "errors.only-creator-can-lock" +msgstr "버전 생성자만 잠글 수 있습니다" + +#: src/app/main/errors.cljs:182 +msgid "errors.only-creator-can-unlock" +msgstr "버전 생성자만 잠금 해제할 수 있습니다" + +#: src/app/main/ui/settings/password.cljs +msgid "errors.password-invalid-confirmation" +msgstr "확인용 비밀번호가 일치하지 않습니다" + +#: src/app/main/ui/settings/password.cljs +msgid "errors.password-too-short" +msgstr "비밀번호는 최소 8자 이상이어야 합니다" + +#: src/app/main/errors.cljs:155 +msgid "errors.paste-data-validation" +msgstr "클립보드에 유효하지 않은 데이터가 있습니다" + +#: src/app/main/data/auth.cljs:337, src/app/main/ui/auth/login.cljs:85, +#: src/app/main/ui/auth/login.cljs:93 +msgid "errors.profile-blocked" +msgstr "차단된 프로필입니다" + +#: src/app/main/ui/auth/recovery_request.cljs:53, +#: src/app/main/ui/dashboard/team.cljs:182, +#: src/app/main/ui/dashboard/team.cljs:618, +#: src/app/main/ui/dashboard/team.cljs:844, +#: src/app/main/ui/onboarding/team_choice.cljs:97, +#: src/app/main/ui/settings/change_email.cljs:33 +msgid "errors.profile-is-muted" +msgstr "" +"사용자 프로필의 이메일 수신이 거부되었습니다(스팸 신고 또는 높은 반송률)." + +#: src/app/main/data/auth.cljs:335, src/app/main/ui/auth/register.cljs:92 +msgid "errors.registration-disabled" +msgstr "현재 회원가입이 비활성화되어 있습니다." + +#: src/app/main/errors.cljs:226 +msgid "errors.svg-parser.invalid-svg" +msgstr "SVG가 유효하지 않거나 형식이 잘못되었습니다" + +#: src/app/main/errors.cljs:270 +msgid "errors.team-feature-mismatch" +msgstr "호환되지 않는 기능 '%s'이(가) 감지되었습니다" + +#: src/app/main/ui/dashboard/sidebar.cljs:373, +#: src/app/main/ui/dashboard/team.cljs:393 +msgid "errors.team-leave.insufficient-members" +msgstr "팀에 본인만 남아 있어 나갈 수 없습니다. 팀을 삭제해야 합니다." + +#: src/app/main/ui/dashboard/sidebar.cljs:376, +#: src/app/main/ui/dashboard/team.cljs:396 +msgid "errors.team-leave.member-does-not-exists" +msgstr "할당하려는 멤버가 존재하지 않습니다." + +#: src/app/main/ui/dashboard/sidebar.cljs:379, +#: src/app/main/ui/dashboard/team.cljs:399 +msgid "errors.team-leave.owner-cant-leave" +msgstr "" +"소유자(Owner)는 팀을 나갈 수 없습니다. 소유자 역할을 먼저 다른 분께 위임해야 " +"합니다." + +#: src/app/main/ui/workspace/tokens/sets/helpers.cljs:26, +#: src/app/main/ui/workspace/tokens/sets/helpers.cljs:45 +msgid "errors.token-set-already-exists" +msgstr "동일한 이름의 token 세트가 이미 존재합니다" + +#: src/app/main/data/tokens.cljs: +msgid "errors.token-set-doesnt-exists" +msgstr "알 수 없는 세트는 복제할 수 없습니다" + +#: src/app/main/data/workspace/tokens/library_edit.cljs:337 +msgid "errors.token-set-exists-on-drop" +msgstr "" +"이동을 완료할 수 없습니다. 해당 경로에 동일한 이름의 세트가 이미 존재합니다." + +#: src/app/main/data/workspace/tokens/library_edit.cljs:125, +#: src/app/main/data/workspace/tokens/library_edit.cljs:144 +msgid "errors.token-theme-already-exists" +msgstr "동일한 이름의 테마 옵션이 이미 존재합니다" + +#: src/app/main/data/media.cljs:73 +msgid "errors.unexpected-error" +msgstr "예기치 않은 오류가 발생했습니다." + +#: src/app/main/errors.cljs:105 +msgid "errors.unexpected-exception" +msgstr "예기치 않은 오류: %s" + +#: src/app/main/ui/auth/verify_token.cljs:62 +msgid "errors.unexpected-token" +msgstr "알 수 없는 token" + +msgid "errors.validation" +msgstr "유효성 검사 오류" + +#: src/app/main/errors.cljs:190 +msgid "errors.version-already-locked" +msgstr "이 버전은 이미 잠겨 있습니다" + +#: src/app/main/errors.cljs:166 +msgid "errors.version-locked" +msgstr "이 버전은 잠겨 있어 다른 사람이 삭제할 수 없습니다" + +#: src/app/main/errors.cljs:287 +msgid "errors.version-not-supported" +msgstr "파일의 버전 번호가 호환되지 않습니다" + +#: src/app/main/ui/static.cljs:315 +msgid "errors.webgl-context-lost.desc-message" +msgstr "WebGL이 작동을 멈췄습니다. 문제를 해결하려면 페이지를 새로고침하세요" + +#: src/app/main/ui/static.cljs:314 +msgid "errors.webgl-context-lost.main-message" +msgstr "캔버스 연결이 끊어졌습니다" + +#: src/app/main/ui/dashboard/team.cljs:1051 +msgid "errors.webhooks.connection" +msgstr "연결 오류, URL에 연결할 수 없습니다" + +#: src/app/main/ui/dashboard/team.cljs:1045 +msgid "errors.webhooks.invalid-uri" +msgstr "URL 유효성 검사를 통과하지 못했습니다." + +#: src/app/main/ui/dashboard/team.cljs:1204 +msgid "errors.webhooks.last-delivery" +msgstr "마지막 전송이 성공하지 못했습니다." + +#: src/app/main/ui/dashboard/team.cljs:1047, +#: src/app/main/ui/dashboard/team.cljs:1207 +msgid "errors.webhooks.ssl-validation" +msgstr "SSL 인증 오류." + +#: src/app/main/ui/dashboard/team.cljs:1049 +msgid "errors.webhooks.timeout" +msgstr "시간 초과" + +#: src/app/main/ui/dashboard/team.cljs:1043 +msgid "errors.webhooks.unexpected" +msgstr "유효성 검사 중 예기치 않은 오류 발생" + +#: src/app/main/ui/dashboard/team.cljs:1053, +#: src/app/main/ui/dashboard/team.cljs:1210 +msgid "errors.webhooks.unexpected-status" +msgstr "예기치 않은 상태 %s" + +#: src/app/main/ui/auth/login.cljs:97, src/app/main/ui/auth/login.cljs:101 +msgid "errors.wrong-credentials" +msgstr "이메일 또는 비밀번호가 올바르지 않습니다." + +#: src/app/main/ui/settings/password.cljs:25 +msgid "errors.wrong-old-password" +msgstr "이전 비밀번호가 올바르지 않습니다" + +#: src/app/main/ui/settings/feedback.cljs:120 +msgid "feedback.description" +msgstr "설명" + +#: src/app/main/ui/settings/feedback.cljs:122 +msgid "feedback.description-placeholder" +msgstr "의견을 보내시는 이유를 설명해주세요" + +#: src/app/main/ui/settings/feedback.cljs:150 +msgid "feedback.discourse-subtitle1" +msgstr "" +"함께해주셔서 기쁩니다. 도움이 필요하시면 게시하기 전에 먼저 검색을 이용해보세" +"요." + +#: src/app/main/ui/settings/feedback.cljs:149 +msgid "feedback.discourse-title" +msgstr "Penpot 커뮤니티" + +#: src/app/main/ui/settings/feedback.cljs:143 +msgid "feedback.other-ways-contact" +msgstr "기타 문의 방법" + +#: src/app/main/ui/settings/feedback.cljs:126 +msgid "feedback.penpot.link" +msgstr "" +"파일이나 프로젝트와 관련된 의견인 경우 여기에 Penpot 링크를 추가해주세요:" + +#: src/app/main/ui/settings/feedback.cljs:105 +msgid "feedback.subject" +msgstr "제목" + +#: src/app/main/ui/settings/feedback.cljs:102 +msgid "feedback.subtitle" +msgstr "" +"문의하시는 이유를 이슈, 제안, 궁금한 점 등으로 구분하여 설명해주세요. 담당 멤" +"버가 최대한 빨리 답변해 드리겠습니다." + +#: src/app/main/ui/settings/feedback.cljs:101 +msgid "feedback.title-contact-us" +msgstr "문의하기" + +#: src/app/main/ui/settings/feedback.cljs:156 +msgid "feedback.twitter-subtitle1" +msgstr "기술적인 문의 사항을 도와드립니다." + +#: src/app/main/ui/settings/feedback.cljs:155 +msgid "feedback.twitter-title" +msgstr "X(Twitter) 지원 계정" + +#: src/app/main/ui/settings/feedback.cljs:110, +#: src/app/main/ui/settings/feedback.cljs:111 +msgid "feedback.type" +msgstr "유형" + +#: src/app/main/ui/settings/feedback.cljs:115 +msgid "feedback.type.doubt" +msgstr "궁금한 점" + +#: src/app/main/ui/settings/feedback.cljs:113 +msgid "feedback.type.idea" +msgstr "아이디어/제안" + +#: src/app/main/ui/settings/feedback.cljs:114 +msgid "feedback.type.issue" +msgstr "이슈/문제" + +#: src/app/main/ui/exports/files.cljs:133 +msgid "files-download-modal.description-2" +msgstr "* 컴포넌트, 그래픽, 컬러 및/또는 타이포그래피가 포함될 수 있습니다." + +#: src/app/main/ui/exports/files.cljs:141 +msgid "files-download-modal.options.all.message" +msgstr "" +"공유 라이브러리가 포함된 파일이 연결 상태를 유지하며 내보내기에 포함됩니다." + +#: src/app/main/ui/exports/files.cljs:142 +msgid "files-download-modal.options.all.title" +msgstr "공유 라이브러리 내보내기" + +#: src/app/main/ui/exports/files.cljs:143 +msgid "files-download-modal.options.detach.message" +msgstr "" +"공유 라이브러리는 내보내기에 포함되지 않으며, 라이브러리에 에셋이 추가되지 않" +"습니다. " + +#: src/app/main/ui/exports/files.cljs:144 +msgid "files-download-modal.options.detach.title" +msgstr "공유 라이브러리 에셋을 일반 객체로 변환" + +#: src/app/main/ui/exports/files.cljs:145 +msgid "files-download-modal.options.merge.message" +msgstr "모든 외부 에셋이 파일 라이브러리에 병합된 상태로 파일이 내보내집니다." + +#: src/app/main/ui/exports/files.cljs:146 +msgid "files-download-modal.options.merge.title" +msgstr "공유 라이브러리 에셋을 파일 라이브러리에 포함" + +#: src/app/main/ui/exports/files.cljs:124 +msgid "files-download-modal.title" +msgstr "파일 다운로드" + +#: src/app/main/ui/settings/password.cljs:31 +msgid "generic.error" +msgstr "오류가 발생했습니다" + +#: src/app/main/ui/components/color_input.cljs:31 +msgid "inspect.attributes.color" +msgstr "색상" + +#: src/app/main/ui/inspect/styles/rows/color_properties_row.cljs:120 +msgid "inspect.attributes.image.preview" +msgstr "도형 채우기 이미지 미리보기" + +msgid "inspect.attributes.stroke.style.none" +msgstr "없음" + +#: src/app/main/ui/inspect/attributes/text.cljs:113 +msgid "inspect.attributes.typography.font-weight" +msgstr "글꼴 굵기" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:397, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:408 +msgid "inspect.attributes.typography.letter-spacing" +msgstr "자간" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:379, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:389 +msgid "inspect.attributes.typography.line-height" +msgstr "행간" + +#: src/app/main/ui/inspect/attributes/text.cljs:140 +msgid "inspect.attributes.typography.text-decoration" +msgstr "텍스트 장식" + +msgid "inspect.attributes.typography.text-decoration.line-through" +msgstr "취소선" + +#: src/app/main/ui/inspect/attributes/text.cljs:111 +msgid "inspect.attributes.typography.text-decoration.none" +msgstr "없음" + +#: src/app/main/ui/inspect/attributes/text.cljs:125, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:437 +msgid "inspect.attributes.typography.text-transform.capitalize" +msgstr "첫 글자 대문자" + +#: src/app/main/ui/inspect/attributes/text.cljs:124 +msgid "inspect.attributes.typography.text-transform.none" +msgstr "없음" + +#: src/app/main/ui/inspect/attributes/text.cljs:127 +msgid "inspect.attributes.typography.text-transform.unset" +msgstr "설정 해제" + +#: src/app/main/ui/inspect/attributes/variant.cljs:44 +msgid "inspect.attributes.variant" +msgstr "베리언트 속성" + +#: src/app/main/ui/inspect/attributes/variant.cljs:44 +msgid "inspect.attributes.variants" +msgstr "베리언트 속성" + +#: src/app/main/ui/inspect/right_sidebar.cljs:170 +msgid "inspect.color-space-label" +msgstr "색상 공간 선택" + +#: src/app/main/ui/inspect/right_sidebar.cljs:234 +msgid "inspect.empty.help" +msgstr "" +"디자인 검사(Inspect)에 대해 더 알고 싶으시면 Penpot 도움말 센터를 방문하세요" + +#: src/app/main/ui/inspect/right_sidebar.cljs:238 +msgid "inspect.empty.more" +msgstr "더 보기" + +#: src/app/main/ui/inspect/right_sidebar.cljs:232 +msgid "inspect.empty.select" +msgstr "속성과 코드를 검사할 도형, 보드 또는 그룹을 선택하세요" + +#: src/app/main/ui/inspect/right_sidebar.cljs:166 +msgid "inspect.layer-info" +msgstr "레이어 정보" + +#: src/app/main/ui/inspect/right_sidebar.cljs:137 +msgid "inspect.multiple-selected" +msgstr "%s개 선택됨" + +#: src/app/main/ui/inspect/right_sidebar.cljs:68 +msgid "inspect.subtitle.copy" +msgstr "복사" + +#: src/app/main/ui/inspect/right_sidebar.cljs:64 +msgid "inspect.subtitle.main" +msgstr "메인 컴포넌트" + +#: src/app/main/ui/inspect/styles/style_box.cljs:68 +msgid "inspect.tabs.styles.copy-shorthand" +msgstr "CSS 단축 표기를 클립보드에 복사" + +#: src/app/main/ui/inspect/styles/property_detail_copiable.cljs:51 +msgid "inspect.tabs.styles.copy-to-clipboard" +msgstr "클립보드에 복사" + +#: src/app/main/ui/inspect/styles/style_box.cljs:22 +msgid "inspect.tabs.styles.geometry-panel" +msgstr "크기 및 위치" + +#: src/app/main/ui/inspect/styles/style_box.cljs:60, +#: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:178 +msgid "inspect.tabs.styles.toggle-style" +msgstr "%s 패널 표시/숨김" + +#: src/app/main/ui/inspect/styles/style_box.cljs:21 +msgid "inspect.tabs.styles.token-panel" +msgstr "token 세트 및 테마" + +#: src/app/main/ui/inspect/styles/rows/color_properties_row.cljs:102, +#: src/app/main/ui/inspect/styles/rows/properties_row.cljs:60 +msgid "inspect.tabs.styles.token-resolved-value" +msgstr "계산된 값:" + +#: src/app/main/ui/inspect/styles/style_box.cljs:20 +msgid "inspect.tabs.styles.variants-panel" +msgstr "베리언트 속성" + +#: src/app/main/ui/dashboard/comments.cljs:96 +msgid "label.mark-all-as-read" +msgstr "모두 읽음으로 표시" + +#: src/app/main/ui/dashboard/sidebar.cljs:1138 +msgid "labels.about-penpot" +msgstr "Penpot 정보" + +#: src/app/main/ui/settings/sidebar.cljs:123 +msgid "labels.access-tokens" +msgstr "액세스 토큰" + +#: src/app/main/ui/workspace/libraries.cljs:169, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1040 +msgid "labels.add" +msgstr "추가" + +#: src/app/main/ui/workspace/libraries.cljs:169 +msgid "labels.adding" +msgstr "추가 중..." + +#: src/app/main/ui/onboarding/questions.cljs:160 +msgid "labels.adobe-xd" +msgstr "Adobe XD" + +#: src/app/main/ui/static.cljs:297 +msgid "labels.bad-gateway.desc-message" +msgstr "" +"잠시 기다린 후 다시 시도해주세요. 현재 서버 유지보수 작업을 진행 중입니다." + +#: src/app/main/ui/inspect/styles/style_box.cljs:26 +msgid "labels.blur" +msgstr "블러" + +#: src/app/main/ui/onboarding/questions.cljs:162 +msgid "labels.canva" +msgstr "Canva" + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:181 +msgid "labels.collapse" +msgstr "접기" + +#: src/app/main/ui/workspace/colorpicker.cljs:424 +msgid "labels.color" +msgstr "색상" + +#: src/app/main/ui/comments.cljs:901 +msgid "labels.comment" +msgstr "댓글" + +#: src/app/main/ui/comments.cljs:905 +msgid "labels.comment.mark-as-solved" +msgstr "해결됨으로 표시" + +#: src/app/main/ui/dashboard/sidebar.cljs:1125 +msgid "labels.community-contributions" +msgstr "커뮤니티 및 기여" + +#: src/app/main/ui/inspect/right_sidebar.cljs:110 +msgid "labels.computed" +msgstr "계산됨" + +#: src/app/main/ui/static.cljs:415 +msgid "labels.contact-support" +msgstr "고객 지원 문의" + +#: src/app/main/ui/settings/sidebar.cljs:136 +msgid "labels.contact-us" +msgstr "문의하기" + +msgid "labels.continue-with" +msgstr "다음으로 계속하기:" + +#: src/app/main/ui/viewer/login.cljs:69 +msgid "labels.continue-with-penpot" +msgstr "Penpot 계정으로 계속할 수 있습니다" + +#: src/app/main/ui/components/copy_button.cljs:41 +msgid "labels.copy" +msgstr "복사" + +#: src/app/main/ui/inspect/attributes/common.cljs:101 +msgid "labels.copy-color" +msgstr "색상 복사" + +#: src/app/main/ui/static.cljs:67 +msgid "labels.copyright-period" +msgstr "Kaleidos © 2019-present" + +#: src/app/main/ui/dashboard/file_menu.cljs:291 +msgid "labels.delete-multi-files" +msgstr "%s개 파일 삭제" + +#: src/app/main/ui/dashboard/deleted.cljs:215 +msgid "labels.deleted" +msgstr "삭제된 항목" + +#: src/app/main/ui/onboarding/questions.cljs:86 +msgid "labels.developer" +msgstr "개발" + +#: src/app/main/ui/onboarding/questions.cljs:260 +msgid "labels.director" +msgstr "디렉터" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:211 +msgid "labels.discard" +msgstr "취소" + +#: src/app/main/ui/settings/feedback.cljs:134, src/app/main/ui/static.cljs:409 +msgid "labels.download" +msgstr "%s 다운로드" + +#: src/app/main/ui/workspace/tokens/sets/context_menu.cljs:65 +msgid "labels.duplicate" +msgstr "복제" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:300 +msgid "labels.empty" +msgstr "비어 있음" + +#: src/app/main/ui/dashboard/import.cljs:297 +msgid "labels.error" +msgstr "오류" + +#: src/app/main/ui/onboarding/questions.cljs:404 +msgid "labels.event" +msgstr "이벤트" + +#: src/app/main/ui/settings/feedback.cljs:83 +msgid "labels.feedback-disabled" +msgstr "의견 보내기 비활성화됨" + +#: src/app/main/ui/settings/feedback.cljs:74 +msgid "labels.feedback-sent" +msgstr "의견이 전송되었습니다" + +#: src/app/main/ui/onboarding/questions.cljs:156 +msgid "labels.figma" +msgstr "Figma" + +#: src/app/main/ui/inspect/styles/style_box.cljs:23 +msgid "labels.fill" +msgstr "채우기" + +#: src/app/main/ui/onboarding/questions.cljs:259 +msgid "labels.founder" +msgstr "CEO 또는 설립자" + +#: src/app/main/ui/onboarding/questions.cljs:258 +msgid "labels.freelancer" +msgstr "프리랜서" + +#: src/app/main/ui/dashboard/sidebar.cljs:929, +#: src/app/main/ui/workspace/main_menu.cljs:176 +msgid "labels.github-repo" +msgstr "Github 저장소" + +#: src/app/main/ui/dashboard/sidebar.cljs:904, +#: src/app/main/ui/workspace/main_menu.cljs:205 +msgid "labels.give-feedback" +msgstr "의견 보내기" + +#: src/app/main/ui/onboarding/questions.cljs:88 +msgid "labels.graphic-design" +msgstr "그래픽 디자인" + +#: src/app/main/ui/dashboard/sidebar.cljs:1114 +msgid "labels.help-learning" +msgstr "도움말 및 학습" + +#: src/app/main/ui/dashboard/templates.cljs:91 +msgid "labels.hide" +msgstr "숨기기" + +#: src/app/main/ui/viewer/comments.cljs:103, +#: src/app/main/ui/workspace/comments.cljs:75 +msgid "labels.hide-resolved-comments" +msgstr "해결된 댓글 숨기기" + +#: src/app/main/ui/workspace/tokens/sidebar.cljs:131 +msgid "labels.import" +msgstr "가져오기" + +#: src/app/main/ui/dashboard/fonts.cljs:430 +msgid "labels.installed-fonts" +msgstr "설치된 글꼴" + +#: src/app/main/ui/static.cljs:405 +msgid "labels.internal-error.desc-message-first" +msgstr "문제가 발생했습니다." + +#: src/app/main/ui/static.cljs:406 +msgid "labels.internal-error.desc-message-second" +msgstr "작업을 다시 시도하거나 지원팀에 문의하세요." + +#: src/app/main/ui/static.cljs:402 +msgid "labels.internal-error.main-message" +msgstr "내부 오류" + +#: src/app/main/ui/onboarding/questions.cljs:164 +msgid "labels.invision" +msgstr "InVision" + +#: src/app/main/ui/dashboard/sidebar.cljs:454, +#: src/app/main/ui/dashboard/team.cljs:102, +#: src/app/main/ui/dashboard/team.cljs:110, +#: src/app/main/ui/dashboard/team.cljs:944 +msgid "labels.invitations" +msgstr "초대" + +#: src/app/main/ui/settings/options.cljs:53 +msgid "labels.language" +msgstr "언어" + +#: src/app/main/ui/inspect/styles/style_box.cljs:28 +msgid "labels.layout" +msgstr "레이아웃" + +#: src/app/main/ui/dashboard/sidebar.cljs:893 +msgid "labels.learning-center" +msgstr "학습 센터" + +#: src/app/main/ui/workspace/main_menu.cljs:168 +msgid "labels.libraries-and-templates" +msgstr "라이브러리 및 템플릿" + +#: src/app/main/ui/auth/verify_token.cljs:100, +#: src/app/main/ui/dashboard/grid.cljs:126, +#: src/app/main/ui/dashboard/grid.cljs:147, +#: src/app/main/ui/dashboard/import.cljs:258, +#: src/app/main/ui/dashboard/placeholder.cljs:140, +#: src/app/main/ui/ds/product/loader.cljs:85, +#: src/app/main/ui/exports/files.cljs:60, src/app/main/ui/viewer.cljs:642, +#: src/app/main/ui/workspace/sidebar/assets/file_library.cljs:249, +#: src/app/main/ui/workspace.cljs:129, src/app/main/ui.cljs:70, +#: src/app/main/ui.cljs:108, src/app/main/ui.cljs:127 +msgid "labels.loading" +msgstr "로드 중…" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:210 +msgid "labels.lock" +msgstr "잠금" + +#: src/app/main/ui/viewer/header.cljs:205 +msgid "labels.log-or-sign" +msgstr "로그인 또는 회원가입" + +#: src/app/main/ui/static.cljs:61, src/app/main/ui/static.cljs:137 +msgid "labels.login" +msgstr "로그인" + +#: src/app/main/ui/dashboard/sidebar.cljs:1148 +msgid "labels.logout" +msgstr "로그아웃" + +#: src/app/main/ui/onboarding/questions.cljs:89 +msgid "labels.marketing" +msgstr "마케팅" + +#: src/app/main/ui/dashboard/team.cljs:512 +msgid "labels.member" +msgstr "구성원" + +#: src/app/main/ui/dashboard/sidebar.cljs:450, +#: src/app/main/ui/dashboard/team.cljs:100, +#: src/app/main/ui/dashboard/team.cljs:108 +msgid "labels.members" +msgstr "구성원" + +#: src/app/main/ui/comments.cljs:581 +msgid "labels.mention" +msgstr "멘션" + +#: src/app/main/ui/ds/controls/numeric_input.cljs:631 +msgid "labels.mixed-values" +msgstr "혼합" + +#: src/app/main/ui/settings/password.cljs:86 +msgid "labels.new-password" +msgstr "새 비밀번호" + +#: src/app/main/ui/dashboard/templates.cljs:301, +#: src/app/main/ui/onboarding/questions.cljs:54, +#: src/app/main/ui/viewer.cljs:112 +msgid "labels.next" +msgstr "다음" + +#: src/app/main/ui/dashboard/comments.cljs:122, +#: src/app/main/ui/workspace/comments.cljs:162 +msgid "labels.no-comments-available" +msgstr "모든 알림을 확인했습니다! 새 댓글 알림이 여기에 표시됩니다." + +#: src/app/main/ui/dashboard/team.cljs:737 +msgid "labels.no-invitations" +msgstr "대기 중인 초대가 없습니다." + +#: src/app/main/ui/dashboard/team.cljs:739 +msgid "labels.no-invitations-gather-people" +msgstr "사람들을 모아 멋진 결과물을 함께 만들어보세요." + +#: src/app/main/ui/static.cljs +msgid "labels.not-found.desc-message" +msgstr "페이지가 존재하지 않거나 접근 권한이 없습니다." + +#: src/app/main/ui/static.cljs:286 +msgid "labels.not-found.main-message" +msgstr "죄송합니다!" + +#: src/app/main/ui/settings/sidebar.cljs:103 +msgid "labels.notifications" +msgstr "알림" + +#: src/app/main/ui/dashboard/projects.cljs:240, +#: src/app/main/ui/dashboard/team.cljs:1354 +msgid "labels.num-of-files" +msgid_plural "labels.num-of-files" +msgstr[0] "파일 %s개" + +#: src/app/main/ui/viewer/thumbnails.cljs:82 +msgid "labels.num-of-frames" +msgid_plural "labels.num-of-frames" +msgstr[0] "보드 %s개" + +#: src/app/main/ui/dashboard/team.cljs:1349 +msgid "labels.num-of-projects" +msgid_plural "labels.num-of-projects" +msgstr[0] "프로젝트 %개" + +msgid "labels.ok" +msgstr "확인" + +#: src/app/main/ui/settings/password.cljs:79 +msgid "labels.old-password" +msgstr "이전 비밀번호" + +#: src/app/main/ui/workspace/comments.cljs +msgid "labels.only-yours" +msgstr "내 항목만" + +#: src/app/main/ui/comments.cljs:911, src/app/main/ui/comments.cljs:976, +#: src/app/main/ui/workspace/palette.cljs:199, +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:107, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:906, +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:155, +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:213, +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:294, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:402, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1031, +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:316, +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:345, +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:146 +msgid "labels.options" +msgstr "옵션" + +msgid "labels.or" +msgstr "또는" + +#: src/app/main/ui/onboarding/questions.cljs:131, +#: src/app/main/ui/onboarding/questions.cljs:203, +#: src/app/main/ui/onboarding/questions.cljs:285, +#: src/app/main/ui/onboarding/questions.cljs:358 +msgid "labels.other" +msgstr "기타 (직접 입력)" + +#: src/app/main/ui/onboarding/questions.cljs:91, +#: src/app/main/ui/onboarding/questions.cljs:166, +#: src/app/main/ui/onboarding/questions.cljs:255, +#: src/app/main/ui/onboarding/questions.cljs:324 +msgid "labels.other-short" +msgstr "기타" + +#: src/app/main/ui/dashboard/team.cljs:324, +#: src/app/main/ui/dashboard/team.cljs:564, +#: src/app/main/ui/dashboard/team.cljs:1335 +msgid "labels.owner" +msgstr "소유자(Owner)" + +#: src/app/main/ui/settings/sidebar.cljs:98 +msgid "labels.password" +msgstr "비밀번호" + +#: src/app/main/ui/dashboard/team.cljs:669 +msgid "labels.pending-invitation" +msgstr "대기 중" + +#: src/app/main/ui/dashboard/sidebar.cljs:973 +msgid "labels.penpot-changelog" +msgstr "Penpot 변경 사항" + +#: src/app/main/ui/dashboard/sidebar.cljs:899 +msgid "labels.penpot-hub" +msgstr "Penpot 허브" + +#: src/app/main/ui/comments.cljs:680 +msgid "labels.post" +msgstr "게시" + +#: src/app/main/ui/onboarding/questions.cljs:50, +#: src/app/main/ui/viewer.cljs:105 +msgid "labels.previous" +msgstr "이전" + +#: src/app/main/ui/onboarding/questions.cljs:85 +msgid "labels.product-design" +msgstr "제품 또는 UX 디자인" + +#: src/app/main/ui/onboarding/questions.cljs:90 +msgid "labels.product-management" +msgstr "제품 관리(PM)" + +#: src/app/main/ui/settings/profile.cljs:128, +#: src/app/main/ui/settings/sidebar.cljs:93 +msgid "labels.profile" +msgstr "프로필" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:205, +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:179 +msgid "labels.reference" +msgstr "참조" + +#: src/app/main/data/common.cljs:83 +msgid "labels.refresh" +msgstr "새로고침" + +#: src/app/main/ui/settings/sidebar.cljs:129, +#: src/app/main/ui/workspace/main_menu.cljs:160 +msgid "labels.release-notes" +msgstr "릴리즈 노트" + +#: src/app/main/ui/workspace.cljs +msgid "labels.reload-file" +msgstr "파일 다시 로드" + +#: src/app/main/ui/static.cljs:318 +msgid "labels.reload-page" +msgstr "페이지 새로고침" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:167, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:406 +msgid "labels.remove" +msgstr "제거" + +#: src/app/main/ui/dashboard/team.cljs:355 +msgid "labels.remove-member" +msgstr "구성원 제거" + +#: src/app/main/ui/dashboard/file_menu.cljs:299, +#: src/app/main/ui/dashboard/project_menu.cljs:88, +#: src/app/main/ui/dashboard/sidebar.cljs:471, +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:167, +#: src/app/main/ui/workspace/sidebar/versions.cljs:192, +#: src/app/main/ui/workspace/tokens/sets/context_menu.cljs:63 +msgid "labels.rename" +msgstr "이름 바꾸기" + +#: src/app/main/ui/dashboard/team_form.cljs:98 +msgid "labels.rename-team" +msgstr "팀 이름 바꾸기" + +#: src/app/main/ui/comments.cljs:642 +msgid "labels.replies" +msgstr "답글" + +#: src/app/main/ui/comments.cljs:647 +msgid "labels.replies.new" +msgstr "새 답글" + +#: src/app/main/ui/comments.cljs:641 +msgid "labels.reply" +msgstr "답글" + +#: src/app/main/ui/comments.cljs:646 +msgid "labels.reply.new" +msgstr "새 답글" + +#: src/app/main/ui/comments.cljs:713 +msgid "labels.reply.thread" +msgstr "답글 달기" + +#: src/app/main/ui/dashboard/team.cljs:788 +msgid "labels.resend" +msgstr "재전송" + +#: src/app/main/ui/dashboard/team.cljs:938 +msgid "labels.resend-invitation" +msgstr "초대 재전송" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:87, +#: src/app/main/ui/workspace/sidebar/versions.cljs:197 +msgid "labels.restore" +msgstr "복원" + +#: src/app/main/ui/components/progress.cljs:80, +#: src/app/main/ui/static.cljs:299, src/app/main/ui/static.cljs:308, +#: src/app/main/ui/static.cljs:419 +msgid "labels.retry" +msgstr "다시 시도" + +#: src/app/main/ui/dashboard/team.cljs:513, +#: src/app/main/ui/dashboard/team.cljs:945 +msgid "labels.role" +msgstr "역할" + +#: src/app/main/ui/dashboard/fonts.cljs:395, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:204, +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:301, +#: src/app/main/ui/workspace/tokens/settings/menu.cljs:110 +msgid "labels.save" +msgstr "저장" + +#: src/app/main/ui/dashboard/fonts.cljs:435 +msgid "labels.search-font" +msgstr "글꼴 검색" + +#: src/app/main/ui/onboarding/questions.cljs:84, +#: src/app/main/ui/onboarding/questions.cljs:230, +#: src/app/main/ui/onboarding/questions.cljs:240 +msgid "labels.select-option" +msgstr "옵션 선택" + +#: src/app/main/ui/settings/feedback.cljs:137 +msgid "labels.send" +msgstr "전송" + +#: src/app/main/ui/settings/feedback.cljs:137 +msgid "labels.sending" +msgstr "전송 중…" + +#: src/app/main/ui/static.cljs:306 +msgid "labels.service-unavailable.desc-message" +msgstr "시스템 정기 점검 중입니다." + +#: src/app/main/ui/static.cljs:305 +msgid "labels.service-unavailable.main-message" +msgstr "서비스를 사용할 수 없음" + +#: src/app/main/ui/workspace/tokens/sidebar.cljs:75 +msgid "labels.sets" +msgstr "세트" + +#: src/app/main/ui/dashboard/sidebar.cljs:464, +#: src/app/main/ui/dashboard/team.cljs:101, +#: src/app/main/ui/dashboard/team.cljs:115, +#: src/app/main/ui/settings/options.cljs:87, +#: src/app/main/ui/settings/sidebar.cljs:109 +msgid "labels.settings" +msgstr "설정" + +#: src/app/main/ui/inspect/styles/style_box.cljs:27, +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:229 +msgid "labels.shadow" +msgstr "그림자" + +#: src/app/main/ui/viewer/header.cljs:201 +msgid "labels.share" +msgstr "공유" + +msgid "labels.share-prototype" +msgstr "프로토타입 공유" + +#: src/app/main/ui/dashboard/sidebar.cljs:840 +msgid "labels.shared-libraries" +msgstr "라이브러리" + +#: src/app/main/ui/dashboard/templates.cljs:87 +msgid "labels.show" +msgstr "표시" + +#: src/app/main/ui/viewer/comments.cljs:82, +#: src/app/main/ui/workspace/comments.cljs:57, +#: src/app/main/ui/workspace/comments.cljs:136 +msgid "labels.show-all-comments" +msgstr "모든 댓글 보기" + +#: src/app/main/ui/viewer/comments.cljs:115 +msgid "labels.show-comments-list" +msgstr "댓글 목록 보기" + +#: src/app/main/ui/workspace/comments.cljs:69, +#: src/app/main/ui/workspace/comments.cljs:138 +msgid "labels.show-mentions" +msgstr "내 멘션만 보기" + +#: src/app/main/ui/viewer/comments.cljs:91, +#: src/app/main/ui/workspace/comments.cljs:63, +#: src/app/main/ui/workspace/comments.cljs:137 +msgid "labels.show-your-comments" +msgstr "내 댓글만 보기" + +#: src/app/main/ui/onboarding/questions.cljs:158 +msgid "labels.sketch" +msgstr "Sketch" + +#: src/app/main/ui/dashboard/sidebar.cljs:825 +msgid "labels.sources" +msgstr "원본" + +#: src/app/main/ui/onboarding/questions.cljs:55 +msgid "labels.start" +msgstr "시작" + +#: src/app/main/ui/dashboard/team.cljs:954 +msgid "labels.status" +msgstr "상태" + +#: src/app/main/ui/inspect/styles/style_box.cljs:24, +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:46 +msgid "labels.stroke" +msgstr "선" + +#: src/app/main/ui/onboarding/questions.cljs:87 +msgid "labels.student-teacher" +msgstr "학생 또는 교사" + +#: src/app/main/ui/inspect/right_sidebar.cljs:108, +#: src/app/main/ui/inspect/styles.cljs:135 +msgid "labels.styles" +msgstr "스타일" + +#: src/app/main/ui/inspect/styles/style_box.cljs:33 +msgid "labels.svg" +msgstr "SVG" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:261 +msgid "labels.switch" +msgstr "전환" + +#: src/app/main/ui/onboarding/questions.cljs:256 +msgid "labels.team-leader" +msgstr "팀 리더" + +#: src/app/main/ui/onboarding/questions.cljs:257 +msgid "labels.team-member" +msgstr "팀 구성원" + +#: src/app/main/ui/inspect/styles/style_box.cljs:25 +msgid "labels.text" +msgstr "텍스트" + +#: src/app/main/ui/workspace/tokens/themes.cljs:36 +msgid "labels.themes" +msgstr "테마" + +#: src/app/main/ui/workspace/main_menu.cljs:152 +msgid "labels.tutorials" +msgstr "튜토리얼" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:189 +msgid "labels.typography" +msgstr "타이포그래피" + +#: src/app/main/data/workspace/tokens/errors.cljs:121 +msgid "labels.unknown-error" +msgstr "알 수 없는 오류" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:204 +msgid "labels.unlock" +msgstr "잠금 해제" + +#: src/app/main/ui/dashboard/file_menu.cljs:285 +msgid "labels.unpublish-multi-files" +msgstr "%s개 파일 게시 취소" + +#: src/app/main/ui/settings/profile.cljs:111 +msgid "labels.update" +msgstr "업데이트" + +#: src/app/main/ui/dashboard/team_form.cljs:119 +msgid "labels.update-team" +msgstr "팀 업데이트" + +#: src/app/main/ui/dashboard/fonts.cljs:253 +msgid "labels.upload" +msgstr "업로드" + +#: src/app/main/ui/dashboard/fonts.cljs:180 +msgid "labels.upload-custom-fonts" +msgstr "사용자 지정 글꼴 업로드" + +#: src/app/main/ui/dashboard/fonts.cljs:252 +msgid "labels.uploading" +msgstr "업로드 중…" + +#: src/app/main/ui/inspect/right_sidebar.cljs:66, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1039 +msgid "labels.variant" +msgstr "베리언트" + +#: src/app/main/ui/dashboard/sidebar.cljs:967 +msgid "labels.version-notes" +msgstr "버전 %s 노트" + +#: src/app/main/ui/inspect/styles/style_box.cljs:32 +msgid "labels.visibility" +msgstr "가시성" + +#: src/app/main/ui/dashboard/team.cljs:268 +msgid "labels.you" +msgstr "(나)" + +#: src/app/main/ui/dashboard/sidebar.cljs:1101 +msgid "labels.your-account" +msgstr "내 계정" + +#: src/app/main/ui/onboarding/questions.cljs:403 +msgid "labels.youtube" +msgstr "YouTube" + +#: src/app/main/ui/ds/product/loader.cljs:21 +msgid "loader.tips.01.message" +msgstr "여러 프로젝트에서 디자인의 일관성을 유지하고 간편하게 업데이트하세요." + +#: src/app/main/ui/ds/product/loader.cljs:20 +msgid "loader.tips.01.title" +msgstr "재사용 가능한 컴포넌트" + +#: src/app/main/ui/ds/product/loader.cljs:23 +msgid "loader.tips.02.message" +msgstr "팀원들과 실시간으로 작업하고 즉시 피드백을 공유하세요." + +#: src/app/main/ui/ds/product/loader.cljs:22 +msgid "loader.tips.02.title" +msgstr "실시간 협업" + +#: src/app/main/ui/ds/product/loader.cljs:25 +msgid "loader.tips.03.message" +msgstr "익숙한 CSS 방식의 레이아웃 컨트롤로 유연하게 디자인하세요." + +#: src/app/main/ui/ds/product/loader.cljs:24 +msgid "loader.tips.03.title" +msgstr "CSS 스타일 레이아웃" + +#: src/app/main/ui/ds/product/loader.cljs:27 +msgid "loader.tips.04.message" +msgstr "디자인에서 직접 CSS 및 SVG 코드를 추출하세요." + +#: src/app/main/ui/ds/product/loader.cljs:26 +msgid "loader.tips.04.title" +msgstr "코드로 내보내기" + +#: src/app/main/ui/ds/product/loader.cljs:29 +msgid "loader.tips.05.message" +msgstr "에셋과 스타일을 공유하여 일관성을 유지하세요." + +#: src/app/main/ui/ds/product/loader.cljs:28 +msgid "loader.tips.05.title" +msgstr "디자인 라이브러리" + +#: src/app/main/ui/ds/product/loader.cljs:31 +msgid "loader.tips.06.message" +msgstr "애니메이션과 트랜지션을 통해 아이디어에 생동감을 불어넣으세요." + +#: src/app/main/ui/ds/product/loader.cljs:30 +msgid "loader.tips.06.title" +msgstr "인터랙티브 프로토타입" + +#: src/app/main/ui/ds/product/loader.cljs:33 +msgid "loader.tips.07.message" +msgstr "Penpot은 원활한 개발을 위해 SVG와 CSS 표준을 사용합니다." + +#: src/app/main/ui/ds/product/loader.cljs:32 +msgid "loader.tips.07.title" +msgstr "웹 표준 포맷" + +#: src/app/main/ui/ds/product/loader.cljs:35 +msgid "loader.tips.08.message" +msgstr "" +"Shift + A(오토 레이아웃)와 같은 편리한 단축키로 워크플로우를 가속화하세요." + +#: src/app/main/ui/ds/product/loader.cljs:34 +msgid "loader.tips.08.title" +msgstr "키보드 단축키" + +#: src/app/main/ui/ds/product/loader.cljs:37 +msgid "loader.tips.09.message" +msgstr "내 스타일에 맞는 테마를 선택하세요." + +#: src/app/main/ui/ds/product/loader.cljs:36 +msgid "loader.tips.09.title" +msgstr "다크 및 라이트 모드" + +#: src/app/main/ui/ds/product/loader.cljs:39 +msgid "loader.tips.10.message" +msgstr "커뮤니티에서 제작한 플러그인으로 Penpot의 기능을 확장해보세요." + +#: src/app/main/ui/ds/product/loader.cljs:38 +msgid "loader.tips.10.title" +msgstr "플러그인 지원" + +#: src/app/main/ui/workspace/colorpicker.cljs:484, +#: src/app/main/ui/workspace/colorpicker.cljs:485, +#: src/app/main/ui/workspace/colorpicker.cljs:487 +msgid "media.choose-image" +msgstr "이미지 선택" + +#: src/app/main/ui/workspace/colorpicker.cljs:257 +msgid "media.gradient" +msgstr "그라데이션" + +#: src/app/main/data/workspace/media.cljs:270, +#: src/app/main/ui/components/color_bullet.cljs:33, +#: src/app/main/ui/components/color_bullet.cljs:46, +#: src/app/main/ui/ds/utilities/swatch.cljs:45, +#: src/app/main/ui/ds/utilities/swatch.cljs:58, +#: src/app/main/ui/inspect/attributes/common.cljs:43, +#: src/app/main/ui/inspect/styles/rows/color_properties_row.cljs:66, +#: src/app/main/ui/workspace/colorpicker.cljs:259, +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:402 +msgid "media.image" +msgstr "이미지" + +#: src/app/main/ui/inspect/attributes/common.cljs:53 +msgid "media.image.short" +msgstr "img" + +#: src/app/main/ui/workspace/colorpicker.cljs:477 +msgid "media.keep-aspect-ratio" +msgstr "가로세로 비율 유지" + +#: src/app/main/ui/workspace/colorpicker.cljs:228 +msgid "media.linear" +msgstr "선형" + +#: src/app/main/ui/workspace/colorpicker.cljs:229 +msgid "media.radial" +msgstr "방사형" + +#: src/app/main/ui/workspace/colorpicker.cljs:255 +msgid "media.solid" +msgstr "단색" + +#: src/app/main/data/common.cljs:118 +msgid "modals.add-shared-confirm-empty.hint" +msgstr "" +"라이브러리가 비어 있습니다. 공유 라이브러리로 추가하면, 앞으로 생성할 에셋들" +"을 다른 파일에서도 사용할 수 있게 됩니다. 정말 게시하시겠습니까?" + +#: src/app/main/data/common.cljs:118 +msgid "modals.add-shared-confirm.hint" +msgstr "" +"공유 라이브러리로 추가하면, 이 파일 라이브러리의 에셋들을 다른 파일에서도 사" +"용할 수 있게 됩니다." + +#: src/app/main/ui/workspace/nudge.cljs:59 +msgid "modals.big-nudge" +msgstr "크게 이동" + +#: src/app/main/ui/settings/change_email.cljs:97 +msgid "modals.change-email.info" +msgstr "본인 확인을 위해 현재 이메일인 \"%s\"로 이메일을 보내드립니다." + +#: src/app/main/ui/settings/access_tokens.cljs:152, +#: src/app/main/ui/settings/access_tokens.cljs:158 +msgid "modals.create-access-token.copy-token" +msgstr "토큰 복사" + +#: src/app/main/ui/settings/access_tokens.cljs:130 +msgid "modals.create-access-token.expiration-date.label" +msgstr "만료일" + +#: src/app/main/ui/settings/access_tokens.cljs:124 +msgid "modals.create-access-token.name.label" +msgstr "이름" + +#: src/app/main/ui/settings/access_tokens.cljs:126 +msgid "modals.create-access-token.name.placeholder" +msgstr "토큰의 용도를 알 수 있는 이름을 입력하세요:" + +#: src/app/main/ui/settings/access_tokens.cljs:178 +msgid "modals.create-access-token.submit-label" +msgstr "토큰 생성" + +#: src/app/main/ui/settings/access_tokens.cljs:111 +msgid "modals.create-access-token.title" +msgstr "액세스 토큰 생성" + +#: src/app/main/ui/dashboard/team.cljs:1103 +msgid "modals.create-webhook.url.label" +msgstr "페이로드(Payload) URL" + +#: src/app/main/ui/dashboard/team.cljs:1104 +msgid "modals.create-webhook.url.placeholder" +msgstr "https://example.com/postreceive" + +#: src/app/main/ui/settings/access_tokens.cljs:257 +msgid "modals.delete-acces-token.accept" +msgstr "토큰 삭제" + +#: src/app/main/ui/settings/access_tokens.cljs:256 +msgid "modals.delete-acces-token.message" +msgstr "정말 이 토큰을 삭제하시겠습니까?" + +#: src/app/main/ui/settings/access_tokens.cljs:255 +msgid "modals.delete-acces-token.title" +msgstr "토큰 삭제" + +#: src/app/main/ui/settings/delete_account.cljs:56 +msgid "modals.delete-account.cancel" +msgstr "취소하고 계정 유지" + +#: src/app/main/ui/settings/delete_account.cljs:61 +msgid "modals.delete-account.confirm" +msgstr "네, 계정을 삭제합니다" + +#: src/app/main/ui/settings/delete_account.cljs:50 +msgid "modals.delete-account.info" +msgstr "계정을 삭제하면 현재의 모든 프로젝트와 아카이브를 잃게 됩니다." + +#: src/app/main/ui/settings/delete_account.cljs:43 +msgid "modals.delete-account.title" +msgstr "정말 계정을 삭제하시겠습니까?" + +#: src/app/main/ui/comments.cljs:888 +msgid "modals.delete-comment-thread.message" +msgstr "정말 이 대화를 삭제하시겠습니까? 이 스레드의 모든 댓글이 삭제됩니다." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:156 +msgid "modals.delete-component-annotation.message" +msgstr "이 주석을 삭제하시겠습니까?" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:155 +msgid "modals.delete-component-annotation.title" +msgstr "주석 삭제" + +#: src/app/main/ui/dashboard/file_menu.cljs:118 +msgid "modals.delete-file-multi-confirm.message" +msgstr "%s개의 파일을 삭제하시겠습니까?" + +#: src/app/main/ui/dashboard/file_menu.cljs:117 +msgid "modals.delete-file-multi-confirm.title" +msgstr "%s개 파일 삭제 중" + +#: src/app/main/ui/dashboard/fonts.cljs:356 +msgid "modals.delete-font-variant.message" +msgstr "" +"정말 이 글꼴 스타일을 삭제하시겠습니까? 파일에서 사용 중인 경우 로드되지 않습" +"니다." + +#: src/app/main/ui/dashboard/fonts.cljs:342 +msgid "modals.delete-font.message" +msgstr "이 글꼴을 삭제하시겠습니까? 파일에서 사용 중인 경우 로드되지 않습니다." + +#: src/app/main/ui/delete_shared.cljs:54 +msgid "modals.delete-shared-confirm.accept" +msgid_plural "modals.delete-shared-confirm.accept" +msgstr[0] "파일 삭제" + +#: src/app/main/ui/delete_shared.cljs:58 +msgid "modals.delete-shared-confirm.activated.no-files-message" +msgid_plural "modals.delete-shared-confirm.activated.no-files-message" +msgstr[0] "어떤 파일에서도 활성화되어 있지 않습니다." + +#: src/app/main/ui/delete_shared.cljs:60 +msgid "modals.delete-shared-confirm.activated.scd-message" +msgid_plural "modals.delete-shared-confirm.activated.scd-message" +msgstr[0] "다음 위치에서 이 라이브러리가 활성화되어 있습니다: " + +#: src/app/main/ui/delete_shared.cljs:49 +msgid "modals.delete-shared-confirm.message" +msgid_plural "modals.delete-shared-confirm.message" +msgstr[0] "이 파일들을 삭제하시겠습니까?" + +#: src/app/main/ui/delete_shared.cljs:44 +msgid "modals.delete-shared-confirm.title" +msgid_plural "modals.delete-shared-confirm.title" +msgstr[0] "파일 삭제 중" + +#: src/app/main/ui/dashboard/sidebar.cljs:443 +msgid "modals.delete-team-confirm.accept" +msgstr "팀 삭제" + +#: src/app/main/ui/dashboard/sidebar.cljs:442 +msgid "modals.delete-team-confirm.message" +msgstr "" +"이 팀을 삭제하시겠습니까? 팀과 관련된 모든 프로젝트와 파일이 영구적으로 삭제" +"됩니다." + +#: src/app/main/ui/dashboard/sidebar.cljs:441 +msgid "modals.delete-team-confirm.title" +msgstr "팀 삭제 중" + +#: src/app/main/ui/dashboard/team.cljs:461 +msgid "modals.delete-team-member-confirm.accept" +msgstr "구성원 삭제" + +#: src/app/main/ui/dashboard/team.cljs:460 +msgid "modals.delete-team-member-confirm.message" +msgstr "이 구성원을 팀에서 삭제하시겠습니까?" + +#: src/app/main/ui/dashboard/team.cljs:459 +msgid "modals.delete-team-member-confirm.title" +msgstr "팀 구성원 삭제" + +#: src/app/main/ui/delete_shared.cljs:62 +msgid "modals.delete-unpublish-shared-confirm.activated.hint" +msgid_plural "modals.delete-unpublish-shared-confirm.activated.hint" +msgstr[0] "" +"해당 파일에서 이미 사용된 에셋은 그대로 유지되며, 디자인에는 문제가 발생하지 " +"않습니다." + +#: src/app/main/ui/dashboard/team.cljs:1197 +msgid "modals.delete-webhook.accept" +msgstr "웹훅 삭제" + +#: src/app/main/ui/dashboard/team.cljs:1196 +msgid "modals.delete-webhook.message" +msgstr "이 웹훅을 삭제하시겠습니까?" + +#: src/app/main/ui/dashboard/team.cljs:1195 +msgid "modals.delete-webhook.title" +msgstr "웹훅 삭제 중" + +#: src/app/main/ui/dashboard/team.cljs:1126 +msgid "modals.edit-webhook.submit-label" +msgstr "웹훅 수정" + +#: src/app/main/ui/dashboard/team.cljs:1091 +msgid "modals.edit-webhook.title" +msgstr "웹훅 수정" + +#: src/app/main/ui/dashboard/team.cljs:249 +msgid "modals.invite-member-confirm.accept" +msgstr "초대 전송" + +#: src/app/main/ui/dashboard/team.cljs:245, +#: src/app/main/ui/onboarding/team_choice.cljs:203 +msgid "modals.invite-member.emails" +msgstr "이메일 주소 (쉼표로 구분)" + +#: src/app/main/ui/dashboard/team.cljs:229 +msgid "modals.invite-member.repeated-invitation" +msgstr "" +"일부 구성원은 이미 팀에 소속되어 있습니다. 나머지 구성원들만 초대합니다." + +#: src/app/main/ui/dashboard/team.cljs:222 +msgid "modals.invite-team-member.text" +msgstr "" +"팀에 구성원을 초대하여 이 파일과 모든 팀 파일에 접근할 수 있도록 할 수 있습니" +"다." + +#: src/app/main/ui/dashboard/team.cljs:218 +msgid "modals.invite-team-member.title" +msgstr "팀에 구성원 초대" + +#: src/app/main/ui/dashboard/sidebar.cljs:431, +#: src/app/main/ui/dashboard/team.cljs:427 +msgid "modals.leave-and-close-confirm.hint" +msgstr "" +"귀하가 이 팀의 유일한 구성원이므로, 팀을 나가면 프로젝트 및 파일과 함께 팀이 " +"삭제됩니다." + +#: src/app/main/ui/dashboard/sidebar.cljs:430, +#: src/app/main/ui/dashboard/team.cljs:426 +msgid "modals.leave-and-close-confirm.message" +msgstr "정말 %s 팀을 나가시겠습니까?" + +#: src/app/main/ui/dashboard/change_owner.cljs:54 +msgid "modals.leave-and-reassign.forbidden" +msgstr "" +"소유자(Owner) 역할을 위임할 다른 구성원이 없으면 팀을 나갈 수 없습니다. 팀 삭" +"제를 고려해보세요." + +#: src/app/main/ui/dashboard/change_owner.cljs:50 +msgid "modals.leave-and-reassign.hint1" +msgstr "" +"귀하는 이 팀의 소유자입니다. 나가기 전에 소유자 역할을 위임할 다른 구성원을 " +"선택해주세요." + +#: src/app/main/ui/dashboard/change_owner.cljs:73 +msgid "modals.leave-and-reassign.promote-and-leave" +msgstr "위임하고 나가기" + +#: src/app/main/ui/dashboard/change_owner.cljs:30 +msgid "modals.leave-and-reassign.select-member-to-promote" +msgstr "위임할 구성원 선택" + +#: src/app/main/ui/dashboard/change_owner.cljs:44 +msgid "modals.leave-and-reassign.title" +msgstr "팀을 나가기 전에" + +#: src/app/main/ui/dashboard/sidebar.cljs:410, +#: src/app/main/ui/dashboard/sidebar.cljs:432, +#: src/app/main/ui/dashboard/team.cljs:428, +#: src/app/main/ui/dashboard/team.cljs:450 +msgid "modals.leave-confirm.accept" +msgstr "팀 나가기" + +#: src/app/main/ui/dashboard/sidebar.cljs:409, +#: src/app/main/ui/dashboard/team.cljs:449 +msgid "modals.leave-confirm.message" +msgstr "이 팀을 나가시겠습니까?" + +#: src/app/main/ui/dashboard/sidebar.cljs:408, +#: src/app/main/ui/dashboard/sidebar.cljs:429, +#: src/app/main/ui/dashboard/team.cljs:425, +#: src/app/main/ui/dashboard/team.cljs:448 +msgid "modals.leave-confirm.title" +msgstr "팀 나가는 중" + +#: src/app/main/ui/delete_shared.cljs:56 +msgid "modals.move-shared-confirm.accept" +msgid_plural "modals.move-shared-confirm.accept" +msgstr[0] "이동" + +#: src/app/main/ui/delete_shared.cljs:51 +msgid "modals.move-shared-confirm.message" +msgid_plural "modals.move-shared-confirm.message" +msgstr[0] "이 라이브러리를 이동하시겠습니까?" + +#: src/app/main/ui/delete_shared.cljs:46 +msgid "modals.move-shared-confirm.title" +msgid_plural "modals.move-shared-confirm.title" +msgstr[0] "라이브러리 이동" + +#: src/app/main/ui/workspace/main_menu.cljs:302, +#: src/app/main/ui/workspace/nudge.cljs:46 +msgid "modals.nudge-title" +msgstr "이동 간격" + +#: src/app/main/ui/dashboard/team.cljs:380 +msgid "modals.promote-owner-confirm.accept" +msgstr "소유권 이전" + +#: src/app/main/ui/dashboard/team.cljs:379 +msgid "modals.promote-owner-confirm.hint" +msgstr "" +"소유권을 이전하면 귀하의 역할이 관리자(Admin)로 변경되며, 이 팀에 대한 일부 " +"권한을 잃게 됩니다. " + +#: src/app/main/ui/dashboard/team.cljs:378 +msgid "modals.promote-owner-confirm.message" +msgstr "" +"귀하는 현재 이 팀의 소유자입니다. 정말 %s님을 팀의 새 소유자로 지정하시겠습니" +"까?" + +#: src/app/main/ui/dashboard/team.cljs:377 +msgid "modals.promote-owner-confirm.title" +msgstr "새 팀 소유자" + +#: src/app/main/ui/workspace/libraries.cljs:286 +msgid "modals.publish-empty-library.accept" +msgstr "게시" + +#: src/app/main/ui/workspace/libraries.cljs:285 +msgid "modals.publish-empty-library.message" +msgstr "라이브러리가 비어 있습니다. 정말 게시하시겠습니까?" + +#: src/app/main/ui/workspace/libraries.cljs:284 +msgid "modals.publish-empty-library.title" +msgstr "빈 라이브러리 게시" + +#: src/app/main/ui/workspace/header.cljs, +#: src/app/main/ui/dashboard/file_menu.cljs +msgid "modals.remove-shared-confirm.accept" +msgstr "공유 라이브러리에서 제거" + +#: src/app/main/ui/workspace/header.cljs, +#: src/app/main/ui/dashboard/file_menu.cljs +msgid "modals.remove-shared-confirm.hint" +msgstr "" +"공유 라이브러리에서 제거하면, 이 파일의 라이브러리를 다른 파일에서 더 이상 사" +"용할 수 없게 됩니다." + +#: src/app/main/ui/workspace/header.cljs, +#: src/app/main/ui/dashboard/file_menu.cljs +msgid "modals.remove-shared-confirm.message" +msgstr "\"%s\"를 공유 라이브러리에서 제거" + +#: src/app/main/ui/workspace/nudge.cljs:52 +msgid "modals.small-nudge" +msgstr "미세 이동" + +#: src/app/main/ui/delete_shared.cljs:55 +msgid "modals.unpublish-shared-confirm.accept" +msgid_plural "modals.unpublish-shared-confirm.accept" +msgstr[0] "게시 취소" + +#: src/app/main/ui/delete_shared.cljs:50 +msgid "modals.unpublish-shared-confirm.message" +msgid_plural "modals.unpublish-shared-confirm.message" +msgstr[0] "이 라이브러리의 게시를 취소하시겠습니까?" + +#: src/app/main/ui/delete_shared.cljs:45 +msgid "modals.unpublish-shared-confirm.title" +msgid_plural "modals.unpublish-shared-confirm.title" +msgstr[0] "라이브러리 게시 취소" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs, +#: src/app/main/ui/workspace/context_menu.cljs +msgid "modals.update-remote-component-in-bulk.hint" +msgstr "" +"공유 라이브러리의 컴포넌트를 업데이트하려고 합니다. 이를 사용하는 다른 파일들" +"에 영향을 줄 수 있습니다." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs, +#: src/app/main/ui/workspace/context_menu.cljs +msgid "modals.update-remote-component-in-bulk.message" +msgstr "공유 라이브러리의 컴포넌트 일괄 업데이트" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:417 +msgid "modals.update-remote-component.accept" +msgstr "업데이트" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:416 +msgid "modals.update-remote-component.cancel" +msgstr "취소" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:415 +msgid "modals.update-remote-component.hint" +msgstr "" +"공유 라이브러리의 컴포넌트를 업데이트하려고 합니다. 이를 사용하는 다른 파일들" +"에 영향을 줄 수 있습니다." + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:414 +msgid "modals.update-remote-component.message" +msgstr "공유 라이브러리의 컴포넌트 업데이트" + +#: src/app/main/ui/static.cljs:288 +msgid "not-found.desc-message.doesnt-exist" +msgstr "페이지가 존재하지 않습니다" + +#: src/app/main/ui/static.cljs:287 +msgid "not-found.desc-message.error" +msgstr "404 오류" + +#: src/app/main/ui/static.cljs:138 +msgid "not-found.login.free" +msgstr "" +"Penpot은 디자인과 코드 사이의 협업을 위한 무료 오픈소스 디자인 툴입니다" + +#: src/app/main/ui/auth/recovery_request.cljs:114 +msgid "not-found.login.sent-recovery" +msgstr "다음을 통해 복구 이메일을 보냈습니다:" + +#: src/app/main/ui/auth/recovery_request.cljs:116 +msgid "not-found.login.sent-recovery-check" +msgstr "이메일을 확인하고 링크를 클릭하여 새 비밀번호를 생성하세요." + +#: src/app/main/ui/static.cljs:152 +msgid "not-found.login.signup-free" +msgstr "무료 회원가입" + +#: src/app/main/ui/static.cljs:153 +msgid "not-found.login.start-using" +msgstr "몇 초 안에 Penpot 사용을 시작해보세요!" + +#: src/app/main/ui/static.cljs:69 +msgid "not-found.made-with-love" +msgstr "사랑과 오픈 소스의 힘으로 만들어졌습니다" + +#: src/app/main/ui/static.cljs:248 +msgid "not-found.no-permission.already-requested.file" +msgstr "이미 이 파일에 대한 접근 권한을 요청하셨습니다." + +#: src/app/main/ui/static.cljs:249 +msgid "not-found.no-permission.already-requested.or-others.file" +msgstr "" +"이미 이 파일 또는 이 팀의 다른 파일/프로젝트에 대한 접근 권한을 요청하셨습니" +"다." + +#: src/app/main/ui/static.cljs:255 +msgid "not-found.no-permission.already-requested.or-others.project" +msgstr "" +"이미 이 프로젝트 또는 이 팀의 다른 프로젝트/파일에 대한 접근 권한을 요청하셨" +"습니다." + +#: src/app/main/ui/static.cljs:254 +msgid "not-found.no-permission.already-requested.project" +msgstr "이미 이 프로젝트에 대한 접근 권한을 요청하셨습니다." + +#: src/app/main/ui/static.cljs:269, src/app/main/ui/static.cljs:278 +msgid "not-found.no-permission.ask" +msgstr "접근 권한 요청" + +#: src/app/main/ui/static.cljs:261 +msgid "not-found.no-permission.done.remember" +msgstr "소유자가 승인하면 팀에 초대될 것임을 잊지 마세요." + +#: src/app/main/ui/static.cljs:260 +msgid "not-found.no-permission.done.success" +msgstr "요청이 성공적으로 전송되었습니다!" + +#: src/app/main/ui/static.cljs:266 +msgid "not-found.no-permission.file" +msgstr "이 파일에 대한 접근 권한이 없습니다." + +#: src/app/main/ui/static.cljs:56, src/app/main/ui/static.cljs:244, +#: src/app/main/ui/static.cljs:250, src/app/main/ui/static.cljs:256, +#: src/app/main/ui/static.cljs:262, src/app/main/ui/static.cljs:271, +#: src/app/main/ui/static.cljs:280 +msgid "not-found.no-permission.go-dashboard" +msgstr "내 Penpot으로 이동" + +#: src/app/main/ui/static.cljs:268, src/app/main/ui/static.cljs:277 +msgid "not-found.no-permission.if-approves" +msgstr "소유자가 승인하면 팀에 초대됩니다." + +#: src/app/main/ui/static.cljs:496, src/app/main/ui/static.cljs:509 +msgid "not-found.no-permission.penpot-file" +msgstr "Penpot 파일" + +#: src/app/main/ui/static.cljs:243, src/app/main/ui/static.cljs:275 +msgid "not-found.no-permission.project" +msgstr "이 프로젝트에 대한 접근 권한이 없습니다." + +#: src/app/main/ui/static.cljs:495, src/app/main/ui/static.cljs:507 +msgid "not-found.no-permission.project-name" +msgstr "프로젝트" + +#: src/app/main/ui/static.cljs:267 +msgid "not-found.no-permission.you-can-ask.file" +msgstr "이 파일에 접근하려면 팀 소유자에게 문의하세요." + +#: src/app/main/ui/static.cljs:276 +msgid "not-found.no-permission.you-can-ask.project" +msgstr "이 프로젝트에 접근하려면 팀 소유자에게 문의하세요." + +#: src/app/main/data/common.cljs:89 +msgid "notifications.by-code.maintenance" +msgstr "" +"시스템 점검 안내: 5분 이내에 짧은 점검을 위해 서비스가 중단될 예정입니다." + +#: src/app/main/data/common.cljs:82 +msgid "notifications.by-code.upgrade-version" +msgstr "새 버전이 출시되었습니다. 페이지를 새로고침해주세요" + +#: src/app/main/ui/dashboard/team.cljs:825 +msgid "notifications.invitation-deleted" +msgstr "초대가 성공적으로 삭제되었습니다" + +#: src/app/main/ui/dashboard/team.cljs:170, +#: src/app/main/ui/dashboard/team.cljs:867 +msgid "notifications.invitation-email-sent" +msgstr "초대가 성공적으로 전송되었습니다" + +#: src/app/main/ui/dashboard/team.cljs:635 +msgid "notifications.invitation-link-copied" +msgstr "초대 링크가 복사되었습니다" + +#: src/app/main/ui/settings/delete_account.cljs:24 +msgid "notifications.profile-deletion-not-allowed" +msgstr "" +"프로필을 삭제할 수 없습니다. 진행하기 전에 팀 소유권을 먼저 위임해주세요." + +#: src/app/main/ui/settings/change_email.cljs:46 +msgid "notifications.validation-email-sent" +msgstr "인증 이메일을 %s로 보냈습니다. 이메일을 확인해주세요!" + +msgid "onboarding-v2.before-start.desc1" +msgstr "" +"사용자 가이드와 YouTube 채널 등 Penpot 시작을 돕는 다양한 리소스가 준비되어 " +"있습니다." + +msgid "onboarding-v2.before-start.desc2" +msgstr "" +"프로토타이핑부터 디자인 정리 및 공유까지, Penpot 사용법에 대한 자세한 정보입" +"니다." + +msgid "onboarding-v2.before-start.desc2.title" +msgstr "사용자 가이드" + +msgid "onboarding-v2.before-start.desc3" +msgstr "" +"공식 튜토리얼 및 커뮤니티에서 제작한 튜토리얼 영상을 시청하실 수 있습니다." + +msgid "onboarding-v2.before-start.desc3.title" +msgstr "비디오 튜토리얼" + +msgid "onboarding-v2.before-start.title" +msgstr "시작하기 전에" + +#: src/app/main/ui/onboarding/newsletter.cljs:68 +msgid "onboarding-v2.newsletter.desc" +msgstr "Penpot 뉴스레터를 구독하고 제품 개발 현황 및 소식을 받아보세요." + +#: src/app/main/ui/onboarding/newsletter.cljs:88 +msgid "onboarding-v2.newsletter.news" +msgstr "Penpot 관련 소식 받기 (블로그 포스트, 비디오 튜토리얼, 스트리밍 등)." + +#: src/app/main/ui/onboarding/newsletter.cljs:96 +msgid "onboarding-v2.newsletter.privacy1" +msgstr "우리는 개인정보를 소중히 여깁니다. 다음을 확인해주세요: " + +#: src/app/main/ui/onboarding/newsletter.cljs:102 +msgid "onboarding-v2.newsletter.privacy2" +msgstr "" +"꼭 필요한 정보만 보내드립니다. 뉴스레터 하단의 수신 거부 링크를 통해 언제든" +"지 구독을 취소하실 수 있습니다." + +#: src/app/main/ui/auth/register.cljs:35, +#: src/app/main/ui/onboarding/newsletter.cljs:76 +msgid "onboarding-v2.newsletter.updates" +msgstr ".제품 업데이트 소식 받기 (새 기능, 릴리즈, 수정 사항 등)." + +msgid "onboarding-v2.welcome.desc1" +msgstr "" +"Penpot은 오픈소스이며 Kaleidos와 커뮤니티가 함께 만들어갑니다. 이미 많은 분들" +"이 서로 돕고 있으며, 누구나 다음과 같이 참여할 수 있습니다:" + +msgid "onboarding-v2.welcome.desc2" +msgstr "" +"전체 커뮤니티 및 Penpot 핵심 팀과 함께 Penpot의 현재와 미래에 대해 배우고, 공" +"유하고, 토론하는 공개 공간입니다." + +msgid "onboarding-v2.welcome.desc2.title" +msgstr "커뮤니티 참여" + +msgid "onboarding-v2.welcome.desc3" +msgstr "" +"번역, 기능 제안, 코어 기여, 버그 찾기 등 협업 방법을 확인하실 수 있습니다." + +msgid "onboarding-v2.welcome.desc3.title" +msgstr "기여 가이드" + +#: src/app/main/ui/onboarding/team_choice.cljs:241 +msgid "onboarding-v2.welcome.title" +msgstr "Penpot에 오신 것을 환영합니다!" + +#: src/app/main/ui/onboarding/team_choice.cljs:254 +msgid "onboarding.choice.team-up.continue-creating-team" +msgstr "팀 생성 계속하기" + +#: src/app/main/ui/onboarding/team_choice.cljs:230 +msgid "onboarding.choice.team-up.continue-without-a-team" +msgstr "팀 없이 계속하기" + +#: src/app/main/ui/onboarding/team_choice.cljs:214 +msgid "onboarding.choice.team-up.create-team-and-invite" +msgstr "팀 생성 및 초대" + +msgid "onboarding.choice.team-up.create-team-and-send-invites" +msgstr "팀 생성 및 초대 전송" + +#: src/app/main/ui/onboarding/team_choice.cljs:219 +msgid "onboarding.choice.team-up.create-team-and-send-invites-description" +msgstr "나중에 초대할 수도 있습니다" + +#: src/app/main/ui/onboarding/team_choice.cljs:177 +msgid "onboarding.choice.team-up.create-team-desc" +msgstr "팀 이름을 정한 후, 구성원들을 초대할 수 있습니다." + +#: src/app/main/ui/onboarding/team_choice.cljs:185 +msgid "onboarding.choice.team-up.create-team-placeholder" +msgstr "팀 이름 입력" + +#: src/app/main/ui/onboarding/team_choice.cljs:215 +msgid "onboarding.choice.team-up.create-team-without-invite" +msgstr "팀 생성" + +msgid "onboarding.choice.team-up.create-team-without-inviting" +msgstr "초대 없이 팀 생성" + +#: src/app/main/ui/dashboard/projects.cljs:97, +#: src/app/main/ui/onboarding/team_choice.cljs:187 +msgid "onboarding.choice.team-up.invite-members" +msgstr "구성원 초대" + +#: src/app/main/ui/onboarding/team_choice.cljs:188 +msgid "onboarding.choice.team-up.invite-members-info" +msgstr "" +"모든 분들을 포함하는 것을 잊지 마세요. 개발자, 디자이너, 매니저... 다양성이 " +"힘이 됩니다 :)" + +#: src/app/main/ui/dashboard/team.cljs:234, +#: src/app/main/ui/onboarding/team_choice.cljs:194 +msgid "onboarding.choice.team-up.roles" +msgstr "다음 역할로 초대:" + +#: src/app/main/ui/onboarding/team_choice.cljs:223 +msgid "onboarding.choice.team-up.start-without-a-team" +msgstr "팀 없이 시작하기" + +#: src/app/main/ui/onboarding/team_choice.cljs:225 +msgid "onboarding.choice.team-up.start-without-a-team-description" +msgstr "나중에 언제든지 팀을 생성할 수 있습니다." + +msgid "onboarding.newsletter.accept" +msgstr "네, 구독합니다" + +#: src/app/main/ui/onboarding/newsletter.cljs:42 +msgid "onboarding.newsletter.acceptance-message" +msgstr "구독 요청이 전송되었습니다. 확인 메일을 보내드릴게요." + +#: src/app/main/ui/onboarding/newsletter.cljs:100 +msgid "onboarding.newsletter.policy" +msgstr "개인정보 처리방침." + +#: src/app/main/ui/onboarding/newsletter.cljs:65 +msgid "onboarding.newsletter.title" +msgstr "Penpot 소식을 받아보시겠어요?" + +#: src/app/main/ui/onboarding/questions.cljs:108 +msgid "onboarding.questions.lets-get-started" +msgstr "시작해볼까요!" + +#: src/app/main/ui/onboarding/questions.cljs:249 +msgid "onboarding.questions.reasons.alternative" +msgstr "Figma, XD 등의 대안을 찾고 있음" + +#: src/app/main/ui/onboarding/questions.cljs:243 +msgid "onboarding.questions.reasons.exploring" +msgstr "그냥 둘러보는 중" + +#: src/app/main/ui/onboarding/questions.cljs:246 +msgid "onboarding.questions.reasons.fit" +msgstr "Penpot이 우리 팀에 적합한지 검토 중" + +#: src/app/main/ui/onboarding/questions.cljs:252 +msgid "onboarding.questions.reasons.testing" +msgstr "셀프 호스팅 전 테스트 중" + +#: src/app/main/ui/onboarding/questions.cljs:407 +msgid "onboarding.questions.referer.article" +msgstr "기사 (블로그, 포스트, 뉴스레터)" + +#: src/app/main/ui/onboarding/questions.cljs:405 +msgid "onboarding.questions.referer.search" +msgstr "검색 엔진 (Google, Yahoo, Bing 등)" + +#: src/app/main/ui/onboarding/questions.cljs:406 +msgid "onboarding.questions.referer.social" +msgstr "소셜 미디어 (X, LinkedIn, FB 등)" + +#: src/app/main/ui/onboarding/questions.cljs:322 +msgid "onboarding.questions.start-with.code" +msgstr "디자인에서 실제 코드 추출" + +#: src/app/main/ui/onboarding/questions.cljs:320 +msgid "onboarding.questions.start-with.ds" +msgstr "디자인 시스템 구축" + +#: src/app/main/ui/onboarding/questions.cljs:318 +msgid "onboarding.questions.start-with.prototyping" +msgstr "프로토타이핑" + +#: src/app/main/ui/onboarding/questions.cljs:314 +msgid "onboarding.questions.start-with.ui" +msgstr "앱의 UI/UX 디자인" + +#: src/app/main/ui/onboarding/questions.cljs:316 +msgid "onboarding.questions.start-with.wireframing" +msgstr "와이어프레임 제작" + +#: src/app/main/ui/onboarding/questions.cljs:116 +msgid "onboarding.questions.step1.question1" +msgstr "어떤 용도로 Penpot을 사용하실 예정인가요?" + +#: src/app/main/ui/onboarding/questions.cljs:273 +msgid "onboarding.questions.step1.question2" +msgstr "오늘 Penpot을 방문하신 이유는 무엇인가요?" + +#: src/app/main/ui/onboarding/questions.cljs:112 +msgid "onboarding.questions.step1.subtitle" +msgstr "" +"Penpot이 사용자분께 더 잘 맞도록 도와드리기 위해 정보를 조금만 알려주세요. 답" +"변해주신 내용은 새 기능의 우선순위를 정하고 시작 가이드를 제공하는 데 도움이 " +"됩니다." + +#: src/app/main/ui/onboarding/questions.cljs:110 +msgid "onboarding.questions.step1.title" +msgstr "사용자분에 대해 알려주세요" + +#: src/app/main/ui/onboarding/questions.cljs:190 +msgid "onboarding.questions.step2.title" +msgstr "가장 많이 사용하는 디자인 도구는 무엇인가요?" + +#: src/app/main/ui/onboarding/questions.cljs:122 +msgid "onboarding.questions.step3.question1" +msgstr "주로 어떤 작업을 하시나요?" + +#: src/app/main/ui/onboarding/questions.cljs:303 +msgid "onboarding.questions.step3.question2" +msgstr "사용자분의 역할은 무엇인가요?" + +#: src/app/main/ui/onboarding/questions.cljs:290 +msgid "onboarding.questions.step3.question3" +msgstr "회사의 규모는 어느 정도인가요?" + +#: src/app/main/ui/onboarding/questions.cljs:270 +msgid "onboarding.questions.step3.title" +msgstr "사용자분의 업무에 대해 알려주세요" + +#: src/app/main/ui/onboarding/questions.cljs:345 +msgid "onboarding.questions.step4.title" +msgstr "어디서부터 시작하고 싶으신가요?" + +#: src/app/main/ui/onboarding/questions.cljs:428 +msgid "onboarding.questions.step5.title" +msgstr "어떻게 Penpot을 알게 되셨나요?" + +#: src/app/main/ui/onboarding/questions.cljs:233 +msgid "onboarding.questions.team-size.11-30" +msgstr "11-30명" + +#: src/app/main/ui/onboarding/questions.cljs:234 +msgid "onboarding.questions.team-size.2-10" +msgstr "2-10명" + +#: src/app/main/ui/onboarding/questions.cljs:232 +msgid "onboarding.questions.team-size.31-50" +msgstr "31-50명" + +#: src/app/main/ui/onboarding/questions.cljs:235 +msgid "onboarding.questions.team-size.freelancer" +msgstr "프리랜서입니다" + +#: src/app/main/ui/onboarding/questions.cljs:231 +msgid "onboarding.questions.team-size.more-than-50" +msgstr "50명 이상" + +#: src/app/main/ui/onboarding/questions.cljs:236 +msgid "onboarding.questions.team-size.personal-project" +msgstr "개인 프로젝트 진행 중입니다" + +#: src/app/main/ui/onboarding/questions.cljs:79 +msgid "onboarding.questions.use.education" +msgstr "교육용" + +#: src/app/main/ui/onboarding/questions.cljs:80 +msgid "onboarding.questions.use.personal" +msgstr "개인용" + +#: src/app/main/ui/onboarding/questions.cljs:78 +msgid "onboarding.questions.use.work" +msgstr "업무용" + +#: src/app/main/ui/onboarding/team_choice.cljs:175 +msgid "onboarding.team-modal.create-team" +msgstr "팀 생성" + +#: src/app/main/ui/onboarding/team_choice.cljs:31 +msgid "onboarding.team-modal.create-team-desc" +msgstr "" +"팀을 구성하면 동일한 파일과 프로젝트에서 다른 Penpot 사용자와 협업할 수 있습" +"니다." + +#: src/app/main/ui/onboarding/team_choice.cljs:36 +msgid "onboarding.team-modal.create-team-feature-1" +msgstr "무제한 파일 및 프로젝트" + +#: src/app/main/ui/onboarding/team_choice.cljs:40 +msgid "onboarding.team-modal.create-team-feature-2" +msgstr "실시간 동시 편집" + +#: src/app/main/ui/onboarding/team_choice.cljs:44 +msgid "onboarding.team-modal.create-team-feature-3" +msgstr "역할 관리" + +#: src/app/main/ui/onboarding/team_choice.cljs:48 +msgid "onboarding.team-modal.create-team-feature-4" +msgstr "무제한 구성원" + +#: src/app/main/ui/onboarding/team_choice.cljs:52 +msgid "onboarding.team-modal.create-team-feature-5" +msgstr "100% 무료!" + +#: src/app/main/ui/onboarding/team_choice.cljs:29 +msgid "onboarding.team-modal.team-definition" +msgstr "팀이란?" + +#: src/app/main/ui/onboarding/templates.cljs:77 +msgid "onboarding.templates.subtitle" +msgstr "제공되는 템플릿들입니다." + +#: src/app/main/ui/onboarding/templates.cljs:76 +msgid "onboarding.templates.title" +msgstr "디자인 시작하기" + +msgid "onboarding.welcome.alt" +msgstr "Penpot" + +#: src/app/main/ui/auth/recovery.cljs:88 +msgid "profile.recovery.go-to-login" +msgstr "로그인으로 이동" + +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:373 +msgid "settings.detach" +msgstr "해제" + +#: src/app/main/ui/inspect/exports.cljs:148, +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:196, +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:213, +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:215, +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:240, +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:260, +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:278, +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:295, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:342, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:496, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1062, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1302, +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:138, +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:149, +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:223, +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:221, +#: src/app/main/ui/workspace/sidebar/options/menus/input_wrapper_tokens.cljs:28, +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:233, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:385, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:396, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:422, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:432, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:520, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:554, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:587, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:621, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:763, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:801, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:80, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:86, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:424, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:447, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:458, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:486, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:499, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:508, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:519, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:540, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:552, +#: src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs:155, +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:200, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:336, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:391, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:410, +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:422, +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:235 +msgid "settings.multiple" +msgstr "혼합됨" + +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:431 +msgid "settings.remove-color" +msgstr "색상 제거" + +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:436 +msgid "settings.select-this-color" +msgstr "이 스타일을 사용하는 항목 선택" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:409 +msgid "shortcut-section.basics" +msgstr "기본" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:415 +msgid "shortcut-section.dashboard" +msgstr "대시보드" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:418 +msgid "shortcut-section.viewer" +msgstr "뷰어" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:412 +msgid "shortcut-section.workspace" +msgstr "워크스페이스" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:58 +msgid "shortcut-subsection.alignment" +msgstr "정렬" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:59 +msgid "shortcut-subsection.edit" +msgstr "편집" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:60 +msgid "shortcut-subsection.general-dashboard" +msgstr "일반" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:61 +msgid "shortcut-subsection.general-viewer" +msgstr "일반" + +#: src/app/main/ui/workspace/main_menu.cljs:857, +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:62 +msgid "shortcut-subsection.main-menu" +msgstr "메인 메뉴" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:63 +msgid "shortcut-subsection.modify-layers" +msgstr "레이어 수정" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:64 +msgid "shortcut-subsection.navigation-dashboard" +msgstr "내비게이션" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:65 +msgid "shortcut-subsection.navigation-viewer" +msgstr "내비게이션" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:66 +msgid "shortcut-subsection.navigation-workspace" +msgstr "내비게이션" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:67 +msgid "shortcut-subsection.panels" +msgstr "패널" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:68 +msgid "shortcut-subsection.path-editor" +msgstr "경로" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:69 +msgid "shortcut-subsection.shape" +msgstr "도형" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:70 +msgid "shortcut-subsection.text-editor" +msgstr "텍스트" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:71 +msgid "shortcut-subsection.tools" +msgstr "도구" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:72 +msgid "shortcut-subsection.zoom-viewer" +msgstr "확대/축소" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:73 +msgid "shortcut-subsection.zoom-workspace" +msgstr "확대/축소" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:74 +msgid "shortcuts.add-comment" +msgstr "댓글" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:75 +msgid "shortcuts.add-node" +msgstr "노드 추가" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:76 +msgid "shortcuts.align-bottom" +msgstr "아래 정렬" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:77 +msgid "shortcuts.align-center" +msgstr "가운데 정렬" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:78 +msgid "shortcuts.align-hcenter" +msgstr "가로 가운데 정렬" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:79 +msgid "shortcuts.align-justify" +msgstr "양끝 정렬" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:80 +msgid "shortcuts.align-left" +msgstr "왼쪽 정렬" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:81 +msgid "shortcuts.align-right" +msgstr "오른쪽 정렬" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:82 +msgid "shortcuts.align-top" +msgstr "위쪽 정렬" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:83 +msgid "shortcuts.align-vcenter" +msgstr "세로 가운데 정렬" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:84 +msgid "shortcuts.artboard-selection" +msgstr "선택 영역을 보드로 만들기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:85 +msgid "shortcuts.bold" +msgstr "굵게 표시/해제" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:86 +msgid "shortcuts.bool-difference" +msgstr "빼기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:87 +msgid "shortcuts.bool-exclude" +msgstr "제외" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:88 +msgid "shortcuts.bool-intersection" +msgstr "교차" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:89 +msgid "shortcuts.bool-union" +msgstr "합치기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:90 +msgid "shortcuts.bring-back" +msgstr "맨 뒤로 보내기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:91 +msgid "shortcuts.bring-backward" +msgstr "뒤로 보내기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:92 +msgid "shortcuts.bring-forward" +msgstr "앞으로 가져오기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:93 +msgid "shortcuts.bring-front" +msgstr "맨 앞으로 가져오기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:94 +msgid "shortcuts.clear-undo" +msgstr "실행 취소 기록 삭제" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:95 +msgid "shortcuts.copy" +msgstr "복사" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:96 +msgid "shortcuts.copy-link" +msgstr "링크 복사" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:106 +msgid "shortcuts.copy-props" +msgstr "속성 복사" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:97 +msgid "shortcuts.create-component-variant" +msgstr "컴포넌트 / 베리언트 생성" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:98 +msgid "shortcuts.create-new-project" +msgstr "새로 생성" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:99 +msgid "shortcuts.cut" +msgstr "잘라내기" + +#: src/app/main/ui/viewer/header.cljs:82, +#: src/app/main/ui/workspace/right_header.cljs:82, +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:100 +msgid "shortcuts.decrease-zoom" +msgstr "축소" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:101 +msgid "shortcuts.delete" +msgstr "삭제" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:102 +msgid "shortcuts.delete-node" +msgstr "노드 삭제" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:103 +msgid "shortcuts.detach-component" +msgstr "컴포넌트 해제" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:104 +msgid "shortcuts.draw-curve" +msgstr "곡선" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:105 +msgid "shortcuts.draw-ellipse" +msgstr "타원" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:106 +msgid "shortcuts.draw-frame" +msgstr "보드" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:107 +msgid "shortcuts.draw-nodes" +msgstr "경로 그리기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:108 +msgid "shortcuts.draw-path" +msgstr "경로" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:109 +msgid "shortcuts.draw-rect" +msgstr "사각형" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:110 +msgid "shortcuts.draw-text" +msgstr "텍스트" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:111 +msgid "shortcuts.duplicate" +msgstr "복제" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:112 +msgid "shortcuts.escape" +msgstr "취소" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:113 +msgid "shortcuts.export-shapes" +msgstr "도형 내보내기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:114 +msgid "shortcuts.fit-all" +msgstr "전체 맞춤" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:117 +msgid "shortcuts.font-size-dec" +msgstr "글꼴 크기 감소" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:118 +msgid "shortcuts.font-size-inc" +msgstr "글꼴 크기 증가" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:124 +msgid "shortcuts.hide-ui" +msgstr "UI 표시/숨기기" + +#: src/app/main/ui/viewer/header.cljs:88, +#: src/app/main/ui/workspace/right_header.cljs:87, +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:125 +msgid "shortcuts.increase-zoom" +msgstr "확대" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:126 +msgid "shortcuts.insert-image" +msgstr "이미지 삽입" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:127 +msgid "shortcuts.italic" +msgstr "기울임꼴 표시/해제" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:128 +msgid "shortcuts.join-nodes" +msgstr "노드 연결" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:129 +msgid "shortcuts.line-through" +msgstr "취소선 표시/해제" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:130 +msgid "shortcuts.make-corner" +msgstr "직각 노드로 변경" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:131 +msgid "shortcuts.make-curve" +msgstr "곡선 노드로 변경" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:132 +msgid "shortcuts.mask" +msgstr "마스크" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:133 +msgid "shortcuts.merge-nodes" +msgstr "노드 병합" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:134 +msgid "shortcuts.move" +msgstr "이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:135 +msgid "shortcuts.move-fast-down" +msgstr "아래로 크게 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:136 +msgid "shortcuts.move-fast-left" +msgstr "왼쪽으로 크게 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:137 +msgid "shortcuts.move-fast-right" +msgstr "오른쪽으로 크게 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:138 +msgid "shortcuts.move-fast-up" +msgstr "위로 크게 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:139 +msgid "shortcuts.move-nodes" +msgstr "노드 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:140 +msgid "shortcuts.move-unit-down" +msgstr "아래로 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:141 +msgid "shortcuts.move-unit-left" +msgstr "왼쪽으로 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:142 +msgid "shortcuts.move-unit-right" +msgstr "오른쪽으로 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:143 +msgid "shortcuts.move-unit-up" +msgstr "위로 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:144 +msgid "shortcuts.next-frame" +msgstr "다음 보드" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:509 +msgid "shortcuts.not-found" +msgstr "단축키를 찾을 수 없습니다" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:145 +msgid "shortcuts.opacity-0" +msgstr "불투명도 100% 설정" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:146 +msgid "shortcuts.opacity-1" +msgstr "불투명도 10% 설정" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:147 +msgid "shortcuts.opacity-2" +msgstr "불투명도 20% 설정" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:148 +msgid "shortcuts.opacity-3" +msgstr "불투명도 30% 설정" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:149 +msgid "shortcuts.opacity-4" +msgstr "불투명도 40% 설정" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:150 +msgid "shortcuts.opacity-5" +msgstr "불투명도 50% 설정" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:151 +msgid "shortcuts.opacity-6" +msgstr "불투명도 60% 설정" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:152 +msgid "shortcuts.opacity-7" +msgstr "불투명도 70% 설정" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:153 +msgid "shortcuts.opacity-8" +msgstr "불투명도 80% 설정" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:154 +msgid "shortcuts.opacity-9" +msgstr "불투명도 90% 설정" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:155 +msgid "shortcuts.open-color-picker" +msgstr "색상 선택기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:156 +msgid "shortcuts.open-comments" +msgstr "뷰어 댓글 섹션으로 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:157 +msgid "shortcuts.open-dashboard" +msgstr "대시보드로 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:158 +msgid "shortcuts.open-inspect" +msgstr "뷰어 검사 섹션으로 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:159 +msgid "shortcuts.open-interactions" +msgstr "뷰어 인터랙션 섹션으로 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:160 +msgid "shortcuts.open-viewer" +msgstr "뷰어 인터랙션 섹션으로 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:161 +msgid "shortcuts.open-workspace" +msgstr "워크스페이스로 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:255 +msgid "shortcuts.or" +msgstr " 또는 " + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:162 +msgid "shortcuts.paste" +msgstr "붙여넣기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:111 +msgid "shortcuts.paste-props" +msgstr "속성 붙여넣기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:604 +msgid "shortcuts.plugins" +msgstr "플러그인 관리자" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:163 +msgid "shortcuts.prev-frame" +msgstr "이전 보드" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:164 +msgid "shortcuts.redo" +msgstr "다시 실행" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:165 +msgid "shortcuts.rename" +msgstr "이름 바꾸기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:166 +msgid "shortcuts.reset-zoom" +msgstr "확대/축소 초기화" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:167 +msgid "shortcuts.scale" +msgstr "스케일" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:168 +msgid "shortcuts.search-placeholder" +msgstr "단축키 검색" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:169 +msgid "shortcuts.select-all" +msgstr "전체 선택" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:170 +msgid "shortcuts.select-next" +msgstr "다음 레이어 선택" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:171 +msgid "shortcuts.select-parent-layer" +msgstr "상위 레이어 선택" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:172 +msgid "shortcuts.select-prev" +msgstr "이전 레이어 선택" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:173 +msgid "shortcuts.separate-nodes" +msgstr "노드 분리" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:174 +msgid "shortcuts.show-pixel-grid" +msgstr "픽셀 그리드 표시/숨기기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:175 +msgid "shortcuts.show-shortcuts" +msgstr "단축키 표시/숨기기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:176 +msgid "shortcuts.snap-nodes" +msgstr "노드에 스냅" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:177 +msgid "shortcuts.snap-pixel-grid" +msgstr "픽셀 그리드에 스냅" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:178 +msgid "shortcuts.start-editing" +msgstr "편집 시작" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:179 +msgid "shortcuts.start-measure" +msgstr "측정 시작" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:180 +msgid "shortcuts.stop-measure" +msgstr "측정 중지" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:181 +msgid "shortcuts.thumbnail-set" +msgstr "썸네일 설정" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:491, +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:498 +msgid "shortcuts.title" +msgstr "키보드 단축키" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:182 +msgid "shortcuts.toggle-alignment" +msgstr "동적 정렬 전환" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:183 +msgid "shortcuts.toggle-assets" +msgstr "에셋 전환" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:184 +msgid "shortcuts.toggle-colorpalette" +msgstr "색상 선택자 켜기/끄기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:185 +msgid "shortcuts.toggle-focus-mode" +msgstr "포커스 모드 켜기/끄기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:186 +msgid "shortcuts.toggle-fullscreen" +msgstr "전체 화면 켜기/끄기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:187 +msgid "shortcuts.toggle-guides" +msgstr "가이드 표시 / 숨기기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:188 +msgid "shortcuts.toggle-history" +msgstr "히스토리 패널 전환" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:189 +msgid "shortcuts.toggle-layers" +msgstr "레이어 패널 전환" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:190 +msgid "shortcuts.toggle-layout-flex" +msgstr "플렉스 레이아웃 추가 / 제거" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:191 +msgid "shortcuts.toggle-layout-grid" +msgstr "그리드 레이아웃 추가 / 제거" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:192 +msgid "shortcuts.toggle-lock" +msgstr "잠금 / 잠금 해제" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:193 +msgid "shortcuts.toggle-lock-size" +msgstr "비율 잠금" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:194 +msgid "shortcuts.toggle-rulers" +msgstr "눈금자 표시 / 숨기기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:195 +msgid "shortcuts.toggle-snap-guides" +msgstr "가이드에 스냅" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:196 +msgid "shortcuts.toggle-snap-ruler-guide" +msgstr "눈금자 가이드에 스냅" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:197 +msgid "shortcuts.toggle-textpalette" +msgstr "텍스트 팔레트 전환" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:198 +msgid "shortcuts.toggle-theme" +msgstr "테마 변경" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:199 +msgid "shortcuts.toggle-visibility" +msgstr "표시 / 숨기기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:200 +msgid "shortcuts.toggle-zoom-style" +msgstr "확대/축소 스타일 전환" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:201 +msgid "shortcuts.underline" +msgstr "밑줄 전환" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:202 +msgid "shortcuts.undo" +msgstr "실행 취소" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:203 +msgid "shortcuts.ungroup" +msgstr "그룹 해제" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:204 +msgid "shortcuts.unmask" +msgstr "마스크 해제" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:205 +msgid "shortcuts.v-distribute" +msgstr "세로로 균등 배분" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:206 +msgid "shortcuts.zoom-lense-decrease" +msgstr "줌 렌즈 축소" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:207 +msgid "shortcuts.zoom-lense-increase" +msgstr "줌 렌즈 확대" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:208 +msgid "shortcuts.zoom-selected" +msgstr "선택 항목에 맞추기" + +#: src/app/main/ui/dashboard/subscription.cljs:114, +#: src/app/main/ui/dashboard/subscription.cljs:162 +msgid "subscription.dashboard.power-up.enterprise-plan" +msgstr "엔터프라이즈 플랜" + +#: src/app/main/ui/dashboard/subscription.cljs:109 +msgid "subscription.dashboard.power-up.enterprise-trial.top-title" +msgstr "엔터프라이즈 플랜 (체험판)" + +#: src/app/main/ui/dashboard/subscription.cljs:84 +msgid "subscription.dashboard.power-up.professional.bottom-button" +msgstr "업그레이드!" + +#: src/app/main/ui/dashboard/subscription.cljs:83 +msgid "subscription.dashboard.power-up.professional.bottom-description" +msgstr "팀을 위한 추가 저장 공간, 파일 복구 등의 기능을 이용해 보세요." + +#: src/app/main/ui/dashboard/subscription.cljs:82 +msgid "subscription.dashboard.power-up.professional.top-title" +msgstr "프로페셔널 플랜" + +#: src/app/main/ui/dashboard/subscription.cljs:64, +#: src/app/main/ui/settings/subscription.cljs:107, +#: src/app/main/ui/settings/subscription.cljs:131 +msgid "subscription.dashboard.power-up.subscribe" +msgstr "구독하기" + +#: src/app/main/ui/dashboard/subscription.cljs:94 +msgid "subscription.dashboard.power-up.trial.bottom-description" +msgstr "" +"체험판은 만족스러우신가요? 모든 기능을 무제한으로 사용해 보세요. [구독하기|" +"target:self](%s)" + +#: src/app/main/ui/dashboard/subscription.cljs:93 +msgid "subscription.dashboard.power-up.trial.top-title" +msgstr "무제한 플랜 (체험판)" + +#: src/app/main/ui/dashboard/subscription.cljs:100, +#: src/app/main/ui/dashboard/subscription.cljs:161 +msgid "subscription.dashboard.power-up.unlimited-plan" +msgstr "무제한 플랜" + +#: src/app/main/ui/dashboard/subscription.cljs:101 +msgid "subscription.dashboard.power-up.unlimited.bottom-text" +msgstr "" +"모든 팀에 대해 고정된 가격으로 무제한 저장 공간, 확장된 파일 복구 기능 및 무" +"제한 편집자 권한을 누리세요. [엔터프라이즈 플랜 살펴보기.|target:self](%s)" + +#: src/app/main/ui/dashboard/subscription.cljs:70 +msgid "subscription.dashboard.power-up.unlimited.cta" +msgstr "자세히 보기" + +#: src/app/main/ui/dashboard/subscription.cljs:68 +msgid "subscription.dashboard.power-up.unlimited.top-description" +msgstr "추가 편집자 시트, 저장 공간, 자동 저장 버전 및 파일 백업 등." + +#: src/app/main/ui/dashboard/subscription.cljs:81, +#: src/app/main/ui/dashboard/subscription.cljs:92, +#: src/app/main/ui/dashboard/subscription.cljs:99, +#: src/app/main/ui/dashboard/subscription.cljs:108, +#: src/app/main/ui/dashboard/subscription.cljs:113 +msgid "subscription.dashboard.power-up.your-subscription" +msgstr "현재 구독:" + +#: src/app/main/ui/dashboard/subscription.cljs:196 +msgid "subscription.dashboard.professional-dashboard-cta-title" +msgstr "" +"소유하신 팀 전체에 %s명의 편집자가 있으며, 프로페셔널 플랜은 최대 8명까지 지" +"원합니다." + +#: src/app/main/ui/dashboard/subscription.cljs:204 +msgid "subscription.dashboard.professional-dashboard-cta-upgrade-owner" +msgstr "" +"더 많은 편집자 시트와 저장 공간, 파일 복구 기능을 위해 무제한 또는 엔터프라이" +"즈 플랜으로 업그레이드해 주세요. [지금 구독하기.|target:self](%s)" + +#: src/app/main/ui/dashboard/subscription.cljs:137 +msgid "subscription.dashboard.team-plan" +msgstr "팀 플랜" + +#: src/app/main/ui/dashboard/subscription.cljs:199 +msgid "subscription.dashboard.unlimited-dashboard-cta-title" +msgstr "" +"팀이 계속 성장하고 있네요! 무제한 플랜은 최대 %s명의 편집자를 지원하지만, 현" +"재 %s명이 소속되어 있습니다." + +#: src/app/main/ui/dashboard/subscription.cljs:207 +msgid "subscription.dashboard.unlimited-dashboard-cta-upgrade-owner" +msgstr "" +"현재 편집자 수에 맞춰 플랜을 업그레이드해 주세요. [지금 구독하기.|" +"target:self](%s)" + +#: src/app/main/ui/dashboard/subscription.cljs:184 +msgid "subscription.dashboard.unlimited-members-extra-editors-cta-text" +msgstr "" +"소유한 팀 전체에 추가된 새로운 편집자만 향후 요금에 합산됩니다. 25명 이상의 " +"편집자에 대해서는 월 $175의 고정 요금이 적용됩니다." + +#: src/app/main/ui/dashboard/sidebar.cljs:1073 +msgid "subscription.dashboard.upgrade-plan.power-up" +msgstr "업그레이드" + +#: src/app/main/ui/settings/sidebar.cljs:116, +#: src/app/main/ui/settings/subscription.cljs:425, +#: src/app/main/ui/settings/subscription.cljs:462 +msgid "subscription.labels" +msgstr "구독" + +#: src/app/main/ui/settings/subscription.cljs:484, +#: src/app/main/ui/settings/subscription.cljs:508 +msgid "subscription.settings.add-payment-to-continue" +msgstr "체험판 종료 후에도 계속 이용하시려면 결제 수단을 추가해 주세요" + +#: src/app/main/ui/settings/subscription.cljs:478, +#: src/app/main/ui/settings/subscription.cljs:554 +msgid "subscription.settings.benefits.all-professional-benefits" +msgstr "프로페셔널 플랜의 모든 혜택 및:" + +#: src/app/main/ui/settings/subscription.cljs:490, +#: src/app/main/ui/settings/subscription.cljs:502, +#: src/app/main/ui/settings/subscription.cljs:512, +#: src/app/main/ui/settings/subscription.cljs:570 +msgid "subscription.settings.benefits.all-unlimited-benefits" +msgstr "무제한 플랜의 모든 혜택 및:" + +#: src/app/main/ui/settings/subscription.cljs:53 +msgid "subscription.settings.editors" +msgstr "(x %s명 편집자)" + +#: src/app/main/ui/dashboard/subscription.cljs:145, +#: src/app/main/ui/settings/subscription.cljs:104, +#: src/app/main/ui/settings/subscription.cljs:457, +#: src/app/main/ui/settings/subscription.cljs:510, +#: src/app/main/ui/settings/subscription.cljs:566 +msgid "subscription.settings.enterprise" +msgstr "엔터프라이즈" + +#: src/app/main/ui/settings/subscription.cljs:100, +#: src/app/main/ui/settings/subscription.cljs:456, +#: src/app/main/ui/settings/subscription.cljs:500 +msgid "subscription.settings.enterprise-trial" +msgstr "엔터프라이즈 (체험판)" + +#: src/app/main/ui/settings/subscription.cljs:504, +#: src/app/main/ui/settings/subscription.cljs:514, +#: src/app/main/ui/settings/subscription.cljs:572 +msgid "subscription.settings.enterprise.autosave" +msgstr "90일 자동 저장 버전 및 파일 복구" + +#: src/app/main/ui/settings/subscription.cljs:505, +#: src/app/main/ui/settings/subscription.cljs:515, +#: src/app/main/ui/settings/subscription.cljs:573 +msgid "subscription.settings.enterprise.capped-bill" +msgstr "월 정액제" + +#: src/app/main/ui/settings/subscription.cljs:503, +#: src/app/main/ui/settings/subscription.cljs:513, +#: src/app/main/ui/settings/subscription.cljs:571 +msgid "subscription.settings.enterprise.unlimited-storage-benefit" +msgstr "무제한 저장 공간" + +#: src/app/main/ui/dashboard/subscription.cljs:150, +#: src/app/main/ui/settings/subscription.cljs:482, +#: src/app/main/ui/settings/subscription.cljs:494, +#: src/app/main/ui/settings/subscription.cljs:506, +#: src/app/main/ui/settings/subscription.cljs:516 +msgid "subscription.settings.manage-your-subscription" +msgstr "나의 구독 관리" + +#: src/app/main/ui/settings/subscription.cljs:298 +msgid "subscription.settings.management-dialog.step-2-add-payment-button" +msgstr "결제 수단 추가" + +#: src/app/main/ui/settings/subscription.cljs:285 +msgid "subscription.settings.management-dialog.step-2-description" +msgstr "" +"결제 정보를 지금 추가하면 체험판 종료 후에도 구독이 끊김 없이 유지되며 당사" +"의 오픈소스 프로젝트를 후원하실 수 있습니다. 지금은 요금이 청구되지 않습니다." + +#: src/app/main/ui/settings/subscription.cljs:293 +msgid "subscription.settings.management-dialog.step-2-skip-button" +msgstr "지금은 건너뛰고 체험판 시작" + +#: src/app/main/ui/settings/subscription.cljs:203 +msgid "subscription.settings.management-dialog.step-2-title" +msgstr "체험판을 더 원활하게 시작할 수 있도록 도와주세요" + +#: src/app/main/ui/settings/subscription.cljs:209 +msgid "subscription.settings.management.dialog.currently-editors-title" +msgid_plural "subscription.settings.management.dialog.currently-editors-title" +msgstr[0] "현재 팀 전체에서 편집할 수 있는 사람이 %s명 있습니다." + +#: src/app/main/ui/settings/subscription.cljs:230 +msgid "subscription.settings.management.dialog.downgrade" +msgstr "" +"알림: 하위 플랜으로 변경하면 저장 공간이 줄어들고 백업 및 버전 히스토리 보존 " +"기간이 단축됩니다." + +#: src/app/main/ui/settings/subscription.cljs:211 +msgid "subscription.settings.management.dialog.editors" +msgstr "편집자" + +#: src/app/main/ui/settings/subscription.cljs:218 +msgid "subscription.settings.management.dialog.editors-explanation" +msgstr "(소유자, 관리자, 편집자 포함. 뷰어는 편집자에 포함되지 않음)" + +#: src/app/main/ui/settings/subscription.cljs:263 +msgid "subscription.settings.management.dialog.input-error" +msgstr "" +"현재 인원보다 적은 편집자 수를 설정할 수 없습니다. 실제 편집을 하지 않는 사용" +"자는 팀 설정에서 역할(편집자/관리자에서 뷰어로)을 변경해 주세요." + +#: src/app/main/ui/settings/subscription.cljs:259 +msgid "subscription.settings.management.dialog.payment-explanation" +msgstr "체험판 종료 후 청구됩니다. 지금은 신용카드가 필요하지 않습니다." + +#: src/app/main/ui/settings/subscription.cljs:252, +#: src/app/main/ui/settings/subscription.cljs:256 +msgid "subscription.settings.management.dialog.price-month" +msgstr "**$%s**/월" + +#: src/app/main/ui/settings/subscription.cljs:204 +msgid "subscription.settings.management.dialog.title" +msgstr "내 팀에 %s 적용" + +#: src/app/main/ui/settings/subscription.cljs:266 +msgid "subscription.settings.management.dialog.unlimited-capped-warning" +msgstr "" +"팁: 향후 초대를 고려하여 지금 시트 수를 늘려두실 수 있습니다. 팀 전체 편집자" +"가 25명 이상이면 월 $175의 고정 요금이 적용됩니다." + +#: src/app/main/ui/settings/subscription.cljs:533 +msgid "subscription.settings.member-since" +msgstr "Penpot 가입일: %s" + +#: src/app/main/ui/settings/subscription.cljs:546, +#: src/app/main/ui/settings/subscription.cljs:560, +#: src/app/main/ui/settings/subscription.cljs:576 +msgid "subscription.settings.more-information" +msgstr "자세한 정보" + +#: src/app/main/ui/settings/subscription.cljs:536 +msgid "subscription.settings.other-plans" +msgstr "다른 Penpot 플랜" + +#: src/app/main/ui/settings/subscription.cljs:540, +#: src/app/main/ui/settings/subscription.cljs:553 +msgid "subscription.settings.price-editor-month" +msgstr "편집자당 월 비용" + +#: src/app/main/ui/settings/subscription.cljs:569 +msgid "subscription.settings.price-organization-month" +msgstr "조직/월" + +#: src/app/main/ui/dashboard/subscription.cljs:140, +#: src/app/main/ui/settings/subscription.cljs:102, +#: src/app/main/ui/settings/subscription.cljs:469, +#: src/app/main/ui/settings/subscription.cljs:538 +msgid "subscription.settings.professional" +msgstr "프로페셔널" + +#: src/app/main/ui/settings/subscription.cljs:471, +#: src/app/main/ui/settings/subscription.cljs:542 +msgid "subscription.settings.professional.autosave-benefit" +msgstr "7일 자동 저장 버전 및 파일 복구" + +#: src/app/main/ui/settings/subscription.cljs:470, +#: src/app/main/ui/settings/subscription.cljs:541 +msgid "subscription.settings.professional.storage-benefit" +msgstr "10GB 저장 공간" + +#: src/app/main/ui/settings/subscription.cljs:472, +#: src/app/main/ui/settings/subscription.cljs:543 +msgid "subscription.settings.professional.teams-editors-benefit" +msgstr "무제한 팀 생성. 소유한 팀 전체 합산 최대 8명의 편집자." + +#: src/app/main/ui/settings/subscription.cljs:50 +msgid "subscription.settings.recommended" +msgstr "추천" + +#: src/app/main/ui/settings/subscription.cljs:466 +msgid "subscription.settings.section-plan" +msgstr "내 구독 정보" + +#: src/app/main/ui/settings/subscription.cljs:313 +msgid "subscription.settings.start-trial" +msgstr "무료 체험판 시작" + +#: src/app/main/ui/settings/subscription.cljs:278, +#: src/app/main/ui/settings/subscription.cljs:544, +#: src/app/main/ui/settings/subscription.cljs:558, +#: src/app/main/ui/settings/subscription.cljs:574 +msgid "subscription.settings.subscribe" +msgstr "구독하기" + +#: src/app/main/ui/settings/subscription.cljs:345 +msgid "subscription.settings.success.dialog.description" +msgstr "" +"계정 상세 정보의 '구독' 페이지에서 언제든지 구독 내용을 수정하실 수 있습니다." + +#: src/app/main/ui/settings/subscription.cljs:343 +msgid "subscription.settings.success.dialog.thanks" +msgstr "Penpot %s 플랜을 선택해주셔서 감사합니다!" + +#: src/app/main/ui/settings/subscription.cljs:347 +msgid "subscription.settings.sucess.dialog.footer" +msgstr "플랜을 즐기세요!" + +#: src/app/main/ui/settings/subscription.cljs:340 +msgid "subscription.settings.sucess.dialog.title" +msgstr "%s이(가) 되셨습니다!" + +#: src/app/main/ui/settings/subscription.cljs:526 +msgid "subscription.settings.support-us-since" +msgstr "이 플랜으로 저희를 지원해주신 날짜: %s" + +#: src/app/main/ui/settings/subscription.cljs:558, +#: src/app/main/ui/settings/subscription.cljs:574 +msgid "subscription.settings.try-it-free" +msgstr "14일 무료 체험" + +#: src/app/main/ui/dashboard/subscription.cljs:143, +#: src/app/main/ui/settings/subscription.cljs:103, +#: src/app/main/ui/settings/subscription.cljs:454, +#: src/app/main/ui/settings/subscription.cljs:488, +#: src/app/main/ui/settings/subscription.cljs:550 +msgid "subscription.settings.unlimited" +msgstr "무제한" + +#: src/app/main/ui/dashboard/subscription.cljs:142, +#: src/app/main/ui/settings/subscription.cljs:99, +#: src/app/main/ui/settings/subscription.cljs:453, +#: src/app/main/ui/settings/subscription.cljs:476 +msgid "subscription.settings.unlimited-trial" +msgstr "무제한 (체험판)" + +#: src/app/main/ui/settings/subscription.cljs:480, +#: src/app/main/ui/settings/subscription.cljs:492, +#: src/app/main/ui/settings/subscription.cljs:556 +msgid "subscription.settings.unlimited.autosave-benefit" +msgstr "30일 자동 저장 버전 및 파일 복구" + +#: src/app/main/ui/settings/subscription.cljs:481, +#: src/app/main/ui/settings/subscription.cljs:493, +#: src/app/main/ui/settings/subscription.cljs:557 +msgid "subscription.settings.unlimited.bill" +msgstr "월 최대 $175 정액 청구" + +#: src/app/main/ui/settings/subscription.cljs:479, +#: src/app/main/ui/settings/subscription.cljs:491, +#: src/app/main/ui/settings/subscription.cljs:555 +msgid "subscription.settings.unlimited.storage-benefit" +msgstr "25GB 저장 공간" + +#: src/app/main/ui/dashboard/subscription.cljs:175, +#: src/app/main/ui/workspace/main_menu.cljs:945 +msgid "subscription.workspace.header.menu.option.power-up" +msgstr "플랜 업그레이드" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:57 +msgid "subscription.workspace.versions.warning.enterprise.subtext-owner" +msgstr "이 한도를 늘리려면 [%s](mailto)로 문의하세요" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:59 +msgid "subscription.workspace.versions.warning.subtext-member" +msgstr "이 한도를 늘리려면 팀 소유자에게 문의하세요: [mailto:%s](%s)" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:58 +msgid "subscription.workspace.versions.warning.subtext-owner" +msgstr "이 한도를 늘리려면 [플랜을 업그레이드하세요|target:self](%s)" + +#: src/app/main/ui/dashboard/team.cljs:933 +msgid "team.invitations-selected" +msgid_plural "team.invitations-selected" +msgstr[0] "초대 %s개 선택됨" + +#: src/app/main/ui/dashboard/files.cljs:181 +msgid "title.dashboard.files" +msgstr "%s - Penpot" + +#: src/app/main/ui/dashboard/fonts.cljs:46 +msgid "title.dashboard.font-providers" +msgstr "글꼴 제공자 - %s - Penpot" + +#: src/app/main/ui/dashboard/fonts.cljs:45 +msgid "title.dashboard.fonts" +msgstr "글꼴 - %s - Penpot" + +#: src/app/main/ui/dashboard/projects.cljs:357 +msgid "title.dashboard.projects" +msgstr "프로젝트 - %s - Penpot" + +#: src/app/main/ui/dashboard/search.cljs:50 +msgid "title.dashboard.search" +msgstr "검색 - %s - Penpot" + +#: src/app/main/ui/dashboard/libraries.cljs:58 +msgid "title.dashboard.shared-libraries" +msgstr "공유 라이브러리 - %s - Penpot" + +#: src/app/main/ui/auth/verify_token.cljs:70, src/app/main/ui/auth.cljs:34 +msgid "title.default" +msgstr "Penpot - 팀을 위한 자유로운 디자인" + +#: src/app/main/ui/settings/access_tokens.cljs:278 +msgid "title.settings.access-tokens" +msgstr "프로필 - 액세스 토큰" + +#: src/app/main/ui/settings/feedback.cljs:161 +msgid "title.settings.feedback" +msgstr "의견 보내기 - Penpot" + +#: src/app/main/ui/settings/notifications.cljs:45 +msgid "title.settings.notifications" +msgstr "알림 - Penpot" + +#: src/app/main/ui/settings/options.cljs:83 +msgid "title.settings.options" +msgstr "설정 - Penpot" + +#: src/app/main/ui/settings/password.cljs:105 +msgid "title.settings.password" +msgstr "비밀번호 - Penpot" + +#: src/app/main/ui/settings/profile.cljs:124 +msgid "title.settings.profile" +msgstr "프로필 - Penpot" + +#: src/app/main/ui/dashboard/team.cljs:981 +msgid "title.team-invitations" +msgstr "초대 - %s - Penpot" + +#: src/app/main/ui/dashboard/team.cljs:535 +msgid "title.team-members" +msgstr "구성원 - %s - Penpot" + +#: src/app/main/ui/dashboard/team.cljs:1296 +msgid "title.team-settings" +msgstr "설정 - %s - Penpot" + +#: src/app/main/ui/dashboard/team.cljs:1249 +msgid "title.team-webhooks" +msgstr "웹훅 - %s - Penpot" + +#: src/app/main/ui/viewer.cljs:423 +msgid "title.viewer" +msgstr "%s - 보기 모드 - Penpot" + +#: src/app/main/ui/workspace.cljs:237 +msgid "title.workspace" +msgstr "%s - Penpot" + +#: src/app/main/ui.cljs:138 +msgid "viewer.breaking-change.description" +msgstr "" +"공유 링크가 더 이상 유효하지 않습니다. 새 링크를 만들거나 소유자에게 요청하세" +"요." + +#: src/app/main/ui.cljs:137 +msgid "viewer.breaking-change.message" +msgstr "죄송합니다!" + +#: src/app/main/ui/viewer.cljs:573 +msgid "viewer.empty-state" +msgstr "페이지에 보드가 없습니다." + +#: src/app/main/ui/viewer.cljs:578 +msgid "viewer.frame-not-found" +msgstr "보드를 찾을 수 없습니다." + +#: src/app/main/ui/viewer/header.cljs:336 +msgid "viewer.header.comments-section" +msgstr "댓글 (%s)" + +#: src/app/main/ui/viewer/interactions.cljs:298 +msgid "viewer.header.dont-show-interactions" +msgstr "인터랙션 숨기기" + +#: src/app/main/ui/viewer/header.cljs:187 +msgid "viewer.header.edit-in-workspace" +msgstr "워크스페이스에서 편집" + +#: src/app/main/ui/viewer/header.cljs:193 +msgid "viewer.header.fullscreen" +msgstr "전체 화면" + +#: src/app/main/ui/viewer/header.cljs:346 +msgid "viewer.header.inspect-section" +msgstr "검사 (%s)" + +#: src/app/main/ui/viewer/interactions.cljs:288 +msgid "viewer.header.interactions" +msgstr "인터랙션" + +#: src/app/main/ui/viewer/header.cljs:327 +msgid "viewer.header.interactions-section" +msgstr "인터랙션 (%s)" + +#: src/app/main/ui/viewer/share_link.cljs:193 +msgid "viewer.header.share.copy-link" +msgstr "링크 복사" + +#: src/app/main/ui/viewer/interactions.cljs:306 +msgid "viewer.header.show-interactions" +msgstr "인터랙션 표시" + +#: src/app/main/ui/viewer/interactions.cljs:317 +msgid "viewer.header.show-interactions-on-click" +msgstr "클릭 시 인터랙션 표시" + +#: src/app/main/ui/viewer/header.cljs:233 +msgid "viewer.header.sitemap" +msgstr "사이트맵" + +#: src/app/main/ui/dashboard/team.cljs:1203 +msgid "webhooks.last-delivery.success" +msgstr "마지막 전송이 성공했습니다." + +#: src/app/main/ui/workspace/sidebar/options/menus/align.cljs:55 +msgid "workspace.align.hcenter" +msgstr "가로 가운데 정렬 (%s)" + +#: src/app/main/ui/workspace/sidebar/options/menus/align.cljs:69 +msgid "workspace.align.hdistribute" +msgstr "가로 간격 균등 (%s)" + +#: src/app/main/ui/workspace/sidebar/options/menus/align.cljs:48 +msgid "workspace.align.hleft" +msgstr "왼쪽 정렬 (%s)" + +#: src/app/main/ui/workspace/sidebar/options/menus/align.cljs:62 +msgid "workspace.align.hright" +msgstr "오른쪽 정렬 (%s)" + +#: src/app/main/ui/workspace/sidebar/options/menus/align.cljs:91 +msgid "workspace.align.vbottom" +msgstr "아래쪽 정렬 (%s)" + +#: src/app/main/ui/workspace/sidebar/options/menus/align.cljs:84 +msgid "workspace.align.vcenter" +msgstr "세로 가운데 정렬 (%s)" + +#: src/app/main/ui/workspace/sidebar/options/menus/align.cljs:98 +msgid "workspace.align.vdistribute" +msgstr "세로 간격 균등 (%s)" + +#: src/app/main/ui/workspace/sidebar/options/menus/align.cljs:77 +msgid "workspace.align.vtop" +msgstr "위쪽 정렬 (%s)" + +#: src/app/main/ui/workspace/sidebar/assets.cljs:172 +msgid "workspace.assets.add-library" +msgstr "라이브러리 추가" + +#: src/app/main/ui/workspace/sidebar/assets.cljs +msgid "workspace.assets.assets" +msgstr "에셋" + +#: src/app/main/ui/workspace/sidebar/assets.cljs:152 +msgid "workspace.assets.box-filter-all" +msgstr "모든 에셋" + +#: src/app/main/ui/dashboard/grid.cljs:161, +#: src/app/main/ui/dashboard/grid.cljs:193, +#: src/app/main/ui/workspace/sidebar/assets/colors.cljs:489, +#: src/app/main/ui/workspace/sidebar/assets.cljs:158 +msgid "workspace.assets.colors" +msgstr "색상" + +#: src/app/main/ui/workspace/sidebar/assets/colors.cljs:497 +msgid "workspace.assets.colors.add-color" +msgstr "색상 추가" + +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:81 +msgid "workspace.assets.component-group-options" +msgstr "컴포넌트 그룹 옵션" + +#: src/app/main/ui/dashboard/grid.cljs:157, +#: src/app/main/ui/dashboard/grid.cljs:172, +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:559, +#: src/app/main/ui/workspace/sidebar/assets.cljs:155 +msgid "workspace.assets.components" +msgstr "컴포넌트" + +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:580 +msgid "workspace.assets.components.add-component" +msgstr "컴포넌트 추가" + +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:177, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:602 +msgid "workspace.assets.components.num-variants" +msgstr "%s개 베리언트" + +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:141 +msgid "workspace.assets.create-group" +msgstr "그룹 생성" + +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:152 +msgid "workspace.assets.create-group-hint" +msgstr "항목 이름이 \"그룹 이름 / 항목 이름\" 형식으로 자동으로 지정됩니다" + +#: src/app/main/ui/workspace/context_menu.cljs:684, +#: src/app/main/ui/workspace/sidebar/assets/colors.cljs:251, +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:640, +#: src/app/main/ui/workspace/sidebar/assets/typographies.cljs:442 +msgid "workspace.assets.delete" +msgstr "삭제" + +#: src/app/main/ui/workspace/context_menu.cljs:689 +msgid "workspace.assets.duplicate" +msgstr "복제" + +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:619 +msgid "workspace.assets.duplicate-main" +msgstr "메인 복제" + +#: src/app/main/ui/workspace/sidebar/assets/colors.cljs:247, +#: src/app/main/ui/workspace/sidebar/assets/typographies.cljs:438 +msgid "workspace.assets.edit" +msgstr "편집" + +#: src/app/main/ui/workspace/sidebar/assets.cljs:186 +msgid "workspace.assets.filter" +msgstr "필터" + +#: src/app/main/ui/workspace/sidebar/assets/graphics.cljs:386, +#: src/app/main/ui/workspace/sidebar/assets.cljs:152 +msgid "workspace.assets.graphics" +msgstr "그래픽" + +#: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:189, +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:575, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:806 +msgid "workspace.assets.grid-view" +msgstr "그리드 보기" + +#: src/app/main/ui/workspace/sidebar/assets/colors.cljs:255, +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:624, +#: src/app/main/ui/workspace/sidebar/assets/typographies.cljs:447 +msgid "workspace.assets.group" +msgstr "그룹" + +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:151 +msgid "workspace.assets.group-name" +msgstr "그룹 이름" + +#: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:190, +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:571, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:802 +msgid "workspace.assets.list-view" +msgstr "목록 보기" + +#: src/app/main/ui/workspace/sidebar/assets/file_library.cljs:108, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:686 +msgid "workspace.assets.local-library" +msgstr "로컬 라이브러리" + +#: src/app/main/ui/workspace/sidebar/assets.cljs:176 +msgid "workspace.assets.manage-library" +msgstr "라이브러리 관리" + +#: src/app/main/ui/workspace/sidebar/assets/file_library.cljs:307 +msgid "workspace.assets.not-found" +msgstr "에셋을 찾을 수 없습니다" + +#: src/app/main/ui/workspace/sidebar/assets/file_library.cljs:113 +msgid "workspace.assets.open-library" +msgstr "라이브러리 파일 열기" + +#: src/app/main/ui/workspace/context_menu.cljs:687, +#: src/app/main/ui/workspace/sidebar/assets/colors.cljs:243, +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:615, +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:67, +#: src/app/main/ui/workspace/sidebar/assets/typographies.cljs:433 +msgid "workspace.assets.rename" +msgstr "이름 바꾸기" + +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:142 +msgid "workspace.assets.rename-group" +msgstr "그룹 이름 바꾸기" + +#: src/app/main/ui/workspace/sidebar/assets.cljs:181 +msgid "workspace.assets.search" +msgstr "에셋 검색" + +#: src/app/main/ui/workspace/sidebar/assets.cljs +msgid "workspace.assets.selected-count" +msgid_plural "workspace.assets.selected-count" +msgstr[0] "%s개 항목 선택됨" + +#: src/app/main/ui/workspace/sidebar/assets.cljs +msgid "workspace.assets.shared-library" +msgstr "공유 라이브러리" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:229 +msgid "workspace.assets.sidebar.components" +msgid_plural "workspace.assets.sidebar.components" +msgstr[0] "컴포넌트 %s개" + +#: src/app/main/ui/workspace/sidebar/assets.cljs:201 +msgid "workspace.assets.sort" +msgstr "정렬" + +#: src/app/main/ui/dashboard/grid.cljs:165, +#: src/app/main/ui/dashboard/grid.cljs:220, +#: src/app/main/ui/workspace/sidebar/assets/typographies.cljs:396, +#: src/app/main/ui/workspace/sidebar/assets.cljs:161 +msgid "workspace.assets.typography" +msgstr "타이포그래피" + +#: src/app/main/ui/workspace/sidebar/assets/typographies.cljs:404 +msgid "workspace.assets.typography.add-typography" +msgstr "타이포그래피 추가" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +msgid "workspace.assets.typography.font-id" +msgstr "글꼴" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:528 +msgid "workspace.assets.typography.font-size" +msgstr "크기" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:524 +msgid "workspace.assets.typography.font-style" +msgstr "글꼴 스타일" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:546 +msgid "workspace.assets.typography.go-to-edit" +msgstr "스타일 라이브러리 파일로 이동하여 편집" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:536 +msgid "workspace.assets.typography.letter-spacing" +msgstr "자간" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:532 +msgid "workspace.assets.typography.line-height" +msgstr "행간" + +#: src/app/main/ui/dashboard/grid.cljs:230, +#: src/app/main/ui/workspace/libraries.cljs:566, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:487, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:512, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:619, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:639 +msgid "workspace.assets.typography.sample" +msgstr "가나다" + +msgid "workspace.assets.typography.text-styles" +msgstr "텍스트 스타일" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:540 +msgid "workspace.assets.typography.text-transform" +msgstr "텍스트 변환" + +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:70 +msgid "workspace.assets.ungroup" +msgstr "그룹 해제" + +#: src/app/main/ui/workspace/colorpicker.cljs:428, +#: src/app/main/ui/workspace/colorpicker.cljs:441 +msgid "workspace.colorpicker.color-tokens" +msgstr "색상 token" + +#: src/app/main/ui/workspace/colorpicker.cljs:434 +msgid "workspace.colorpicker.get-color" +msgstr "색상 가져오기" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:506 +msgid "workspace.component.swap.loop-error" +msgstr "컴포넌트는 자기 자신 안에 중첩될 수 없습니다." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:505 +msgid "workspace.component.switch.loop-error-multi" +msgstr "" +"일부 복사본을 전환할 수 없습니다. 컴포넌트는 자기 자신 안에 중첩될 수 없습니" +"다." + +#: src/app/main/ui/workspace/context_menu.cljs:796 +msgid "workspace.context-menu.grid-cells.area" +msgstr "영역 생성" + +#: src/app/main/ui/workspace/context_menu.cljs:799 +msgid "workspace.context-menu.grid-cells.create-board" +msgstr "보드 생성" + +#: src/app/main/ui/workspace/context_menu.cljs:791 +msgid "workspace.context-menu.grid-cells.merge" +msgstr "셀 병합" + +#: src/app/main/ui/workspace/context_menu.cljs:754 +msgid "workspace.context-menu.grid-track.column.add-after" +msgstr "오른쪽에 열 1개 추가" + +#: src/app/main/ui/workspace/context_menu.cljs:753 +msgid "workspace.context-menu.grid-track.column.add-before" +msgstr "왼쪽에 열 1개 추가" + +#: src/app/main/ui/workspace/context_menu.cljs:755 +msgid "workspace.context-menu.grid-track.column.delete" +msgstr "열 삭제" + +#: src/app/main/ui/workspace/context_menu.cljs:756 +msgid "workspace.context-menu.grid-track.column.delete-shapes" +msgstr "열 및 도형 삭제" + +#: src/app/main/ui/workspace/context_menu.cljs:752 +msgid "workspace.context-menu.grid-track.column.duplicate" +msgstr "열 복제" + +#: src/app/main/ui/workspace/context_menu.cljs:761 +msgid "workspace.context-menu.grid-track.row.add-after" +msgstr "아래에 행 1개 추가" + +#: src/app/main/ui/workspace/context_menu.cljs:760 +msgid "workspace.context-menu.grid-track.row.add-before" +msgstr "위에 행 1개 추가" + +#: src/app/main/ui/workspace/context_menu.cljs:762 +msgid "workspace.context-menu.grid-track.row.delete" +msgstr "행 삭제" + +#: src/app/main/ui/workspace/context_menu.cljs:763 +msgid "workspace.context-menu.grid-track.row.delete-shapes" +msgstr "행 및 도형 삭제" + +#: src/app/main/ui/workspace/context_menu.cljs:759 +msgid "workspace.context-menu.grid-track.row.duplicate" +msgstr "행 복제" + +#: src/app/main/ui/workspace/sidebar/debug.cljs:38 +msgid "workspace.debug.title" +msgstr "디버깅 도구" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:512 +msgid "workspace.focus.focus-mode" +msgstr "포커스 모드" + +#: src/app/main/ui/workspace/context_menu.cljs:396, +#: src/app/main/ui/workspace/context_menu.cljs:711 +msgid "workspace.focus.focus-off" +msgstr "포커스 해제" + +#: src/app/main/ui/workspace/context_menu.cljs:395 +msgid "workspace.focus.focus-on" +msgstr "포커스 설정" + +msgid "workspace.focus.selection" +msgstr "선택" + +#: src/app/util/color.cljs:34 +msgid "workspace.gradients.linear" +msgstr "선형 그래디언트" + +#: src/app/util/color.cljs:35 +msgid "workspace.gradients.radial" +msgstr "방사형 그래디언트" + +#: src/app/main/ui/workspace/main_menu.cljs:274 +msgid "workspace.header.menu.disable-dynamic-alignment" +msgstr "동적 정렬 비활성화" + +#: src/app/main/ui/workspace/main_menu.cljs:228 +msgid "workspace.header.menu.disable-scale-content" +msgstr "비율 유지 크기 조정 비활성화" + +#: src/app/main/ui/workspace/header.cljs +msgid "workspace.header.menu.disable-scale-text" +msgstr "텍스트 크기 조정 비활성화" + +#: src/app/main/ui/workspace/main_menu.cljs:259 +msgid "workspace.header.menu.disable-snap-guides" +msgstr "가이드 스냅 비활성화" + +#: src/app/main/ui/workspace/main_menu.cljs:289 +msgid "workspace.header.menu.disable-snap-pixel-grid" +msgstr "픽셀 스냅 비활성화" + +#: src/app/main/ui/workspace/main_menu.cljs:243 +msgid "workspace.header.menu.disable-snap-ruler-guides" +msgstr "눈금자 가이드 스냅 비활성화" + +#: src/app/main/ui/workspace/main_menu.cljs:275 +msgid "workspace.header.menu.enable-dynamic-alignment" +msgstr "동적 정렬 활성화" + +#: src/app/main/ui/workspace/main_menu.cljs:229 +msgid "workspace.header.menu.enable-scale-content" +msgstr "비율 크기 조절 활성화" + +#: src/app/main/ui/workspace/header.cljs +msgid "workspace.header.menu.enable-scale-text" +msgstr "텍스트 크기 조절 활성화" + +#: src/app/main/ui/workspace/main_menu.cljs:260 +msgid "workspace.header.menu.enable-snap-guides" +msgstr "가이드에 스냅" + +#: src/app/main/ui/workspace/main_menu.cljs:290 +msgid "workspace.header.menu.enable-snap-pixel-grid" +msgstr "픽셀 스냅 활성화" + +#: src/app/main/ui/workspace/main_menu.cljs:244 +msgid "workspace.header.menu.enable-snap-ruler-guides" +msgstr "눈금자 가이드에 스냅" + +#: src/app/main/ui/workspace/main_menu.cljs:422 +msgid "workspace.header.menu.hide-artboard-names" +msgstr "보드 이름 숨기기" + +#: src/app/main/ui/workspace/main_menu.cljs:376 +msgid "workspace.header.menu.hide-guides" +msgstr "가이드 숨기기" + +#: src/app/main/ui/workspace/main_menu.cljs:393 +msgid "workspace.header.menu.hide-palette" +msgstr "색상 팔레트 숨기기" + +#: src/app/main/ui/workspace/main_menu.cljs:434 +msgid "workspace.header.menu.hide-pixel-grid" +msgstr "픽셀 그리드 숨기기" + +#: src/app/main/ui/workspace/main_menu.cljs:360 +msgid "workspace.header.menu.hide-rules" +msgstr "눈금자 숨기기" + +#: src/app/main/ui/workspace/main_menu.cljs:407 +msgid "workspace.header.menu.hide-textpalette" +msgstr "글꼴 팔레트 숨기기" + +#: src/app/main/ui/workspace/main_menu.cljs:884 +msgid "workspace.header.menu.option.edit" +msgstr "편집" + +#: src/app/main/ui/workspace/main_menu.cljs:873 +msgid "workspace.header.menu.option.file" +msgstr "파일" + +#: src/app/main/ui/workspace/main_menu.cljs:930 +msgid "workspace.header.menu.option.help-info" +msgstr "도움말 및 정보" + +#: src/app/main/ui/workspace/main_menu.cljs:916 +msgid "workspace.header.menu.option.power-up" +msgstr "플랜 업그레이드" + +#: src/app/main/ui/workspace/main_menu.cljs:906 +msgid "workspace.header.menu.option.preferences" +msgstr "환경 설정" + +#: src/app/main/ui/workspace/main_menu.cljs:895 +msgid "workspace.header.menu.option.view" +msgstr "보기" + +#: src/app/main/ui/workspace/main_menu.cljs:506 +msgid "workspace.header.menu.redo" +msgstr "다시 실행" + +#: src/app/main/ui/workspace/main_menu.cljs:477 +msgid "workspace.header.menu.select-all" +msgstr "모두 선택" + +#: src/app/main/ui/workspace/main_menu.cljs:423 +msgid "workspace.header.menu.show-artboard-names" +msgstr "보드 이름 표시" + +#: src/app/main/ui/workspace/main_menu.cljs:377 +msgid "workspace.header.menu.show-guides" +msgstr "가이드 표시" + +#: src/app/main/ui/workspace/main_menu.cljs:394 +msgid "workspace.header.menu.show-palette" +msgstr "색상 팔레트 표시" + +#: src/app/main/ui/workspace/main_menu.cljs:435 +msgid "workspace.header.menu.show-pixel-grid" +msgstr "픽셀 그리드 표시" + +#: src/app/main/ui/workspace/main_menu.cljs:361 +msgid "workspace.header.menu.show-rules" +msgstr "눈금자 표시" + +#: src/app/main/ui/workspace/main_menu.cljs:408 +msgid "workspace.header.menu.show-textpalette" +msgstr "글꼴 팔레트 표시" + +#: src/app/main/ui/workspace/main_menu.cljs:316 +msgid "workspace.header.menu.toggle-dark-theme" +msgstr "다크 테마로 전환" + +#: src/app/main/ui/workspace/main_menu.cljs:314, +#: src/app/main/ui/workspace/main_menu.cljs:317 +msgid "workspace.header.menu.toggle-light-theme" +msgstr "라이트 테마로 전환" + +#: src/app/main/ui/workspace/main_menu.cljs:315 +msgid "workspace.header.menu.toggle-system-theme" +msgstr "시스템 테마로 전환" + +#: src/app/main/ui/workspace/main_menu.cljs:492 +msgid "workspace.header.menu.undo" +msgstr "실행 취소" + +#: src/app/main/ui/viewer/header.cljs:93, +#: src/app/main/ui/workspace/right_header.cljs:92 +msgid "workspace.header.reset-zoom" +msgstr "초기화" + +#: src/app/main/ui/workspace/left_header.cljs:128 +msgid "workspace.header.save-error" +msgstr "저장 오류" + +#: src/app/main/ui/workspace/left_header.cljs:127 +msgid "workspace.header.saved" +msgstr "저장됨" + +#: src/app/main/ui/workspace/left_header.cljs:125, +#: src/app/main/ui/workspace/left_header.cljs:126 +msgid "workspace.header.saving" +msgstr "저장 중" + +#: src/app/main/ui/workspace/right_header.cljs:232 +msgid "workspace.header.share" +msgstr "공유" + +#: src/app/main/ui/workspace/right_header.cljs:48, +#: src/app/main/ui/workspace/right_header.cljs:53 +msgid "workspace.header.unsaved" +msgstr "저장하지 않은 변경 사항" + +#: src/app/main/ui/workspace/right_header.cljs:237 +msgid "workspace.header.viewer" +msgstr "보기 모드 (%s)" + +#: src/app/main/ui/viewer/header.cljs:74, +#: src/app/main/ui/workspace/right_header.cljs:74 +msgid "workspace.header.zoom" +msgstr "확대/축소" + +#: src/app/main/ui/viewer/header.cljs:104 +msgid "workspace.header.zoom-fill" +msgstr "채우기 - 채우기 맞춤" + +#: src/app/main/ui/viewer/header.cljs:97 +msgid "workspace.header.zoom-fit" +msgstr "맞추기 - 화면에 맞게 축소" + +#: src/app/main/ui/workspace/right_header.cljs:96 +msgid "workspace.header.zoom-fit-all" +msgstr "전체 맞추기" + +#: src/app/main/ui/viewer/header.cljs:111 +msgid "workspace.header.zoom-full-screen" +msgstr "전체 화면" + +#: src/app/main/ui/workspace/right_header.cljs:104 +msgid "workspace.header.zoom-selected" +msgstr "선택 항목에 맞추기" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:422 +msgid "workspace.layout-grid.editor.margin.expand" +msgstr "4면 여백 옵션 표시" + +#: src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs:275, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:859 +msgid "workspace.layout-grid.editor.options.edit-grid" +msgstr "그리드 편집" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1539 +msgid "workspace.layout-grid.editor.options.exit" +msgstr "종료" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:584, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:593, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:599 +msgid "workspace.layout-grid.editor.padding.bottom" +msgstr "아래쪽 패딩" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:669 +msgid "workspace.layout-grid.editor.padding.expand" +msgstr "4면 패딩 옵션 보기" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:416, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:427, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:433 +msgid "workspace.layout-grid.editor.padding.horizontal" +msgstr "가로 패딩" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:618, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:627, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:633 +msgid "workspace.layout-grid.editor.padding.left" +msgstr "왼쪽 패딩" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:551, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:560, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:566 +msgid "workspace.layout-grid.editor.padding.right" +msgstr "오른쪽 패딩" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:517, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:526, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:532 +msgid "workspace.layout-grid.editor.padding.top" +msgstr "위쪽 패딩" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:380, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:391, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:397 +msgid "workspace.layout-grid.editor.padding.vertical" +msgstr "세로 패딩" + +#: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs:62 +msgid "workspace.layout-grid.editor.title" +msgstr "그리드 편집 중" + +#: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs:70 +msgid "workspace.layout-grid.editor.top-bar.done" +msgstr "완료" + +#: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs:66 +msgid "workspace.layout-grid.editor.top-bar.locate" +msgstr "위치 찾기" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1565 +msgid "workspace.layout-grid.editor.top-bar.locate.tooltip" +msgstr "그리드 레이아웃 위치 찾기" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:449 +msgid "workspace.layout-item.fit-content-horizontal" +msgstr "콘텐츠에 맞춤 (가로)" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:477 +msgid "workspace.layout-item.fit-content-vertical" +msgstr "콘텐츠에 맞춤 (세로)" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:465 +msgid "workspace.layout-item.fix-height" +msgstr "높이 고정" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:439 +msgid "workspace.layout-item.fix-width" +msgstr "너비 고정" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:471 +msgid "workspace.layout-item.height-100" +msgstr "높이 100%" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:444 +msgid "workspace.layout-item.width-100" +msgstr "너비 100%" + +#: src/app/main/ui/workspace/libraries.cljs +msgid "workspace.libraries.add" +msgstr "추가" + +#: src/app/main/ui/workspace/libraries.cljs:100, +#: src/app/main/ui/workspace/libraries.cljs:126 +msgid "workspace.libraries.colors" +msgid_plural "workspace.libraries.colors" +msgstr[0] "색상 %s개" + +#: src/app/main/ui/workspace/color_palette.cljs:147 +msgid "workspace.libraries.colors.empty-palette" +msgstr "라이브러리에 아직 색상 스타일이 없습니다" + +#: src/app/main/ui/workspace/text_palette.cljs:161 +msgid "workspace.libraries.colors.empty-typography-palette" +msgstr "라이브러리에 아직 타이포그래피 스타일이 없습니다" + +#: src/app/main/ui/workspace/color_palette_ctx_menu.cljs:88, +#: src/app/main/ui/workspace/colorpicker/libraries.cljs:48, +#: src/app/main/ui/workspace/text_palette_ctx_menu.cljs:49 +msgid "workspace.libraries.colors.file-library" +msgstr "파일 라이브러리" + +#: src/app/main/ui/workspace/colorpicker.cljs +msgid "workspace.libraries.colors.hsv" +msgstr "HSV" + +#: src/app/main/ui/workspace/color_palette_ctx_menu.cljs:111, +#: src/app/main/ui/workspace/colorpicker/libraries.cljs:47 +msgid "workspace.libraries.colors.recent-colors" +msgstr "최근 색상" + +#: src/app/main/ui/workspace/colorpicker.cljs +msgid "workspace.libraries.colors.rgb-complementary" +msgstr "RGB 보색" + +#: src/app/main/ui/workspace/colorpicker.cljs:355 +msgid "workspace.libraries.colors.rgba" +msgstr "RGBA" + +#: src/app/main/ui/workspace/colorpicker.cljs:555 +msgid "workspace.libraries.colors.save-color" +msgstr "색상 스타일 저장" + +#: src/app/main/ui/workspace/libraries.cljs:94, +#: src/app/main/ui/workspace/libraries.cljs:118 +msgid "workspace.libraries.components" +msgid_plural "workspace.libraries.components" +msgstr[0] "컴포넌트 %s개" + +#: src/app/main/ui/workspace/libraries.cljs:338 +msgid "workspace.libraries.connected-to" +msgstr "연결됨:" + +#: src/app/main/ui/workspace/libraries.cljs:392 +msgid "workspace.libraries.empty.add-some" +msgstr "또는 다음 중 하나를 추가해 보세요:" + +#: src/app/main/ui/workspace/libraries.cljs:386 +msgid "workspace.libraries.empty.no-libraries" +msgstr "팀에 공유 라이브러리가 없습니다. 여기서 찾아보세요" + +#: src/app/main/ui/workspace/libraries.cljs:390 +msgid "workspace.libraries.empty.some-templates" +msgstr "일부 템플릿 보기" + +#: src/app/main/ui/workspace/libraries.cljs:313 +msgid "workspace.libraries.file-library" +msgstr "파일 라이브러리" + +#: src/app/main/ui/workspace/libraries.cljs:97, +#: src/app/main/ui/workspace/libraries.cljs:122 +msgid "workspace.libraries.graphics" +msgid_plural "workspace.libraries.graphics" +msgstr[0] "그래픽 %s개" + +#: src/app/main/ui/workspace/libraries.cljs:307 +msgid "workspace.libraries.in-this-file" +msgstr "이 파일의 라이브러리" + +#: src/app/main/ui/workspace/libraries.cljs:628, +#: src/app/main/ui/workspace/libraries.cljs:648 +msgid "workspace.libraries.libraries" +msgstr "라이브러리" + +#: src/app/main/ui/workspace/libraries.cljs +msgid "workspace.libraries.library" +msgstr "라이브러리" + +#: src/app/main/ui/workspace/libraries.cljs:487 +msgid "workspace.libraries.library-updates" +msgstr "라이브러리 업데이트" + +#: src/app/main/ui/workspace/libraries.cljs:381 +msgid "workspace.libraries.loading" +msgstr "로딩 중…" + +#: src/app/main/ui/workspace/libraries.cljs:387 +msgid "workspace.libraries.more-templates" +msgstr "더 많은 템플릿을 찾아보세요 " + +#: src/app/main/ui/workspace/libraries.cljs:485 +msgid "workspace.libraries.no-libraries-need-sync" +msgstr "업데이트가 필요한 공유 라이브러리가 없습니다" + +#: src/app/main/ui/workspace/libraries.cljs:399 +msgid "workspace.libraries.no-matches-for" +msgstr "\"%s\"에 대한 검색 결과가 없습니다" + +#: src/app/main/ui/workspace/libraries.cljs:356 +msgid "workspace.libraries.search-shared-libraries" +msgstr "공유 라이브러리 검색" + +#: src/app/main/ui/workspace/libraries.cljs:352 +msgid "workspace.libraries.shared-libraries" +msgstr "공유 라이브러리" + +#: src/app/main/ui/workspace/libraries.cljs:372 +msgid "workspace.libraries.shared-library-btn" +msgstr "라이브러리 연결" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:332 +msgid "workspace.libraries.text.multiple-typography" +msgstr "여러 타이포그래피" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:335 +msgid "workspace.libraries.text.multiple-typography-tooltip" +msgstr "모든 타이포그래피 연결 해제" + +#: src/app/main/ui/workspace/libraries.cljs:103, +#: src/app/main/ui/workspace/libraries.cljs:130 +msgid "workspace.libraries.typography" +msgid_plural "workspace.libraries.typography" +msgstr[0] "타이포그래피 %s개" + +#: src/app/main/ui/workspace/libraries.cljs:343 +msgid "workspace.libraries.unlink-library-btn" +msgstr "라이브러리 연결 해제" + +#: src/app/main/ui/workspace/libraries.cljs:507 +msgid "workspace.libraries.update" +msgstr "업데이트" + +#: src/app/main/ui/workspace/libraries.cljs:583 +msgid "workspace.libraries.update.see-all-changes" +msgstr "모든 변경 사항 보기" + +#: src/app/main/ui/workspace/libraries.cljs:630 +msgid "workspace.libraries.updates" +msgstr "업데이트" + +#: src/app/main/ui/ds/notifications/shared/notification_pill.cljs:67, +#: src/app/main/ui/ds/notifications/shared/notification_pill.cljs:72 +msgid "workspace.notification-pill.detail" +msgstr "세부정보" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:784 +msgid "workspace.options.add-interaction" +msgstr "+ 버튼을 클릭하여 인터랙션을 추가하세요." + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:96 +msgid "workspace.options.blur-options.add-blur" +msgstr "블러 추가" + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:119 +msgid "workspace.options.blur-options.remove-blur" +msgstr "블러 제거" + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:92, +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:112 +msgid "workspace.options.blur-options.title" +msgstr "블러" + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:91 +msgid "workspace.options.blur-options.title.group" +msgstr "그룹 블러" + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:90 +msgid "workspace.options.blur-options.title.multiple" +msgstr "선택 블러" + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:115 +msgid "workspace.options.blur-options.toggle-blur" +msgstr "블러 전환" + +#: src/app/main/ui/workspace/sidebar/options/page.cljs:42, +#: src/app/main/ui/workspace/sidebar/options/page.cljs:50 +msgid "workspace.options.canvas-background" +msgstr "캔버스 배경" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:567 +msgid "workspace.options.clip-content" +msgstr "콘텐츠 클리핑" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1027, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1033, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1282 +msgid "workspace.options.component" +msgstr "컴포넌트" + +#: src/app/main/ui/inspect/annotation.cljs:19, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:194 +msgid "workspace.options.component.annotation" +msgstr "주석" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1041 +msgid "workspace.options.component.copy" +msgstr "복사" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:187 +msgid "workspace.options.component.create-annotation" +msgstr "주석 만들기" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:186 +msgid "workspace.options.component.edit-annotation" +msgstr "주석 편집" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1040, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1286 +msgid "workspace.options.component.main" +msgstr "메인" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:781 +msgid "workspace.options.component.swap" +msgstr "컴포넌트 교체" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:820 +msgid "workspace.options.component.swap.empty" +msgstr "이 라이브러리에 에셋이 아직 없습니다" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1066 +msgid "workspace.options.component.unlinked" +msgstr "연결 해제됨" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:570 +msgid "workspace.options.component.variant.duplicated.copy.locate" +msgstr "충돌하는 베리언트 위치 찾기" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:567 +msgid "workspace.options.component.variant.duplicated.copy.title" +msgstr "" +"이 컴포넌트에 충돌하는 베리언트가 있습니다. 각 베리언트가 고유한 속성 조합을 " +"갖도록 하세요." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1341 +msgid "workspace.options.component.variant.duplicated.group.locate" +msgstr "중복된 베리언트 위치 찾기" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1338 +msgid "workspace.options.component.variant.duplicated.group.title" +msgstr "일부 베리언트의 속성과 값이 동일합니다" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:267 +msgid "workspace.options.component.variant.duplicated.single.all" +msgstr "" +"이 베리언트들은 속성과 값이 동일합니다. 각각 고유하게 식별될 수 있도록 값을 " +"조정하세요." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:264 +msgid "workspace.options.component.variant.duplicated.single.one" +msgstr "" +"이 베리언트는 다른 베리언트와 속성 및 값이 동일합니다. 고유하게 식별될 수 있" +"도록 값을 조정하세요." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:270 +msgid "workspace.options.component.variant.duplicated.single.some" +msgstr "" +"일부 베리언트의 속성과 값이 동일합니다. 각각 고유하게 식별될 수 있도록 값을 " +"조정하세요." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:557 +msgid "workspace.options.component.variant.malformed.copy" +msgstr "" +"이 컴포넌트에 이름이 유효하지 않은 베리언트가 있습니다. 올바른 형식을 따르세" +"요." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1331 +msgid "workspace.options.component.variant.malformed.group.locate" +msgstr "유효하지 않은 베리언트 위치 찾기" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1328 +msgid "workspace.options.component.variant.malformed.group.title" +msgstr "일부 베리언트의 이름이 유효하지 않습니다" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:560 +msgid "workspace.options.component.variant.malformed.locate" +msgstr "유효하지 않은 베리언트 위치 찾기" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:251 +msgid "workspace.options.component.variant.malformed.single.all" +msgstr "이 베리언트들의 이름이 유효하지 않습니다." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:248 +msgid "workspace.options.component.variant.malformed.single.one" +msgstr "이 베리언트의 이름이 유효하지 않습니다." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:254 +msgid "workspace.options.component.variant.malformed.single.some" +msgstr "일부 베리언트의 이름이 유효하지 않습니다." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:433 +msgid "workspace.options.component.variant.malformed.structure.example" +msgstr "[속성]=[값], [속성]=[값]" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:431 +msgid "workspace.options.component.variant.malformed.structure.title" +msgstr "다음 구조를 사용해 보세요:" + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:54 +msgid "workspace.options.component.variants-help-modal.intro" +msgstr "" +"베리언트 간 전환 시 변경 사항을 유지하려면, Penpot은 다음 조건을 만족하는 레" +"이어를 연결합니다:" + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:91 +msgid "workspace.options.component.variants-help-modal.outro" +msgstr "" +"이 중 하나를 변경(예: 레이어 이름 바꾸기 또는 그룹화)하면 연결이 끊어지지만 " +"변경을 되돌리면 복원됩니다." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:67 +msgid "workspace.options.component.variants-help-modal.rule1" +msgstr "같은 이름을 사용합니다." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:76 +msgid "workspace.options.component.variants-help-modal.rule2" +msgstr "같은 유형을 사용합니다." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:77 +msgid "workspace.options.component.variants-help-modal.rule2.detail" +msgstr "직사각형, 타원, 경로 및 불린 연산 항목들은 동일한 유형으로 간주됩니다." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:87 +msgid "workspace.options.component.variants-help-modal.rule3" +msgstr "계층 수준이 동일합니다." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:88 +msgid "workspace.options.component.variants-help-modal.rule3.detail" +msgstr "그룹, 보드, 레이아웃은 동일한 것으로 간주됩니다." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1045, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1289, +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:47 +msgid "workspace.options.component.variants-help-modal.title" +msgstr "베리언트 연결 방식" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:164 +msgid "workspace.options.constraints" +msgstr "제약 조건" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:151 +msgid "workspace.options.constraints.bottom" +msgstr "아래쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:142, +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:153 +msgid "workspace.options.constraints.center" +msgstr "중앙" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:224 +msgid "workspace.options.constraints.fix-when-scrolling" +msgstr "스크롤 시 고정" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:139 +msgid "workspace.options.constraints.left" +msgstr "왼쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:141 +msgid "workspace.options.constraints.leftright" +msgstr "좌우" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:140 +msgid "workspace.options.constraints.right" +msgstr "오른쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:143, +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:154 +msgid "workspace.options.constraints.scale" +msgstr "크기 조절" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:150 +msgid "workspace.options.constraints.top" +msgstr "위쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:152 +msgid "workspace.options.constraints.topbottom" +msgstr "위아래" + +#: src/app/main/ui/workspace/sidebar/options.cljs:197 +msgid "workspace.options.design" +msgstr "디자인" + +#: src/app/main/ui/inspect/exports.cljs:140 +msgid "workspace.options.export" +msgstr "내보내기" + +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs, +#: src/app/main/ui/inspect/exports.cljs +msgid "workspace.options.export-multiple" +msgstr "선택 영역 내보내기" + +#: src/app/main/ui/inspect/exports.cljs:196, +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:273 +msgid "workspace.options.export-object" +msgid_plural "workspace.options.export-object" +msgstr[0] "요소 %s개 내보내기" + +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:214 +msgid "workspace.options.export.add-export" +msgstr "내보내기 추가" + +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:226, +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:261 +msgid "workspace.options.export.remove-export" +msgstr "내보내기 제거" + +#: src/app/main/ui/inspect/exports.cljs:179, +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:255 +msgid "workspace.options.export.suffix" +msgstr "접미사" + +#: src/app/main/ui/exports/assets.cljs:250 +msgid "workspace.options.exporting-complete" +msgstr "내보내기 완료" + +#: src/app/main/ui/exports/assets.cljs:171, +#: src/app/main/ui/exports/assets.cljs:251, +#: src/app/main/ui/inspect/exports.cljs:195, +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:272 +msgid "workspace.options.exporting-object" +msgstr "내보내는 중…" + +#: src/app/main/ui/exports/assets.cljs:249 +msgid "workspace.options.exporting-object-error" +msgstr "내보내기 실패" + +#: src/app/main/ui/exports/assets.cljs:252 +msgid "workspace.options.exporting-object-slow" +msgstr "내보내기가 예상보다 느립니다" + +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:107, +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:236 +msgid "workspace.options.fill" +msgstr "채우기" + +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:208 +msgid "workspace.options.fill.add-fill" +msgstr "채우기 추가" + +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:223 +msgid "workspace.options.fill.remove-fill" +msgstr "채우기 제거" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:405 +msgid "workspace.options.fit-content" +msgstr "콘텐츠에 맞게 보드 크기 조절" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:704 +msgid "workspace.options.flows.add-flow-start" +msgstr "플로우 시작 추가" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:700 +msgid "workspace.options.flows.flow" +msgstr "플로우" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:164 +msgid "workspace.options.flows.flow-start" +msgstr "플로우 시작" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:672 +msgid "workspace.options.flows.flow-starts" +msgstr "플로우 시작" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:155 +msgid "workspace.options.flows.remove-flow" +msgstr "플로우 제거" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:32 +msgid "workspace.options.grid.auto" +msgstr "자동" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:163 +msgid "workspace.options.grid.column" +msgstr "열" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs +msgid "workspace.options.grid.grid-title" +msgstr "그리드" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:204, +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:247 +msgid "workspace.options.grid.params.color" +msgstr "색상" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs +msgid "workspace.options.grid.params.columns" +msgstr "열" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:270 +msgid "workspace.options.grid.params.gutter" +msgstr "거터" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:257 +msgid "workspace.options.grid.params.height" +msgstr "높이" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:281 +msgid "workspace.options.grid.params.margin" +msgstr "마진" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs +msgid "workspace.options.grid.params.rows" +msgstr "행" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:226, +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:302 +msgid "workspace.options.grid.params.set-default" +msgstr "기본값으로 설정" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs +msgid "workspace.options.grid.params.size" +msgstr "크기" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs +msgid "workspace.options.grid.params.type" +msgstr "유형" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:241 +msgid "workspace.options.grid.params.type.bottom" +msgstr "아래쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:239 +msgid "workspace.options.grid.params.type.center" +msgstr "중앙" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:238 +msgid "workspace.options.grid.params.type.left" +msgstr "왼쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:242 +msgid "workspace.options.grid.params.type.right" +msgstr "오른쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:235 +msgid "workspace.options.grid.params.type.stretch" +msgstr "늘이기" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:237 +msgid "workspace.options.grid.params.type.top" +msgstr "위쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:221, +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:300 +msgid "workspace.options.grid.params.use-default" +msgstr "기본값 사용" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:258 +msgid "workspace.options.grid.params.width" +msgstr "너비" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:164 +msgid "workspace.options.grid.row" +msgstr "행" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:162 +msgid "workspace.options.grid.square" +msgstr "사각형" + +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:106 +msgid "workspace.options.group-fill" +msgstr "그룹 채우기" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:45 +msgid "workspace.options.group-stroke" +msgstr "그룹 선" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:332 +msgid "workspace.options.guides.add-guide" +msgstr "가이드 추가" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:191 +msgid "workspace.options.guides.remove-guide" +msgstr "가이드 제거" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:329 +msgid "workspace.options.guides.title" +msgstr "가이드" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:187 +msgid "workspace.options.guides.toggle-guide" +msgstr "가이드 전환" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:435, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:454 +msgid "workspace.options.height" +msgstr "높이" + +#: src/app/main/ui/workspace/sidebar/options.cljs:201 +msgid "workspace.options.inspect" +msgstr "검사" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:438 +msgid "workspace.options.interaction-action" +msgstr "동작" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:43, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:344 +msgid "workspace.options.interaction-after-delay" +msgstr "지연 후" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:578 +msgid "workspace.options.interaction-animation" +msgstr "애니메이션" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:616 +msgid "workspace.options.interaction-animation-direction-down" +msgstr "아래" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:593 +msgid "workspace.options.interaction-animation-direction-in" +msgstr "들어오기" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:612 +msgid "workspace.options.interaction-animation-direction-left" +msgstr "왼쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:596 +msgid "workspace.options.interaction-animation-direction-out" +msgstr "나가기" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:608 +msgid "workspace.options.interaction-animation-direction-right" +msgstr "오른쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:620 +msgid "workspace.options.interaction-animation-direction-up" +msgstr "위로" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:383 +msgid "workspace.options.interaction-animation-dissolve" +msgstr "디졸브" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:382 +msgid "workspace.options.interaction-animation-none" +msgstr "없음" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:389 +msgid "workspace.options.interaction-animation-push" +msgstr "밀기" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:384 +msgid "workspace.options.interaction-animation-slide" +msgstr "슬라이드" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:368 +msgid "workspace.options.interaction-auto" +msgstr "자동" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:568 +msgid "workspace.options.interaction-background" +msgstr "배경 오버레이 추가" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:561 +msgid "workspace.options.interaction-close-outside" +msgstr "외부 클릭 시 닫기" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:349 +msgid "workspace.options.interaction-close-overlay" +msgstr "오버레이 닫기" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:58 +msgid "workspace.options.interaction-close-overlay-dest" +msgstr "오버레이 닫기: %s" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:426 +msgid "workspace.options.interaction-delay" +msgstr "지연" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:449 +msgid "workspace.options.interaction-destination" +msgstr "목적지" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:628 +msgid "workspace.options.interaction-duration" +msgstr "지속 시간" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:641 +msgid "workspace.options.interaction-easing" +msgstr "이징" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:393 +msgid "workspace.options.interaction-easing-ease" +msgstr "기본(ease)" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:394 +msgid "workspace.options.interaction-easing-ease-in" +msgstr "감가속(In)" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:396 +msgid "workspace.options.interaction-easing-ease-in-out" +msgstr "감가감속(In-Out)" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:395 +msgid "workspace.options.interaction-easing-ease-out" +msgstr "가감속(Out)" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:392 +msgid "workspace.options.interaction-easing-linear" +msgstr "선형(Linear)" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs +msgid "workspace.options.interaction-in" +msgstr "들어오기" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:41, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:341 +msgid "workspace.options.interaction-mouse-enter" +msgstr "마우스 진입 시" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:42, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:342 +msgid "workspace.options.interaction-mouse-leave" +msgstr "마우스 이탈 시" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:430, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:632 +msgid "workspace.options.interaction-ms" +msgstr "ms" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:346 +msgid "workspace.options.interaction-navigate-to" +msgstr "이동" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:52 +msgid "workspace.options.interaction-navigate-to-dest" +msgstr "이동: %s" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:53, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:55, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:57, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:357 +msgid "workspace.options.interaction-none" +msgstr "(설정 안 함)" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:654 +msgid "workspace.options.interaction-offset-effect" +msgstr "오프셋 효과" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:37, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:337 +msgid "workspace.options.interaction-on-click" +msgstr "클릭 시" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:347 +msgid "workspace.options.interaction-open-overlay" +msgstr "오버레이 열기" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:54 +msgid "workspace.options.interaction-open-overlay-dest" +msgstr "오버레이 열기: %s" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:61, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:351 +msgid "workspace.options.interaction-open-url" +msgstr "URL 열기" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs +msgid "workspace.options.interaction-out" +msgstr "나가기" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:380, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:554 +msgid "workspace.options.interaction-pos-bottom-center" +msgstr "아래 가운데" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:378, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:538 +msgid "workspace.options.interaction-pos-bottom-left" +msgstr "아래 왼쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:379, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:546 +msgid "workspace.options.interaction-pos-bottom-right" +msgstr "아래 오른쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:374, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:506 +msgid "workspace.options.interaction-pos-center" +msgstr "중앙" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:373 +msgid "workspace.options.interaction-pos-manual" +msgstr "수동" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:377, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:530 +msgid "workspace.options.interaction-pos-top-center" +msgstr "위 가운데" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:375, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:514 +msgid "workspace.options.interaction-pos-top-left" +msgstr "위 왼쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:376, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:522 +msgid "workspace.options.interaction-pos-top-right" +msgstr "위 오른쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:492 +msgid "workspace.options.interaction-position" +msgstr "위 오른쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:460 +msgid "workspace.options.interaction-preserve-scroll" +msgstr "스크롤 위치 유지" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:60, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:350 +msgid "workspace.options.interaction-prev-screen" +msgstr "이전 화면" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:482 +msgid "workspace.options.interaction-relative-to" +msgstr "기준:" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:59, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:356, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:370, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:371 +msgid "workspace.options.interaction-self" +msgstr "자기 자신" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:348 +msgid "workspace.options.interaction-toggle-overlay" +msgstr "오버레이 전환" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:56 +msgid "workspace.options.interaction-toggle-overlay-dest" +msgstr "오버레이 전환: %s" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:415 +msgid "workspace.options.interaction-trigger" +msgstr "트리거" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:469 +msgid "workspace.options.interaction-url" +msgstr "URL" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:39, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:339 +msgid "workspace.options.interaction-while-hovering" +msgstr "호버 시" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:40, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:340 +msgid "workspace.options.interaction-while-pressing" +msgstr "누르는 동안" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:743 +msgid "workspace.options.interactions" +msgstr "인터랙션" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:746 +msgid "workspace.options.interactions.add-interaction" +msgstr "인터랙션 추가" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs +msgid "workspace.options.interactions.remove-interaction" +msgstr "인터랙션 제거" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:197 +msgid "workspace.options.layer-options.blend-mode.color" +msgstr "색상" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:186 +msgid "workspace.options.layer-options.blend-mode.color-burn" +msgstr "색상 번" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:189 +msgid "workspace.options.layer-options.blend-mode.color-dodge" +msgstr "색상 닷지" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:184 +msgid "workspace.options.layer-options.blend-mode.darken" +msgstr "어둡게" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:193 +msgid "workspace.options.layer-options.blend-mode.difference" +msgstr "차이" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:194 +msgid "workspace.options.layer-options.blend-mode.exclusion" +msgstr "제외" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:192 +msgid "workspace.options.layer-options.blend-mode.hard-light" +msgstr "하드 라이트" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:195 +msgid "workspace.options.layer-options.blend-mode.hue" +msgstr "색조" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:187 +msgid "workspace.options.layer-options.blend-mode.lighten" +msgstr "밝게" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:198 +msgid "workspace.options.layer-options.blend-mode.luminosity" +msgstr "광도" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:185 +msgid "workspace.options.layer-options.blend-mode.multiply" +msgstr "곱하기" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:183 +msgid "workspace.options.layer-options.blend-mode.normal" +msgstr "보통" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:190 +msgid "workspace.options.layer-options.blend-mode.overlay" +msgstr "오버레이" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:196 +msgid "workspace.options.layer-options.blend-mode.saturation" +msgstr "채도" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:188 +msgid "workspace.options.layer-options.blend-mode.screen" +msgstr "스크린" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:191 +msgid "workspace.options.layer-options.blend-mode.soft-light" +msgstr "소프트 라이트" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs +msgid "workspace.options.layer-options.title" +msgstr "레이어" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs +msgid "workspace.options.layer-options.title.group" +msgstr "레이어 그룹 만들기" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs +msgid "workspace.options.layer-options.title.multiple" +msgstr "선택된 레이어" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:255, +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:261 +msgid "workspace.options.layer-options.toggle-layer" +msgstr "레이어 표시 전환" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +msgid "workspace.options.layout-item.advanced-ops" +msgstr "고급 옵션" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:686, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:693 +msgid "workspace.options.layout-item.layout-item-max-h" +msgstr "최대 높이" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:624, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:631 +msgid "workspace.options.layout-item.layout-item-max-w" +msgstr "최대 너비" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:655, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:663 +msgid "workspace.options.layout-item.layout-item-min-h" +msgstr "최소 높이" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:591, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:600 +msgid "workspace.options.layout-item.layout-item-min-w" +msgstr "최소 너비" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +msgid "workspace.options.layout-item.title.layout-item-max-h" +msgstr "최대 높이" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +msgid "workspace.options.layout-item.title.layout-item-max-w" +msgstr "최대 너비" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +msgid "workspace.options.layout-item.title.layout-item-min-h" +msgstr "최소 높이" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +msgid "workspace.options.layout-item.title.layout-item-min-w" +msgstr "최소 너비" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.bottom" +msgstr "아래쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.direction.column" +msgstr "열" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.direction.column-reverse" +msgstr "열 반전" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.direction.row" +msgstr "행" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.direction.row-reverse" +msgstr "행 반전" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.gap" +msgstr "간격" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.left" +msgstr "왼쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +msgid "workspace.options.layout.margin" +msgstr "마진" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +msgid "workspace.options.layout.margin-all" +msgstr "전체" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +msgid "workspace.options.layout.margin-simple" +msgstr "단일 마진" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.packed" +msgstr "촘촘히 배치" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.padding" +msgstr "패딩" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.padding-all" +msgstr "전체" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.padding-simple" +msgstr "단일 패딩" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.right" +msgstr "오른쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.space-around" +msgstr "여백 포함 배분" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.space-between" +msgstr "균등 배분" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.top" +msgstr "위쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs:241 +msgid "workspace.options.more-colors" +msgstr "색상 더보기" + +#: src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs:223 +msgid "workspace.options.more-lib-colors" +msgstr "라이브러리 색상 더보기" + +#: src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs:265 +msgid "workspace.options.more-token-colors" +msgstr "색상 token 더보기" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:229, +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:241 +msgid "workspace.options.opacity" +msgstr "불투명도" + +#: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:108, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:401 +msgid "workspace.options.orientation.horizontal" +msgstr "가로" + +#: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:104, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:397 +msgid "workspace.options.orientation.vertical" +msgstr "세로" + +#: src/app/main/ui/workspace/sidebar/options/shapes/frame.cljs, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs +msgid "workspace.options.position" +msgstr "위치" + +#: src/app/main/ui/workspace/sidebar/options.cljs:199 +msgid "workspace.options.prototype" +msgstr "프로토타입" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:182, +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:206 +msgid "workspace.options.radius" +msgstr "반지름" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:271, +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:323 +msgid "workspace.options.radius-bottom-left" +msgstr "왼쪽 아래" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:290, +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:331 +msgid "workspace.options.radius-bottom-right" +msgstr "오른쪽 아래" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:234, +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:307 +msgid "workspace.options.radius-top-left" +msgstr "왼쪽 위" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:253, +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:315 +msgid "workspace.options.radius-top-right" +msgstr "오른쪽 위" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:340 +msgid "workspace.options.radius.hide-all-corners" +msgstr "개별 반경 통합" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:341 +msgid "workspace.options.radius.show-single-corners" +msgstr "개별 반경 표시" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:191 +msgid "workspace.options.recent-fonts" +msgstr "최근" + +#: src/app/main/ui/exports/assets.cljs:298 +msgid "workspace.options.retry" +msgstr "다시 시도" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:536, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:544 +msgid "workspace.options.rotation" +msgstr "회전" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:188 +msgid "workspace.options.search-font" +msgstr "글꼴 검색" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:786 +msgid "workspace.options.select-a-shape" +msgstr "도형, 보드 또는 그룹을 선택하여 다른 보드로 연결하세요." + +#: src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs:201 +msgid "workspace.options.selection-color" +msgstr "선택 색상" + +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:105 +msgid "workspace.options.selection-fill" +msgstr "선택 채우기" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:44 +msgid "workspace.options.selection-stroke" +msgstr "선택 선" + +#: src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs:144 +msgid "workspace.options.shadow-options.add-shadow" +msgstr "그림자 추가" + +#: src/app/main/ui/inspect/attributes/shadow.cljs:47, +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:181, +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:183 +msgid "workspace.options.shadow-options.blur" +msgstr "블러" + +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:211 +msgid "workspace.options.shadow-options.color" +msgstr "그림자 색상" + +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:122 +msgid "workspace.options.shadow-options.drop-shadow" +msgstr "그림자 효과" + +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:123 +msgid "workspace.options.shadow-options.inner-shadow" +msgstr "내부 그림자" + +#: src/app/main/ui/inspect/attributes/shadow.cljs:45, +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:172 +msgid "workspace.options.shadow-options.offsetx" +msgstr "X" + +#: src/app/main/ui/inspect/attributes/shadow.cljs:46, +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:201 +msgid "workspace.options.shadow-options.offsety" +msgstr "Y" + +#: src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs:157, +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:161 +msgid "workspace.options.shadow-options.remove-shadow" +msgstr "그림자 제거" + +#: src/app/main/ui/inspect/attributes/shadow.cljs:48, +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:191, +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:193 +msgid "workspace.options.shadow-options.spread" +msgstr "확산" + +#: src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs:139 +msgid "workspace.options.shadow-options.title" +msgstr "그림자" + +#: src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs:138 +msgid "workspace.options.shadow-options.title.group" +msgstr "그룹 그림자" + +#: src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs:137 +msgid "workspace.options.shadow-options.title.multiple" +msgstr "선택 영역 그림자" + +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:157 +msgid "workspace.options.shadow-options.toggle-shadow" +msgstr "그림자 전환" + +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:258 +msgid "workspace.options.show-fill-on-export" +msgstr "내보내기에 표시" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:574 +msgid "workspace.options.show-in-viewer" +msgstr "보기 모드에서 표시" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:168 +msgid "workspace.options.size" +msgstr "크기" + +#: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:71, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:364 +msgid "workspace.options.size-presets" +msgstr "크기 프리셋" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:469 +msgid "workspace.options.size.lock" +msgstr "비율 잠금" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:469 +msgid "workspace.options.size.unlock" +msgstr "비율 잠금 해제" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:44 +msgid "workspace.options.stroke" +msgstr "선" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +msgid "workspace.options.stroke-cap.circle-marker" +msgstr "원형 마커" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:175 +msgid "workspace.options.stroke-cap.circle-marker-short" +msgstr "원" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +msgid "workspace.options.stroke-cap.diamond-marker" +msgstr "다이아몬드 마커" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:176 +msgid "workspace.options.stroke-cap.diamond-marker-short" +msgstr "다이아몬드" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +msgid "workspace.options.stroke-cap.line-arrow" +msgstr "선 화살표" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:172 +msgid "workspace.options.stroke-cap.line-arrow-short" +msgstr "화살표" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:170 +msgid "workspace.options.stroke-cap.none" +msgstr "없음" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:178 +msgid "workspace.options.stroke-cap.round" +msgstr "둥근" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:179 +msgid "workspace.options.stroke-cap.square" +msgstr "사각형" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +msgid "workspace.options.stroke-cap.square-marker" +msgstr "사각형 마커" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:174 +msgid "workspace.options.stroke-cap.square-marker-short" +msgstr "직사각형" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +msgid "workspace.options.stroke-cap.triangle-arrow" +msgstr "삼각형 화살표" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:173 +msgid "workspace.options.stroke-cap.triangle-arrow-short" +msgstr "삼각형" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:210 +msgid "workspace.options.stroke-color" +msgstr "선 색상" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:225, +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:230 +msgid "workspace.options.stroke-width" +msgstr "선 두께" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:189 +msgid "workspace.options.stroke.add-stroke" +msgstr "선 색상 추가" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:111 +msgid "workspace.options.stroke.center" +msgstr "중앙" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:139 +msgid "workspace.options.stroke.dashed" +msgstr "점선" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:138 +msgid "workspace.options.stroke.dotted" +msgstr "점선" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:112 +msgid "workspace.options.stroke.inner" +msgstr "안쪽" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:140 +msgid "workspace.options.stroke.mixed" +msgstr "혼합" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:113 +msgid "workspace.options.stroke.outer" +msgstr "바깥쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:202 +msgid "workspace.options.stroke.remove-stroke" +msgstr "선 제거" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:137 +msgid "workspace.options.stroke.solid" +msgstr "단색" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:127 +msgid "workspace.options.text-options.align-bottom" +msgstr "아래쪽 정렬" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:123 +msgid "workspace.options.text-options.align-middle" +msgstr "중간 정렬" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:119 +msgid "workspace.options.text-options.align-top" +msgstr "위쪽 정렬" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:92 +msgid "workspace.options.text-options.direction-ltr" +msgstr "LTR" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:96 +msgid "workspace.options.text-options.direction-rtl" +msgstr "RTL" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:165 +msgid "workspace.options.text-options.grow-auto-height" +msgstr "자동 높이" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:161 +msgid "workspace.options.text-options.grow-auto-width" +msgstr "자동 너비" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:157 +msgid "workspace.options.text-options.grow-fixed" +msgstr "고정됨" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:400 +msgid "workspace.options.text-options.letter-spacing" +msgstr "자간" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:381 +msgid "workspace.options.text-options.line-height" +msgstr "줄 높이" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +msgid "workspace.options.text-options.lowercase" +msgstr "소문자" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +msgid "workspace.options.text-options.none" +msgstr "없음" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:192 +msgid "workspace.options.text-options.strikethrough" +msgstr "취소선 (%s)" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:61 +msgid "workspace.options.text-options.text-align-center" +msgstr "중앙 정렬" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:69 +msgid "workspace.options.text-options.text-align-justify" +msgstr "양쪽 정렬" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:57 +msgid "workspace.options.text-options.text-align-left" +msgstr "왼쪽 정렬" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:65 +msgid "workspace.options.text-options.text-align-right" +msgstr "오른쪽 정렬" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:205 +msgid "workspace.options.text-options.title" +msgstr "텍스트" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:204 +msgid "workspace.options.text-options.title-group" +msgstr "그룹 텍스트" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:203 +msgid "workspace.options.text-options.title-selection" +msgstr "그룹 텍스트" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +msgid "workspace.options.text-options.titlecase" +msgstr "단어 첫 글자 대문자" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:188 +msgid "workspace.options.text-options.underline" +msgstr "밑줄 (%s)" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +msgid "workspace.options.text-options.uppercase" +msgstr "대문자" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:788 +msgid "workspace.options.use-play-button" +msgstr "헤더의 재생 버튼을 클릭해 프로토타입 보기를 실행하세요." + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:420, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:442 +msgid "workspace.options.width" +msgstr "너비" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:482, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:505 +msgid "workspace.options.x" +msgstr "X축" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:495, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:516 +msgid "workspace.options.y" +msgstr "Y축" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:140 +msgid "workspace.path.actions.add-node" +msgstr "노드 추가 (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:148 +msgid "workspace.path.actions.delete-node" +msgstr "노드 삭제 (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:123 +msgid "workspace.path.actions.draw-nodes" +msgstr "노드 그리기 (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:166 +msgid "workspace.path.actions.join-nodes" +msgstr "노드 연결 (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:184 +msgid "workspace.path.actions.make-corner" +msgstr "직선으로 변환 (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:192 +msgid "workspace.path.actions.make-curve" +msgstr "곡선으로 변환 (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:158 +msgid "workspace.path.actions.merge-nodes" +msgstr "노드 병합 (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:131 +msgid "workspace.path.actions.move-nodes" +msgstr "노드 이동 (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:174 +msgid "workspace.path.actions.separate-nodes" +msgstr "노드 분리 (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:203 +msgid "workspace.path.actions.snap-nodes" +msgstr "노드 스냅 (%s)" + +#: src/app/main/ui/workspace/plugins.cljs:85 +msgid "workspace.plugins.button-open" +msgstr "열기" + +#: src/app/main/ui/workspace/plugins.cljs:199 +msgid "workspace.plugins.discover" +msgstr "[더 많은 플러그인](%s) 찾아보기" + +#: src/app/main/ui/workspace/plugins.cljs:206 +msgid "workspace.plugins.empty-plugins" +msgstr "아직 설치된 플러그인이 없습니다" + +#: src/app/main/ui/workspace/plugins.cljs:193 +msgid "workspace.plugins.error.manifest" +msgstr "플러그인 매니페스트가 올바르지 않습니다." + +#: src/app/main/data/plugins.cljs:105, +#: src/app/main/ui/workspace/main_menu.cljs:766, +#: src/app/main/ui/workspace/plugins.cljs:84 +msgid "workspace.plugins.error.need-editor" +msgstr "이 플러그인을 사용하려면 편집자여야 합니다" + +#: src/app/main/ui/workspace/plugins.cljs:189 +msgid "workspace.plugins.error.url" +msgstr "플러그인이 존재하지 않거나 URL이 올바르지 않습니다." + +#: src/app/main/ui/workspace/plugins.cljs:185 +msgid "workspace.plugins.install" +msgstr "설치" + +#: src/app/main/ui/workspace/plugins.cljs:215 +msgid "workspace.plugins.installed-plugins" +msgstr "설치된 플러그인" + +#: src/app/main/ui/workspace/main_menu.cljs:721 +msgid "workspace.plugins.menu.plugins-manager" +msgstr "플러그인 관리자" + +#: src/app/main/ui/workspace/main_menu.cljs:918 +msgid "workspace.plugins.menu.title" +msgstr "플러그인" + +#: src/app/main/ui/workspace/plugins.cljs:376 +msgid "workspace.plugins.permissions-update.title" +msgstr "이 플러그인 업데이트" + +#: src/app/main/ui/workspace/plugins.cljs:380 +msgid "workspace.plugins.permissions-update.warning" +msgstr "" +"마지막으로 열었던 이후 플러그인이 수정되었습니다. 이제 다음에도 접근하려 합니" +"다:" + +#: src/app/main/ui/workspace/plugins.cljs:280 +msgid "workspace.plugins.permissions.allow-download" +msgstr "파일 다운로드 시작." + +#: src/app/main/ui/workspace/plugins.cljs:287 +msgid "workspace.plugins.permissions.allow-localstorage" +msgstr "브라우저에 데이터를 저장합니다." + +#: src/app/main/ui/workspace/plugins.cljs:273 +msgid "workspace.plugins.permissions.comment-read" +msgstr "댓글과 답글을 읽습니다." + +#: src/app/main/ui/workspace/plugins.cljs:267 +msgid "workspace.plugins.permissions.comment-write" +msgstr "댓글을 읽고 수정하며 귀하의 이름으로 답글을 답니다." + +#: src/app/main/ui/workspace/plugins.cljs:240 +msgid "workspace.plugins.permissions.content-read" +msgstr "사용자가 접근할 수 있는 파일의 내용을 읽습니다." + +#: src/app/main/ui/workspace/plugins.cljs:234 +msgid "workspace.plugins.permissions.content-write" +msgstr "사용자가 접근할 수 있는 파일의 내용을 읽고 수정합니다." + +#: src/app/main/ui/workspace/plugins.cljs:327 +msgid "workspace.plugins.permissions.disclaimer" +msgstr "" +"이 플러그인은 외부 개발자가 만들었습니다. 접근 권한을 부여하기 전에 신뢰할 " +"수 있는지 확인하세요. 개인정보 보호 및 보안이 중요합니다. 문의 사항은 지원팀" +"에 연락하세요." + +#: src/app/main/ui/workspace/plugins.cljs:260 +msgid "workspace.plugins.permissions.library-read" +msgstr "라이브러리와 에셋을 읽습니다." + +#: src/app/main/ui/workspace/plugins.cljs:254 +msgid "workspace.plugins.permissions.library-write" +msgstr "라이브러리와 에셋을 읽고 수정합니다." + +#: src/app/main/ui/workspace/plugins.cljs:320 +msgid "workspace.plugins.permissions.title" +msgstr "'%s' 플러그인이 다음에 접근하려 합니다:" + +#: src/app/main/ui/workspace/plugins.cljs:247 +msgid "workspace.plugins.permissions.user-read" +msgstr "현재 사용자의 프로필 정보를 읽습니다." + +#: src/app/main/ui/workspace/plugins.cljs:211 +msgid "workspace.plugins.plugin-list-link" +msgstr "플러그인 목록" + +#: src/app/main/ui/workspace/plugins.cljs:88 +msgid "workspace.plugins.remove-plugin" +msgstr "플러그인 제거" + +#: src/app/main/ui/workspace/plugins.cljs:180 +msgid "workspace.plugins.search-placeholder" +msgstr "플러그인 URL로 검색" + +msgid "workspace.plugins.success" +msgstr "플러그인이 올바르게 로드되었습니다." + +#: src/app/main/ui/workspace/plugins.cljs:174 +msgid "workspace.plugins.title" +msgstr "플러그인" + +#: src/app/main/ui/workspace/plugins.cljs:440 +msgid "workspace.plugins.try-out.cancel" +msgstr "나중에" + +#: src/app/main/ui/workspace/plugins.cljs:433 +msgid "workspace.plugins.try-out.message" +msgstr "" +"살펴보시겠습니까? 현재 팀의 새 초안에서 열립니다. (그렇지 않으면 언제든지 파" +"일의 설치된 플러그인에서 찾을 수 있습니다.)" + +#: src/app/main/ui/workspace/plugins.cljs:429 +msgid "workspace.plugins.try-out.title" +msgstr "'%s' 플러그인이 설치되었습니다!" + +#: src/app/main/ui/workspace/plugins.cljs:446 +msgid "workspace.plugins.try-out.try" +msgstr "플러그인 사용해보기" + +#: src/app/main/ui/workspace/context_menu.cljs:559 +msgid "workspace.shape.menu.add-flex" +msgstr "플렉스 레이아웃 추가" + +#: src/app/main/ui/workspace/context_menu.cljs:563 +msgid "workspace.shape.menu.add-grid" +msgstr "그리드 레이아웃 추가" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1248, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1272 +msgid "workspace.shape.menu.add-layout" +msgstr "레이아웃 추가" + +#: src/app/main/ui/workspace/context_menu.cljs:612, +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:508, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1051, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1219, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1293 +msgid "workspace.shape.menu.add-variant" +msgstr "베리언트 생성" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:512, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1073, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1221, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1307 +msgid "workspace.shape.menu.add-variant-property" +msgstr "새 속성 추가" + +#: src/app/main/ui/workspace/context_menu.cljs:282 +msgid "workspace.shape.menu.back" +msgstr "맨 뒤로" + +#: src/app/main/ui/workspace/context_menu.cljs:279 +msgid "workspace.shape.menu.backward" +msgstr "뒤로" + +#: src/app/main/ui/workspace/context_menu.cljs:619, +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:633, +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:75, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1106 +msgid "workspace.shape.menu.combine-as-variants" +msgstr "베리언트로 결합" + +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:635 +msgid "workspace.shape.menu.combine-as-variants-error" +msgstr "컴포넌트가 같은 페이지에 있어야 합니다" + +#: src/app/main/ui/workspace/context_menu.cljs:200 +msgid "workspace.shape.menu.copy" +msgstr "복사" + +#: src/app/main/ui/workspace/context_menu.cljs:218 +msgid "workspace.shape.menu.copy-css" +msgstr "CSS로 복사" + +#: src/app/main/ui/workspace/context_menu.cljs:220 +msgid "workspace.shape.menu.copy-css-nested" +msgstr "CSS로 복사 (중첩된 레이어 포함)" + +#: src/app/main/ui/workspace/context_menu.cljs:203 +msgid "workspace.shape.menu.copy-link" +msgstr "링크 복사" + +#: src/app/main/ui/workspace/context_menu.cljs:216 +msgid "workspace.shape.menu.copy-paste-as" +msgstr "다른 형식으로 복사/붙여넣기." + +#: src/app/main/ui/workspace/context_menu.cljs:230 +msgid "workspace.shape.menu.copy-props" +msgstr "속성 복사" + +#: src/app/main/ui/workspace/context_menu.cljs:222 +msgid "workspace.shape.menu.copy-svg" +msgstr "SVG로 복사" + +#: src/app/main/ui/workspace/context_menu.cljs:227 +msgid "workspace.shape.menu.copy-text" +msgstr "텍스트로 복사" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:484 +msgid "workspace.shape.menu.create-annotation" +msgstr "주석 추가" + +#: src/app/main/ui/workspace/context_menu.cljs:382 +msgid "workspace.shape.menu.create-artboard-from-selection" +msgstr "선택 항목을 보드로" + +#: src/app/main/ui/workspace/context_menu.cljs:592 +msgid "workspace.shape.menu.create-component" +msgstr "컴포넌트 생성" + +#: src/app/main/ui/workspace/context_menu.cljs:596 +msgid "workspace.shape.menu.create-multiple-components" +msgstr "여러 컴포넌트 생성" + +#: src/app/main/ui/workspace/context_menu.cljs:206 +msgid "workspace.shape.menu.cut" +msgstr "잘라내기" + +#: src/app/main/ui/workspace/context_menu.cljs:629, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1001, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1290 +msgid "workspace.shape.menu.delete" +msgstr "삭제" + +#: src/app/main/ui/workspace/context_menu.cljs:506 +msgid "workspace.shape.menu.delete-flow-start" +msgstr "플로우 시작 삭제" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:489 +msgid "workspace.shape.menu.detach-instance" +msgstr "인스턴스 분리" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:488 +msgid "workspace.shape.menu.detach-instances-in-bulk" +msgstr "인스턴스 모두 분리" + +#: src/app/main/ui/workspace/context_menu.cljs:447, +#: src/app/main/ui/workspace/sidebar/options/menus/bool.cljs:88 +msgid "workspace.shape.menu.difference" +msgstr "차이" + +#: src/app/main/ui/workspace/context_menu.cljs:212 +msgid "workspace.shape.menu.duplicate" +msgstr "복제복제" + +#: src/app/main/ui/workspace/context_menu.cljs:432 +msgid "workspace.shape.menu.edit" +msgstr "편집" + +#: src/app/main/ui/workspace/context_menu.cljs:453, +#: src/app/main/ui/workspace/sidebar/options/menus/bool.cljs:98 +msgid "workspace.shape.menu.exclude" +msgstr "제외" + +#: src/app/main/ui/workspace/context_menu.cljs:437, +#: src/app/main/ui/workspace/context_menu.cljs:460, +#: src/app/main/ui/workspace/sidebar/options/menus/bool.cljs:104 +msgid "workspace.shape.menu.flatten" +msgstr "병합" + +#: src/app/main/ui/workspace/context_menu.cljs:299 +msgid "workspace.shape.menu.flip-horizontal" +msgstr "좌우 반전" + +#: src/app/main/ui/workspace/context_menu.cljs:295 +msgid "workspace.shape.menu.flip-vertical" +msgstr "상하 반전" + +#: src/app/main/ui/workspace/context_menu.cljs:508 +msgid "workspace.shape.menu.flow-start" +msgstr "플로우 시작" + +#: src/app/main/ui/workspace/context_menu.cljs:273 +msgid "workspace.shape.menu.forward" +msgstr "앞으로" + +#: src/app/main/ui/workspace/context_menu.cljs:276 +msgid "workspace.shape.menu.front" +msgstr "맨 앞으로" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs, +#: src/app/main/ui/workspace/context_menu.cljs +msgid "workspace.shape.menu.go-main" +msgstr "메인 컴포넌트 파일로 이동" + +#: src/app/main/ui/workspace/context_menu.cljs:368 +msgid "workspace.shape.menu.group" +msgstr "그룹" + +#: src/app/main/ui/workspace/context_menu.cljs:477, +#: src/app/main/ui/workspace/sidebar/layer_item.cljs:172 +msgid "workspace.shape.menu.hide" +msgstr "숨기기" + +#: src/app/main/ui/workspace/context_menu.cljs:706, +#: src/app/main/ui/workspace/main_menu.cljs:448 +msgid "workspace.shape.menu.hide-ui" +msgstr "UI 표시/숨기기" + +#: src/app/main/ui/workspace/context_menu.cljs:450, +#: src/app/main/ui/workspace/sidebar/options/menus/bool.cljs:93 +msgid "workspace.shape.menu.intersection" +msgstr "교차" + +#: src/app/main/ui/workspace/context_menu.cljs:485, +#: src/app/main/ui/workspace/sidebar/layer_item.cljs:180, +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:268 +msgid "workspace.shape.menu.lock" +msgstr "잠금" + +#: src/app/main/ui/workspace/context_menu.cljs:373 +msgid "workspace.shape.menu.mask" +msgstr "마스크" + +#: src/app/main/ui/workspace/context_menu.cljs:209, +#: src/app/main/ui/workspace/context_menu.cljs:703 +msgid "workspace.shape.menu.paste" +msgstr "붙여넣기" + +#: src/app/main/ui/workspace/context_menu.cljs:234 +msgid "workspace.shape.menu.paste-props" +msgstr "속성 붙여넣기" + +#: src/app/main/ui/workspace/context_menu.cljs:443 +msgid "workspace.shape.menu.path" +msgstr "경로" + +#: src/app/main/ui/workspace/context_menu.cljs:549 +msgid "workspace.shape.menu.remove-flex" +msgstr "플렉스 레이아웃 제거" + +#: src/app/main/ui/workspace/context_menu.cljs:552 +msgid "workspace.shape.menu.remove-grid" +msgstr "그리드 레이아웃 제거" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1266 +msgid "workspace.shape.menu.remove-layout" +msgstr "레이아웃 제거" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1157 +msgid "workspace.shape.menu.remove-variant-property" +msgstr "속성 제거" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1156 +msgid "workspace.shape.menu.remove-variant-property.last-property" +msgstr "베리언트는 최소한 하나의 속성을 가져야 합니다" + +#: src/app/main/ui/workspace/context_menu.cljs:329 +msgid "workspace.shape.menu.rename" +msgstr "이름 바꾸기" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:493 +msgid "workspace.shape.menu.reset-overrides" +msgstr "오버라이드 초기화" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:499 +msgid "workspace.shape.menu.restore-main" +msgstr "메인 컴포넌트 복원" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:498 +msgid "workspace.shape.menu.restore-variant" +msgstr "베리언트 복원" + +#: src/app/main/ui/workspace/context_menu.cljs:263 +msgid "workspace.shape.menu.select-layer" +msgstr "레이어 선택" + +#: src/app/main/ui/workspace/context_menu.cljs:474, +#: src/app/main/ui/workspace/sidebar/layer_item.cljs:171 +msgid "workspace.shape.menu.show" +msgstr "표시" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:481, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1217 +msgid "workspace.shape.menu.show-in-assets" +msgstr "에셋 패널에 표시" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:502, +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:629 +msgid "workspace.shape.menu.show-main" +msgstr "메인 컴포넌트 표시" + +#: src/app/main/ui/workspace/context_menu.cljs:314 +msgid "workspace.shape.menu.thumbnail-remove" +msgstr "썸네일 제거" + +#: src/app/main/ui/workspace/context_menu.cljs:316 +msgid "workspace.shape.menu.thumbnail-set" +msgstr "썸네일로 설정" + +#: src/app/main/ui/workspace/context_menu.cljs:436 +msgid "workspace.shape.menu.transform-to-path" +msgstr "경로로 변환" + +#: src/app/main/ui/workspace/context_menu.cljs:364 +msgid "workspace.shape.menu.ungroup" +msgstr "그룹 해제" + +#: src/app/main/ui/workspace/context_menu.cljs:444, +#: src/app/main/ui/workspace/sidebar/options/menus/bool.cljs:83 +msgid "workspace.shape.menu.union" +msgstr "합집합" + +#: src/app/main/ui/workspace/context_menu.cljs:482, +#: src/app/main/ui/workspace/sidebar/layer_item.cljs:179, +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:274 +msgid "workspace.shape.menu.unlock" +msgstr "잠금 해제" + +#: src/app/main/ui/workspace/context_menu.cljs:378 +msgid "workspace.shape.menu.unmask" +msgstr "마스크 해제" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs, +#: src/app/main/ui/workspace/context_menu.cljs, +#: src/app/main/ui/workspace/context_menu.cljs +msgid "workspace.shape.menu.update-components-in-bulk" +msgstr "메인 컴포넌트 업데이트" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:505 +msgid "workspace.shape.menu.update-main" +msgstr "메인 컴포넌트 업데이트" + +#: src/app/main/ui/components/tab_container.cljs:52, +#: src/app/main/ui/workspace/sidebar.cljs:62 +msgid "workspace.sidebar.collapse" +msgstr "사이드바 접기" + +#: src/app/main/ui/workspace/sidebar.cljs:73, +#: src/app/main/ui/workspace/sidebar.cljs:77 +msgid "workspace.sidebar.expand" +msgstr "사이드바 펼치기" + +#: src/app/main/ui/workspace/right_header.cljs:226 +msgid "workspace.sidebar.history" +msgstr "히스토리" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:509, +#: src/app/main/ui/workspace/sidebar.cljs:154, +#: src/app/main/ui/workspace/sidebar.cljs:157, +#: src/app/main/ui/workspace/sidebar.cljs:164 +msgid "workspace.sidebar.layers" +msgstr "레이어" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:313, +#: src/app/main/ui/workspace/sidebar/layers.cljs:374 +msgid "workspace.sidebar.layers.components" +msgstr "컴포넌트" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:297 +msgid "workspace.sidebar.layers.filter" +msgstr "필터" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:310, +#: src/app/main/ui/workspace/sidebar/layers.cljs:338 +msgid "workspace.sidebar.layers.frames" +msgstr "보드" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:311, +#: src/app/main/ui/workspace/sidebar/layers.cljs:350 +msgid "workspace.sidebar.layers.groups" +msgstr "그룹" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:315, +#: src/app/main/ui/workspace/sidebar/layers.cljs:398 +msgid "workspace.sidebar.layers.images" +msgstr "이미지" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:312, +#: src/app/main/ui/workspace/sidebar/layers.cljs:362 +msgid "workspace.sidebar.layers.masks" +msgstr "마스크" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:293 +msgid "workspace.sidebar.layers.search" +msgstr "레이어 검색" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:316, +#: src/app/main/ui/workspace/sidebar/layers.cljs:410 +msgid "workspace.sidebar.layers.shapes" +msgstr "도형" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:314, +#: src/app/main/ui/workspace/sidebar/layers.cljs:386 +msgid "workspace.sidebar.layers.texts" +msgstr "텍스트" + +#: src/app/main/ui/inspect/attributes/svg.cljs:56, +#: src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs:101 +msgid "workspace.sidebar.options.svg-attrs.title" +msgstr "가져온 SVG 속성" + +#: src/app/main/ui/workspace/sidebar/sitemap.cljs:264 +msgid "workspace.sidebar.sitemap" +msgstr "페이지" + +#: src/app/main/ui/workspace/sidebar/sitemap.cljs:274 +msgid "workspace.sidebar.sitemap.add-page" +msgstr "페이지 추가" + +#: src/app/main/ui/workspace/left_header.cljs:98 +msgid "workspace.sitemap" +msgstr "사이트맵" + +#: src/app/main/ui/workspace/tokens/themes/theme_selector.cljs:86 +msgid "workspace.tokens.active-themes" +msgstr "활성 테마 %s개" + +#: src/app/main/ui/workspace/tokens/sidebar.cljs +msgid "workspace.tokens.add set" +msgstr "세트 추가" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:66, +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:151, +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:347 +msgid "workspace.tokens.add-new-theme" +msgstr "새 테마 추가" + +#: src/app/main/ui/workspace/tokens/sets/context_menu.cljs:62 +msgid "workspace.tokens.add-set-to-group" +msgstr "이 그룹에 세트 추가" + +#: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:196, +#: src/app/main/ui/workspace/tokens/management/group.cljs:156 +msgid "workspace.tokens.add-token" +msgstr "token 추가: %s" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:137 +msgid "workspace.tokens.applied-to" +msgstr "적용됨" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:330 +msgid "workspace.tokens.axis" +msgstr "축" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:356 +msgid "workspace.tokens.back-to-themes" +msgstr "테마 목록으로" + +#: src/app/main/ui/workspace/tokens/settings/menu.cljs:89 +msgid "workspace.tokens.base-font-size" +msgstr "기본 글꼴 크기" + +#: src/app/main/ui/workspace/tokens/settings/menu.cljs:43 +msgid "workspace.tokens.base-font-size.error" +msgstr "기본 글꼴 크기는 픽셀 또는 단위가 없는 값이어야 합니다." + +#: src/app/main/ui/workspace/tokens/modals/import.cljs:127 +msgid "workspace.tokens.choose-file" +msgstr "파일 선택" + +#: src/app/main/ui/workspace/tokens/modals/import.cljs:132 +msgid "workspace.tokens.choose-folder" +msgstr "폴더 선택" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:299, +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:130 +msgid "workspace.tokens.color" +msgstr "색상" + +#: src/app/main/data/workspace/tokens/errors.cljs:101, +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:251 +msgid "workspace.tokens.composite-line-height-needs-font-size" +msgstr "" +"행간은 글꼴 크기에 따라 달라집니다. 확인된 값을 얻으려면 글꼴 크기를 추가하세" +"요." + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:57 +msgid "workspace.tokens.create-new-theme" +msgstr "첫 번째 테마를 만들어 보세요." + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:96, +#: src/app/main/ui/workspace/tokens/themes.cljs:44 +msgid "workspace.tokens.create-one" +msgstr "하나 생성하기." + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:235 +msgid "workspace.tokens.create-token" +msgstr "새 %s token 생성" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:353 +msgid "workspace.tokens.delete" +msgstr "token 삭제" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:140 +msgid "workspace.tokens.delete-theme-title" +msgstr "테마 삭제" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:350 +msgid "workspace.tokens.duplicate" +msgstr "token 복제" + +#: src/app/main/data/workspace/tokens/library_edit.cljs:240, +#: src/app/main/data/workspace/tokens/library_edit.cljs:509 +msgid "workspace.tokens.duplicate-suffix" +msgstr "복사" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:337 +msgid "workspace.tokens.edit" +msgstr "token 편집" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:346 +msgid "workspace.tokens.edit-theme-title" +msgstr "테마 편집" + +#: src/app/main/ui/workspace/tokens/themes/theme_selector.cljs:74 +msgid "workspace.tokens.edit-themes" +msgstr "테마 편집" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:234 +msgid "workspace.tokens.edit-token" +msgstr "%s token 편집" + +#: src/app/main/data/workspace/tokens/errors.cljs:41 +msgid "workspace.tokens.empty-input" +msgstr "token 값은 비워둘 수 없습니다" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 +msgid "workspace.tokens.enter-token-name" +msgstr "%s token 이름 입력" + +#: src/app/main/data/workspace/tokens/errors.cljs:15 +msgid "workspace.tokens.error-parse" +msgstr "가져오기 오류: JSON을 구문 분석할 수 없습니다." + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:49 +msgid "workspace.tokens.export" +msgstr "내보내기" + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:125 +msgid "workspace.tokens.export-tokens" +msgstr "token 내보내기" + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:118 +msgid "workspace.tokens.export.multiple-files" +msgstr "여러 파일" + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:38 +msgid "workspace.tokens.export.no-tokens-themes-sets" +msgstr "내보낼 token, 테마 또는 세트가 없습니다." + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:35 +msgid "workspace.tokens.export.preview" +msgstr "미리보기:" + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:116 +msgid "workspace.tokens.export.single-file" +msgstr "단일 파일" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:129 +msgid "workspace.tokens.font-size-value-enter" +msgstr "글꼴 크기 또는 {alias} 입력" + +#: src/app/main/data/workspace/tokens/application.cljs:325 +msgid "workspace.tokens.font-variant-not-found" +msgstr "글꼴 굵기/스타일 설정 오류. 현재 글꼴에 이 스타일이 없습니다" + +#: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:42, +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:137 +msgid "workspace.tokens.font-weight-value-enter" +msgstr "글꼴 굵기(300, Bold Italic 등) 또는 {alias} 입력" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:240 +msgid "workspace.tokens.gaps" +msgstr "간격" + +#: src/app/main/ui/workspace/tokens/style_dictionary.cljs +msgid "workspace.tokens.generic-error" +msgstr "오류: " + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:93 +msgid "workspace.tokens.group-name" +msgstr "그룹 이름" + +#: src/app/main/ui/workspace/tokens/sets.cljs +msgid "workspace.tokens.grouping-set-alert" +msgstr "token 세트 그룹화는 아직 지원되지 않습니다." + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:233 +msgid "workspace.tokens.import-button-prefix" +msgstr "%s 가져오기" + +#: src/app/main/data/workspace/tokens/errors.cljs:32, +#: src/app/main/data/workspace/tokens/errors.cljs:37 +msgid "workspace.tokens.import-error" +msgstr "가져오기 오류:" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:273 +msgid "workspace.tokens.import-menu-folder-option" +msgstr "폴더" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:271 +msgid "workspace.tokens.import-menu-json-option" +msgstr "단일 JSON 파일" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:272 +msgid "workspace.tokens.import-menu-zip-option" +msgstr "ZIP 파일" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:241 +msgid "workspace.tokens.import-multiple-files" +msgstr "여러 파일의 경우 파일 이름/경로가 세트 이름입니다." + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:240 +msgid "workspace.tokens.import-single-file" +msgstr "단일 JSON 파일에서 최상위 키는 token 세트 이름이어야 합니다." + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:237 +msgid "workspace.tokens.import-tokens" +msgstr "token 가져오기" + +#: src/app/main/ui/workspace/tokens/sidebar.cljs:414, +#: src/app/main/ui/workspace/tokens/sidebar.cljs:415 +msgid "workspace.tokens.import-tooltip" +msgstr "JSON 파일을 가져오면 현재 token, 세트 및 테마가 모두 교체됩니다" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:247 +msgid "workspace.tokens.import-warning" +msgstr "token을 가져오면 현재 token, 세트 및 테마가 모두 덮어써집니다." + +#: src/app/main/ui/workspace/tokens/management.cljs:78 +msgid "workspace.tokens.inactive-set" +msgstr "비활성" + +#: src/app/main/ui/workspace/tokens/management.cljs:70 +msgid "workspace.tokens.inactive-set-description" +msgstr "" +"이 세트가 활성화되지 않았습니다. 테마를 변경하거나 이 세트를 활성화하여 변경 " +"사항을 확인하세요" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:240, +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:195 +msgid "workspace.tokens.individual-tokens" +msgstr "개별 token 사용" + +#: src/app/main/data/workspace/tokens/errors.cljs:49 +msgid "workspace.tokens.invalid-color" +msgstr "유효하지 않은 색상 값: %s" + +#: src/app/main/data/workspace/tokens/errors.cljs:93 +msgid "workspace.tokens.invalid-font-family-token-value" +msgstr "잘못된 token 값: 글꼴 모음 token만 참조할 수 있습니다" + +#: src/app/main/data/workspace/tokens/errors.cljs:89 +msgid "workspace.tokens.invalid-font-weight-token-value" +msgstr "" +"유효하지 않은 글꼴 굵기 값입니다. 숫자 값(100~950) 또는 표준 이름(thin, " +"light, regular, bold 등)을 사용하세요. 필요하면 뒤에 'Italic'을 붙일 수 있습" +"니다" + +#: src/app/main/data/workspace/tokens/errors.cljs:23 +msgid "workspace.tokens.invalid-json" +msgstr "가져오기 오류: JSON 내 token 데이터가 유효하지 않습니다." + +#: src/app/main/data/workspace/tokens/errors.cljs:27 +msgid "workspace.tokens.invalid-json-token-name" +msgstr "가져오기 오류: JSON 내 token 이름이 유효하지 않습니다." + +#: src/app/main/data/workspace/tokens/errors.cljs:28 +msgid "workspace.tokens.invalid-json-token-name-detail" +msgstr "" +"\"%s\"은(는) 유효한 token 이름이 아닙니다.\n" +"token 이름은 .으로 구분된 문자와 숫자만 포함할 수 있으며 $ 기호로 시작할 수 " +"없습니다." + +#: src/app/main/data/workspace/tokens/errors.cljs:105 +msgid "workspace.tokens.invalid-shadow-type-token-value" +msgstr "" +"유효하지 않은 그림자 유형: '내부 그림자' 또는 '그림자 효과'만 가능합니다" + +#: src/app/main/data/workspace/tokens/errors.cljs:81 +msgid "workspace.tokens.invalid-text-case-token-value" +msgstr "잘못된 token 값: none, Uppercase, Lowercase, Capitalize만 가능합니다" + +#: src/app/main/data/workspace/tokens/errors.cljs:85 +msgid "workspace.tokens.invalid-text-decoration-token-value" +msgstr "잘못된 token 값: none, underline, strike-through만 가능합니다" + +#: src/app/main/data/workspace/tokens/errors.cljs:117 +msgid "workspace.tokens.invalid-token-value-shadow" +msgstr "잘못된 값: 복합 그림자 token을 참조해야 합니다." + +#: src/app/main/data/workspace/tokens/errors.cljs:97 +msgid "workspace.tokens.invalid-token-value-typography" +msgstr "잘못된 값: 복합 타이포그래피 token을 참조해야 합니다." + +#: src/app/main/data/workspace/tokens/errors.cljs:61, +#: src/app/main/data/workspace/tokens/errors.cljs:73, +#: src/app/main/data/workspace/tokens/errors.cljs:77 +msgid "workspace.tokens.invalid-value" +msgstr "잘못된 token 값: %s" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 +msgid "workspace.tokens.label.group" +msgstr "그룹" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:207 +msgid "workspace.tokens.label.group-placeholder" +msgstr "그룹 추가 (예: 모드)" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:214 +msgid "workspace.tokens.label.theme" +msgstr "테마" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:215 +msgid "workspace.tokens.label.theme-placeholder" +msgstr "테마 추가 (예: 라이트)" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:153 +msgid "workspace.tokens.letter-spacing-value-enter-composite" +msgstr "자간 또는 {alias} 입력" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:145 +msgid "workspace.tokens.line-height-value-enter" +msgstr "행간(배수, px, %) 또는 {alias} 입력" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:232 +msgid "workspace.tokens.margins" +msgstr "마진" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:268 +msgid "workspace.tokens.max-size" +msgstr "최대 크기" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:262 +msgid "workspace.tokens.min-size" +msgstr "최소 크기" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:303 +msgid "workspace.tokens.missing-reference" +msgstr "누락된 참조" + +#: src/app/main/data/workspace/tokens/errors.cljs:57 +msgid "workspace.tokens.missing-references" +msgstr "누락된 token 참조: " + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 +msgid "workspace.tokens.more-options" +msgstr "우클릭으로 옵션 보기" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:135 +msgid "workspace.tokens.no-active-sets" +msgstr "활성 세트 없음" + +#: src/app/main/ui/workspace/tokens/themes/theme_selector.cljs:91 +msgid "workspace.tokens.no-active-theme" +msgstr "활성 테마 없음" + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:72 +msgid "workspace.tokens.no-permisions-set" +msgstr "세트를 활성화 / 비활성화하려면 편집자여야 합니다" + +#: src/app/main/ui/workspace/tokens/themes.cljs:54 +msgid "workspace.tokens.no-permission-themes" +msgstr "테마를 사용하려면 편집자여야 합니다" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:87 +msgid "workspace.tokens.no-references-found" +msgstr "참조 없음" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs +msgid "workspace.tokens.no-remap-needed" +msgstr "이 token은 현재 디자인에서 사용되지 않으므로 재매핑이 필요 없습니다." + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:485 +msgid "workspace.tokens.no-sets-create" +msgstr "아직 정의된 세트가 없습니다. 먼저 하나를 만드세요." + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:93, +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:99 +msgid "workspace.tokens.no-sets-yet" +msgstr "아직 세트가 없습니다." + +#: src/app/main/ui/workspace/tokens/themes.cljs:40 +msgid "workspace.tokens.no-themes" +msgstr "테마가 없습니다." + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:53 +msgid "workspace.tokens.no-themes-currently" +msgstr "현재 테마가 없습니다." + +#: src/app/main/data/workspace/tokens/errors.cljs:19 +msgid "workspace.tokens.no-token-files-found" +msgstr "이 파일에서 token, 세트 또는 테마를 찾을 수 없습니다." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:95 +msgid "workspace.tokens.not-remap" +msgstr "재매핑 안 함" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:134 +msgid "workspace.tokens.num-active-sets" +msgstr "활성 세트 %s개" + +#: src/app/main/data/workspace/tokens/errors.cljs:53 +msgid "workspace.tokens.number-too-large" +msgstr "잘못된 token 값. 확인된 값이 너무 큽니다: %s" + +#: src/app/main/data/workspace/tokens/errors.cljs:73, +#: src/app/main/data/workspace/tokens/warnings.cljs:15 +msgid "workspace.tokens.opacity-range" +msgstr "불투명도는 0~100% 또는 0~1 사이의 값이어야 합니다(예: 50% 또는 0.5)." + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:121 +msgid "workspace.tokens.original-value" +msgstr "원래 값: %s" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:216 +msgid "workspace.tokens.paddings" +msgstr "패딩" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:292 +msgid "workspace.tokens.radius" +msgstr "반지름" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:129 +msgid "workspace.tokens.ref-not-valid" +msgstr "참조가 유효하지 않거나 활성 세트에 없습니다" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:178 +msgid "workspace.tokens.reference-composite" +msgstr "타이포그래피 token 별칭 입력" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:204 +msgid "workspace.tokens.reference-composite-shadow" +msgstr "그림자 token 별칭 입력" + +#: src/app/main/ui/workspace/tokens/style_dictionary.cljs +msgid "workspace.tokens.reference-error" +msgstr "참조 오류: " + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:99 +msgid "workspace.tokens.remap" +msgstr "토큰 재매핑" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:86 +msgid "workspace.tokens.remap-token-references-title" +msgstr "`%s`을 사용하는 모든 토큰을 `%s`로 재매핑하시겠습니까?" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:88 +msgid "workspace.tokens.remap-warning-effects" +msgstr "이 작업은 이전 token 이름을 사용하는 모든 레이어와 참조를 변경합니다." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:89 +msgid "workspace.tokens.remap-warning-time" +msgstr "이 작업은 시간이 조금 걸릴 수 있습니다." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:92 +msgid "workspace.tokens.remapping-in-progress" +msgstr "token 참조 재매핑 중..." + +#: src/app/main/data/workspace/tokens/warnings.cljs:15, +#: src/app/main/data/workspace/tokens/warnings.cljs:19, +#: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:56, +#: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:84, +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:103, +#: src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:285, +#: src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:459, +#: src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:176, +#: src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:311, +#: src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:251, +#: src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:364, +#: src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:465, +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:122 +msgid "workspace.tokens.resolved-value" +msgstr "확인된 값: %s" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:251 +msgid "workspace.tokens.save-theme" +msgstr "테마 저장" + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:204, +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:309 +msgid "workspace.tokens.select-set" +msgstr "세트 선택." + +#: src/app/main/data/workspace/tokens/errors.cljs:45, +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 +msgid "workspace.tokens.self-reference" +msgstr "token이 자기 자신을 참조하고 있습니다" + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 +msgid "workspace.tokens.set-edit-placeholder" +msgstr "이름 입력 (그룹화하려면 '/' 사용)" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:361 +msgid "workspace.tokens.set-selection-theme" +msgstr "이 테마에 포함할 token 세트를 정의하세요:" + +#: src/app/main/ui/workspace/tokens/token_pill.cljs:47 +msgid "workspace.tokens.set.not-active" +msgstr "token 세트가 활성화되지 않았습니다" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:129 +msgid "workspace.tokens.sets-hint" +msgstr "테마 편집 및 세트 관리" + +#: src/app/main/ui/workspace/tokens/settings/menu.cljs:91 +msgid "workspace.tokens.setting-description" +msgstr "여기서 1rem의 값을 정의하는 기본 글꼴 크기를 설정할 수 있습니다:" + +#: src/app/main/ui/workspace/tokens/settings/menu.cljs:84 +msgid "workspace.tokens.settings" +msgstr "token 설정" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:232 +msgid "workspace.tokens.shadow-add-shadow" +msgstr "그림자 추가" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:161, +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:162, +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:165 +msgid "workspace.tokens.shadow-blur" +msgstr "블러" + +#: src/app/main/data/workspace/tokens/errors.cljs:109 +msgid "workspace.tokens.shadow-blur-range" +msgstr "그림자 블러는 0 이상이어야 합니다." + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:987, +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:988 +msgid "workspace.tokens.shadow-color" +msgstr "색상" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:114 +msgid "workspace.tokens.shadow-inset" +msgstr "내부" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:123 +msgid "workspace.tokens.shadow-remove-shadow" +msgstr "그림자 제거" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:173, +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:174, +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:177 +msgid "workspace.tokens.shadow-spread" +msgstr "확산" + +#: src/app/main/data/workspace/tokens/errors.cljs:113 +msgid "workspace.tokens.shadow-spread-range" +msgstr "그림자 확산은 0 이상이어야 합니다." + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:1215 +msgid "workspace.tokens.shadow-title" +msgstr "그림자" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:281 +msgid "workspace.tokens.shadow-token-blur-value-error" +msgstr "블러 값은 음수일 수 없습니다" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:287 +msgid "workspace.tokens.shadow-token-spread-value-error" +msgstr "확산 값은 음수일 수 없습니다" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:139, +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:141 +msgid "workspace.tokens.shadow-x" +msgstr "X" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:150, +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:152 +msgid "workspace.tokens.shadow-y" +msgstr "Y" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:256 +msgid "workspace.tokens.size" +msgstr "크기" + +#: src/app/main/data/workspace/tokens/errors.cljs:77, +#: src/app/main/data/workspace/tokens/warnings.cljs:19 +msgid "workspace.tokens.stroke-width-range" +msgstr "선 두께는 0 이상이어야 합니다." + +#: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:40, +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:161 +msgid "workspace.tokens.text-case-value-enter" +msgstr "없음 | 대문자 | 소문자 | 각 단어 첫 글자 대문자 또는 {alias}" + +#: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:41, +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:169 +msgid "workspace.tokens.text-decoration-value-enter" +msgstr "없음 | 밑줄 | 취소선 또는 {alias}" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:154 +msgid "workspace.tokens.theme-name" +msgstr "테마 %s" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:51 +msgid "workspace.tokens.theme-name-already-exists" +msgstr "이미 같은 이름의 테마가 있습니다" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:100 +msgid "workspace.tokens.theme.disable" +msgstr "비활성화" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:96 +msgid "workspace.tokens.theme.enable" +msgstr "활성화" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:85 +msgid "workspace.tokens.themes-description" +msgstr "" +"여기서 테마를 관리하고, 활성화 / 비활성화하고, 활성 세트를 설정할 수 있습니" +"다." + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:49, +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:83 +msgid "workspace.tokens.themes-list" +msgstr "테마 목록" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:275, +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:276 +msgid "workspace.tokens.token-description" +msgstr "설명" + +#: src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:122, +#: src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:246 +msgid "workspace.tokens.token-font-family-select" +msgstr "글꼴 모음 선택" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:121 +msgid "workspace.tokens.token-font-family-value" +msgstr "글꼴 모음" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:120 +msgid "workspace.tokens.token-font-family-value-enter" +msgstr "글꼴 모음 또는 쉼표(,)로 구분된 글꼴 목록" + +#: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:83, +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:112, +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:240 +msgid "workspace.tokens.token-name" +msgstr "이름" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:268, +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:223 +msgid "workspace.tokens.token-name-duplication-validation-error" +msgstr "해당 경로에 이미 token이 있습니다: %s" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:265, +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:220 +msgid "workspace.tokens.token-name-length-validation-error" +msgstr "이름은 최소 1자 이상이어야 합니다" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:267, +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:222 +msgid "workspace.tokens.token-name-validation-error" +msgstr "" +" 은(는) 유효한 token 이름이 아닙니다.\n" +"token 이름은 .으로 구분된 문자와 숫자만 포함할 수 있으며 $ 기호로 시작할 수 " +"없습니다." + +#: src/app/main/ui/workspace/tokens/style_dictionary.cljs:259 +msgid "workspace.tokens.token-not-resolved" +msgstr "이름으로 참조 token을 확인할 수 없습니다: %s" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:267 +msgid "workspace.tokens.token-value" +msgstr "값" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:266, +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:129 +msgid "workspace.tokens.token-value-enter" +msgstr "값 또는 {alias}를 사용한 별칭을 입력하세요" + +#: src/app/main/ui/workspace/tokens/management.cljs:67 +msgid "workspace.tokens.tokens-section-title" +msgstr "token - %s" + +#: src/app/main/ui/workspace/tokens/sidebar.cljs:122 +msgid "workspace.tokens.tools" +msgstr "도구" + +#: src/app/main/data/workspace/tokens/import_export.cljs:46 +msgid "workspace.tokens.unknown-token-type-message" +msgstr "" +"가져오기가 성공적으로 완료되었습니다. 일부 token이 포함되지 않았습니다." + +#: src/app/main/data/workspace/tokens/import_export.cljs:48 +msgid "workspace.tokens.unknown-token-type-section" +msgstr "유형 '%s'가 지원되지 않습니다 (%s)\n" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:244, +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:199 +msgid "workspace.tokens.use-reference" +msgstr "참조 사용" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:132 +msgid "workspace.tokens.value-not-valid" +msgstr "값이 유효하지 않습니다" + +#: src/app/main/data/workspace/tokens/errors.cljs:69 +msgid "workspace.tokens.value-with-percent" +msgstr "잘못된 값: %가 허용되지 않습니다." + +#: src/app/main/data/workspace/tokens/errors.cljs:65 +msgid "workspace.tokens.value-with-units" +msgstr "잘못된 값: 단위가 허용되지 않습니다." + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs +msgid "workspace.tokens.warning-name-change" +msgstr "이 token의 이름을 바꾸면 기존 이름에 대한 참조가 끊어집니다" + +#: src/app/main/ui/workspace/sidebar.cljs:159, +#: src/app/main/ui/workspace/sidebar.cljs:166 +msgid "workspace.toolbar.assets" +msgstr "에셋" + +#: src/app/main/ui/workspace/palette.cljs:184 +msgid "workspace.toolbar.color-palette" +msgstr "색상 팔레트 (%s)" + +#: src/app/main/ui/workspace/right_header.cljs:217 +msgid "workspace.toolbar.comments" +msgstr "댓글 (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:195 +msgid "workspace.toolbar.curve" +msgstr "곡선 (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:231 +msgid "workspace.toolbar.debug" +msgstr "디버깅 도구" + +#: src/app/main/ui/workspace/top_toolbar.cljs:172 +msgid "workspace.toolbar.ellipse" +msgstr "타원 (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:130 +msgid "workspace.toolbar.frame" +msgstr "보드 (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:129 +msgid "workspace.toolbar.frame-first-time" +msgstr "보드를 만듭니다. 클릭하고 드래그하여 크기를 정의합니다. (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:60 +msgid "workspace.toolbar.image" +msgstr "이미지 (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:143 +msgid "workspace.toolbar.move" +msgstr "이동 (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:205 +msgid "workspace.toolbar.path" +msgstr "경로 (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:216 +msgid "workspace.toolbar.plugins" +msgstr "플러그인 (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:162 +msgid "workspace.toolbar.rect" +msgstr "직사각형 (%s)" + +#: src/app/main/ui/workspace/left_toolbar.cljs +msgid "workspace.toolbar.shortcuts" +msgstr "단축키 (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:182 +msgid "workspace.toolbar.text" +msgstr "텍스트 (%s)" + +#: src/app/main/ui/workspace/palette.cljs:190 +msgid "workspace.toolbar.text-palette" +msgstr "타이포그래피 (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:235, +#: src/app/main/ui/workspace/top_toolbar.cljs:236 +msgid "workspace.toolbar.toggle-toolbar" +msgstr "도구 모음 켜기/끄기" + +#: src/app/main/ui/workspace/viewport/top_bar.cljs:41 +msgid "workspace.top-bar.read-only.done" +msgstr "완료" + +#: src/app/main/ui/workspace/viewport/top_bar.cljs:37 +msgid "workspace.top-bar.view-only" +msgstr "**코드 검사 중** (보기 전용)" + +#: src/app/main/ui/workspace/sidebar/history.cljs:333 +msgid "workspace.undo.empty" +msgstr "아직 기록 변경사항이 없습니다" + +#: src/app/main/ui/workspace/sidebar/history.cljs:147 +msgid "workspace.undo.entry.delete" +msgstr "삭제됨 %s" + +#: src/app/main/ui/workspace/sidebar/history.cljs:146 +msgid "workspace.undo.entry.modify" +msgstr "수정됨 %s" + +#: src/app/main/ui/workspace/sidebar/history.cljs:148 +msgid "workspace.undo.entry.move" +msgstr "객체 이동됨" + +#: src/app/main/ui/workspace/sidebar/history.cljs:111 +msgid "workspace.undo.entry.multiple.circle" +msgstr "원" + +#: src/app/main/ui/workspace/sidebar/history.cljs:112 +msgid "workspace.undo.entry.multiple.color" +msgstr "색상 에셋" + +#: src/app/main/ui/workspace/sidebar/history.cljs:113 +msgid "workspace.undo.entry.multiple.component" +msgstr "컴포넌트" + +#: src/app/main/ui/workspace/sidebar/history.cljs:114 +msgid "workspace.undo.entry.multiple.curve" +msgstr "곡선" + +#: src/app/main/ui/workspace/sidebar/history.cljs:115 +msgid "workspace.undo.entry.multiple.frame" +msgstr "보드" + +#: src/app/main/ui/workspace/sidebar/history.cljs:116 +msgid "workspace.undo.entry.multiple.group" +msgstr "그룹" + +#: src/app/main/ui/workspace/sidebar/history.cljs:117 +msgid "workspace.undo.entry.multiple.media" +msgstr "그래픽 에셋" + +#: src/app/main/ui/workspace/sidebar/history.cljs:118 +msgid "workspace.undo.entry.multiple.multiple" +msgstr "객체" + +#: src/app/main/ui/workspace/sidebar/history.cljs:119 +msgid "workspace.undo.entry.multiple.page" +msgstr "페이지" + +#: src/app/main/ui/workspace/sidebar/history.cljs:120 +msgid "workspace.undo.entry.multiple.path" +msgstr "경로" + +#: src/app/main/ui/workspace/sidebar/history.cljs:121 +msgid "workspace.undo.entry.multiple.rect" +msgstr "직사각형" + +#: src/app/main/ui/workspace/sidebar/history.cljs:122 +msgid "workspace.undo.entry.multiple.shape" +msgstr "도형" + +#: src/app/main/ui/workspace/sidebar/history.cljs:123 +msgid "workspace.undo.entry.multiple.text" +msgstr "텍스트" + +#: src/app/main/ui/workspace/sidebar/history.cljs:124 +msgid "workspace.undo.entry.multiple.typography" +msgstr "타이포그래피 에셋" + +#: src/app/main/ui/workspace/sidebar/history.cljs:145 +msgid "workspace.undo.entry.new" +msgstr "새 %s" + +#: src/app/main/ui/workspace/sidebar/history.cljs:125 +msgid "workspace.undo.entry.single.circle" +msgstr "원" + +#: src/app/main/ui/workspace/sidebar/history.cljs:126 +msgid "workspace.undo.entry.single.color" +msgstr "색상 에셋" + +#: src/app/main/ui/workspace/sidebar/history.cljs:127 +msgid "workspace.undo.entry.single.component" +msgstr "컴포넌트" + +#: src/app/main/ui/workspace/sidebar/history.cljs:128 +msgid "workspace.undo.entry.single.curve" +msgstr "곡선" + +#: src/app/main/ui/workspace/sidebar/history.cljs:129 +msgid "workspace.undo.entry.single.frame" +msgstr "보드" + +#: src/app/main/ui/workspace/sidebar/history.cljs:130 +msgid "workspace.undo.entry.single.group" +msgstr "그룹" + +#: src/app/main/ui/workspace/sidebar/history.cljs:131 +msgid "workspace.undo.entry.single.image" +msgstr "이미지" + +#: src/app/main/ui/workspace/sidebar/history.cljs:132 +msgid "workspace.undo.entry.single.media" +msgstr "그래픽 에셋" + +#: src/app/main/ui/workspace/sidebar/history.cljs:133 +msgid "workspace.undo.entry.single.multiple" +msgstr "객체" + +#: src/app/main/ui/workspace/sidebar/history.cljs:134 +msgid "workspace.undo.entry.single.page" +msgstr "페이지" + +#: src/app/main/ui/workspace/sidebar/history.cljs:135 +msgid "workspace.undo.entry.single.path" +msgstr "경로" + +#: src/app/main/ui/workspace/sidebar/history.cljs:136 +msgid "workspace.undo.entry.single.rect" +msgstr "직사각형" + +#: src/app/main/ui/workspace/sidebar/history.cljs:137 +msgid "workspace.undo.entry.single.shape" +msgstr "도형" + +#: src/app/main/ui/workspace/sidebar/history.cljs:138 +msgid "workspace.undo.entry.single.text" +msgstr "텍스트" + +#: src/app/main/ui/workspace/sidebar/history.cljs:139 +msgid "workspace.undo.entry.single.typography" +msgstr "타이포그래피 에셋" + +#: src/app/main/ui/workspace/sidebar/history.cljs:149 +msgid "workspace.undo.entry.unknown" +msgstr "%s에 대한 작업" + +#: src/app/main/ui/workspace/sidebar/history.cljs:335 +msgid "workspace.undo.title" +msgstr "히스토리" + +#: src/app/main/data/workspace/libraries.cljs:1247, +#: src/app/main/ui/workspace/sidebar/versions.cljs:85 +msgid "workspace.updates.dismiss" +msgstr "해제" + +#: src/app/main/data/workspace/libraries.cljs:1245 +msgid "workspace.updates.more-info" +msgstr "자세히" + +#: src/app/main/data/workspace/libraries.cljs:1243 +msgid "workspace.updates.there-are-updates" +msgstr "공유 라이브러리에 업데이트가 있습니다" + +#: src/app/main/data/workspace/libraries.cljs:1249 +msgid "workspace.updates.update" +msgstr "업데이트" + +#: src/app/main/ui/ds/product/milestone_group.cljs:73 +msgid "workspace.versions.autosaved.entry" +msgstr "%s 자동 저장 버전" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:260 +msgid "workspace.versions.autosaved.version" +msgstr "자동 저장됨 %s" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:278 +msgid "workspace.versions.button.pin" +msgstr "버전 고정" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:273 +msgid "workspace.versions.button.restore" +msgstr "버전 복원" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:396, +#: src/app/main/ui/workspace/sidebar/versions.cljs:398 +msgid "workspace.versions.button.save" +msgstr "버전 저장" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:405 +msgid "workspace.versions.empty" +msgstr "아직 버전이 없습니다" + +#: src/app/main/ui/ds/product/milestone_group.cljs:67 +msgid "workspace.versions.expand-snapshot" +msgstr "스냅샷 확장" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:369 +msgid "workspace.versions.filter.all" +msgstr "모든 버전" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:383 +msgid "workspace.versions.filter.label" +msgstr "버전 필터" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:370 +msgid "workspace.versions.filter.mine" +msgstr "내 버전" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:374 +msgid "workspace.versions.filter.user" +msgstr "%s의 버전" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:391 +msgid "workspace.versions.loading" +msgstr "로드 중..." + +msgid "workspace.versions.locked-by-other" +msgstr "이 버전은 %s에 의해 잠겨있으며 수정할 수 없습니다" + +msgid "workspace.versions.locked-by-you" +msgstr "이 버전은 당신에 의해 잠겨있습니다" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:83 +msgid "workspace.versions.restore-warning" +msgstr "이 버전을 복원하시겠습니까?" + +msgid "workspace.versions.snapshot-menu" +msgstr "스냅샷 메뉴 열기" + +#: src/app/main/ui/workspace/sidebar.cljs:257 +msgid "workspace.versions.tab.actions" +msgstr "동작" + +#: src/app/main/ui/workspace/sidebar.cljs:255 +msgid "workspace.versions.tab.history" +msgstr "히스토리" + +msgid "workspace.versions.tooltip.locked-version" +msgstr "잠긴 버전 - 생성자만 수정할 수 있습니다" + +#: src/app/main/ui/ds/product/milestone.cljs:84, +#: src/app/main/ui/ds/product/milestone_group.cljs:86 +msgid "workspace.versions.version-menu" +msgstr "버전 메뉴 열기" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:60 +msgid "workspace.versions.warning.subtext" +msgstr "이 제한을 늘리고 싶으시면, [support@penpot.app](%s)으로 문의하세요" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:431 +msgid "workspace.versions.warning.text" +msgstr "자동 저장 버전은 %s일 동안 유지됩니다." + +msgid "workspace.viewport.click-to-close-path" +msgstr "경로를 닫으려면 클릭하세요" + +#: src/app/main/ui/inspect/styles/panels/tokens_panel.cljs:26 +msgid "inspect.tabs.styles.active-sets" +msgstr "활성 세트" + +#: src/app/main/ui/inspect/styles/panels/tokens_panel.cljs:21 +msgid "inspect.tabs.styles.active-themes" +msgstr "활성 테마" + +#: src/app/main/ui/dashboard/subscription.cljs:180 +msgid "subscription.dashboard.unlimited-members-extra-editors-cta-title" +msgstr "무제한 플랜 이용 중 사람 초대하기" diff --git a/frontend/translations/lv.po b/frontend/translations/lv.po index 4268d7a133..eebc97d0fb 100644 --- a/frontend/translations/lv.po +++ b/frontend/translations/lv.po @@ -1,16 +1,16 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-02-16 08:35+0000\n" -"Last-Translator: \"Ņikita K.\" \n" -"Language-Team: Latvian " -"\n" +"PO-Revision-Date: 2026-02-18 14:09+0000\n" +"Last-Translator: Edgars Andersons \n" +"Language-Team: Latvian \n" "Language: lv\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=3; plural=(n % 10 == 0 || n % 100 >= 11 && n % 100 " -"<= 19) ? 0 : ((n % 10 == 1 && n % 100 != 11) ? 1 : 2);\n" -"X-Generator: Weblate 5.16-dev\n" +"Plural-Forms: nplurals=3; plural=(n % 10 == 0 || n % 100 >= 11 && n % 100 <= " +"19) ? 0 : ((n % 10 == 1 && n % 100 != 11) ? 1 : 2);\n" +"X-Generator: Weblate 5.16.1-dev\n" #: src/app/main/ui/auth/register.cljs:215, src/app/main/ui/static.cljs:158, src/app/main/ui/viewer/login.cljs:100 msgid "auth.already-have-account" @@ -4622,11 +4622,11 @@ msgstr "" "\"Abonements\"." #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "Izbaudi savu plānu!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "Tu esi %s." #: src/app/main/ui/settings/subscription.cljs:526 @@ -6270,39 +6270,39 @@ msgstr "Papildu opcijas" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:686, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:693 msgid "workspace.options.layout-item.layout-item-max-h" -msgstr "Maks.augstums" +msgstr "Lielākais augstums" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:624, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:631 msgid "workspace.options.layout-item.layout-item-max-w" -msgstr "Maks.platums" +msgstr "Lielākais platums" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:655, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:663 msgid "workspace.options.layout-item.layout-item-min-h" -msgstr "Min.augstums" +msgstr "Mazākais augstums" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:591, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:600 msgid "workspace.options.layout-item.layout-item-min-w" -msgstr "Min.platums" +msgstr "Mazākais platums" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs #, unused msgid "workspace.options.layout-item.title.layout-item-max-h" -msgstr "Maksimālais augstums" +msgstr "Lielākais augstums" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs #, unused msgid "workspace.options.layout-item.title.layout-item-max-w" -msgstr "Maksimālais platums" +msgstr "Lielākais platums" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs #, unused msgid "workspace.options.layout-item.title.layout-item-min-h" -msgstr "Minimālais augstums" +msgstr "Mazākais augstums" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs #, unused msgid "workspace.options.layout-item.title.layout-item-min-w" -msgstr "Minimālais platums" +msgstr "Mazākais platums" #: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs #, unused @@ -7378,7 +7378,7 @@ msgid "workspace.tokens.edit-themes" msgstr "Labot izskatus" #: src/app/main/data/workspace/tokens/errors.cljs:41 -msgid "workspace.tokens.empty-input" +msgid "errors.tokens.empty-input" msgstr "Tekstvienības vērtība nevar būt tukša" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 @@ -7386,7 +7386,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "Jāievada %s tekstvienības nosaukums" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "Ievietošanas kļūda: nevarēja apstrādāt JSON." #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -7432,7 +7432,7 @@ msgid "workspace.tokens.grouping-set-alert" msgstr "Tekstvienību apkopošana vēl netiek nodrošināta." #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "Ievietošanas kļūda:" #: src/app/main/ui/workspace/tokens/import/modal.cljs:241 @@ -7473,26 +7473,26 @@ msgstr "" "redzētu izmaiņas" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "Nederīga krāsas vērtība: %s" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "Ievietošanas kļūda: JSON satur nederīgus tekstvienību datus." #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "Ievietošanas kļūda: nederīgs tekstvienības nosaukums JSON." #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "\"%s\" nav derīgs tekstvienības nosaukums.\n" "Tekstvienību nosaukumos var būt tikai burti un cipari, kas atdalīti ar " "rakstzīmi \".\", un tie nedrīkst sākties ar zīmi \"$\"." #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "Nederīga tekstvienības vērtība: %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -7524,7 +7524,7 @@ msgid "workspace.tokens.min-size" msgstr "Mazākais lielums" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "Trūkst tekstvienību atsauces: " #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:135 @@ -7564,11 +7564,11 @@ msgid "workspace.tokens.num-active-sets" msgstr "%s spēkā esošas kopas" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "Nederīga tekstvienības vērtība. Aprēķinātā vērtība ir pārāk liela: %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "Necaurspīdīgumam ir jābūt starp 0 un 100 % vai 0 un 1 (piem., 50% jeb 0.5)." #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:121 @@ -7607,7 +7607,7 @@ msgid "workspace.tokens.select-set" msgstr "Atlasīt kopu." #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "Tekstvienībai ir atsauce uz sevi" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 @@ -7655,7 +7655,7 @@ msgid "workspace.tokens.size" msgstr "Izmērs" #: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 -msgid "workspace.tokens.stroke-width-range" +msgid "errors.tokens.stroke-width-range" msgstr "Vilkuma platumam ir jābūt vienādam vai lielākam par 0." #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:154 @@ -7729,7 +7729,7 @@ msgid "workspace.tokens.value-not-valid" msgstr "Vērtība nav derīga" #: src/app/main/data/workspace/tokens/errors.cljs:65 -msgid "workspace.tokens.value-with-units" +msgid "errors.tokens.value-with-units" msgstr "Nederīga vērtība: mērvienības nav atļautas." #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 diff --git a/frontend/translations/nl.po b/frontend/translations/nl.po index 65fe20fe03..67fb439590 100644 --- a/frontend/translations/nl.po +++ b/frontend/translations/nl.po @@ -1,15 +1,15 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-02-16 08:35+0000\n" -"Last-Translator: Sebastiaan Pasma \n" -"Language-Team: Dutch " -"\n" +"PO-Revision-Date: 2026-02-17 10:09+0000\n" +"Last-Translator: Stephan Paternotte \n" +"Language-Team: Dutch \n" "Language: nl\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 5.16-dev\n" +"X-Generator: Weblate 5.16\n" #: src/app/main/ui/auth/register.cljs:215, src/app/main/ui/static.cljs:158, src/app/main/ui/viewer/login.cljs:100 msgid "auth.already-have-account" @@ -2115,7 +2115,6 @@ msgid "inspect.tabs.styles.active-themes" msgstr "Actieve thema's" #: src/app/main/ui/inspect/styles/style_box.cljs:68 -#, fuzzy msgid "inspect.tabs.styles.copy-shorthand" msgstr "CSS-code kopiëren naar klembord" @@ -5036,15 +5035,14 @@ msgid "subscription.settings.success.dialog.thanks" msgstr "Bedankt voor het kiezen van het Penpot %s-abonnement!" #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "Veel plezier met je abonnement!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "Je bent %s!" #: src/app/main/ui/settings/subscription.cljs:526 -#, fuzzy msgid "subscription.settings.support-us-since" msgstr "Je hebt ons gesteund met dit abonnement sinds: %s" @@ -7854,7 +7852,7 @@ msgid "workspace.tokens.color" msgstr "Kleur" #: src/app/main/data/workspace/tokens/errors.cljs:101, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:251 -msgid "workspace.tokens.composite-line-height-needs-font-size" +msgid "errors.tokens.composite-line-height-needs-font-size" msgstr "" "Regelafstand is afhankelijk van de lettergrootte. Voeg een lettergrootte " "toe om de opgeloste waarde te verkrijgen." @@ -7904,7 +7902,7 @@ msgid "workspace.tokens.edit-token" msgstr "%s token bewerken" #: src/app/main/data/workspace/tokens/errors.cljs:41 -msgid "workspace.tokens.empty-input" +msgid "errors.tokens.empty-input" msgstr "De tokenwaarde mag niet leeg zijn" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 @@ -7912,7 +7910,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "Voer de tokennaam %s in" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "Fout bij importeren: Kan JSON niet verwerken." #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -7976,7 +7974,7 @@ msgid "workspace.tokens.import-button-prefix" msgstr "Importeren: %s" #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "Fout bij importeren:" #: src/app/main/ui/workspace/tokens/import/modal.cljs:273 @@ -8033,63 +8031,63 @@ msgid "workspace.tokens.individual-tokens" msgstr "Individuele tokens gebruiken" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "Ongeldige kleurwaarde: %s" #: src/app/main/data/workspace/tokens/errors.cljs:93 -msgid "workspace.tokens.invalid-font-family-token-value" +msgid "errors.tokens.invalid-font-family-token-value" msgstr "Ongeldige tokenwaarde: je kunt alleen verwijzen naar een font-family-token" #: src/app/main/data/workspace/tokens/errors.cljs:89 -msgid "workspace.tokens.invalid-font-weight-token-value" +msgid "errors.tokens.invalid-font-weight-token-value" msgstr "" "Ongeldige gewichtswaarde lettertype: gebruik numerieke waarden (100-950) of " "standaardtermen (thin, light, regular, bold, etc.) optioneel gevolgd door " "'Italic'" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "Fout bij importeren: Ongeldige tokengegevens in JSON." #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "Importfout: Ongeldige tokennaam in JSON." #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "\"%s\" is geen geldige tokennaam.\n" "Tokennamen mogen alleen letters en cijfers bevatten, gescheiden door . " "tekens en mogen niet beginnen met een $-teken." #: src/app/main/data/workspace/tokens/errors.cljs:105 -msgid "workspace.tokens.invalid-shadow-type-token-value" +msgid "errors.tokens.invalid-shadow-type-token-value" msgstr "" "Ongeldig schaduwtype: alleen 'innerShadow' of 'dropShadow' worden " "geaccepteerd" #: src/app/main/data/workspace/tokens/errors.cljs:81 -msgid "workspace.tokens.invalid-text-case-token-value" +msgid "errors.tokens.invalid-text-case-token-value" msgstr "" "Ongeldige tokenwaarde: alleen none, Uppercase, Lowercase of Capitalize zijn " "toegestaan" #: src/app/main/data/workspace/tokens/errors.cljs:85 -msgid "workspace.tokens.invalid-text-decoration-token-value" +msgid "errors.tokens.invalid-text-decoration-token-value" msgstr "" "Ongeldige tokenwaarde: alleen none, underline en strike-through zijn " "toegestaan" #: src/app/main/data/workspace/tokens/errors.cljs:117 -msgid "workspace.tokens.invalid-token-value-shadow" +msgid "errors.tokens.invalid-token-value-shadow" msgstr "Ongeldige waarde: moet verwijzen naar een samengesteld schaduwtoken." #: src/app/main/data/workspace/tokens/errors.cljs:97 -msgid "workspace.tokens.invalid-token-value-typography" +msgid "errors.tokens.invalid-token-value-typography" msgstr "Ongeldige waarde: moet verwijzen naar een samengesteld typografietoken." #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "Ongeldige tokenwaarde: %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -8129,7 +8127,7 @@ msgid "workspace.tokens.min-size" msgstr "Min. grootte" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "Ontbrekende tokenverwijzingen: " #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 @@ -8181,7 +8179,7 @@ msgid "workspace.tokens.no-themes-currently" msgstr "Je hebt momenteel geen thema's." #: src/app/main/data/workspace/tokens/errors.cljs:19 -msgid "workspace.tokens.no-token-files-found" +msgid "errors.tokens.no-token-files-found" msgstr "Er zijn geen tokens, verzamelingen of thema's gevonden in dit bestand." #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:134 @@ -8189,15 +8187,14 @@ msgid "workspace.tokens.num-active-sets" msgstr "%s actieve verzamelingen" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "Ongeldige tokenwaarde. De opgeloste waarde is te groot: %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "De dekking moet tussen 0 en 100% of 0 en 1 zijn (bijv. 50% of 0,5)." #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:121 -#, fuzzy msgid "workspace.tokens.original-value" msgstr "Oorspronkelijke waarde: %s" @@ -8232,7 +8229,6 @@ msgid "workspace.tokens.remapping-in-progress" msgstr "Token-referenties opnieuw toewijzen…" #: src/app/main/data/workspace/tokens/warnings.cljs:15, src/app/main/data/workspace/tokens/warnings.cljs:19, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:56, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:84, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:103, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:285, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:459, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:176, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:311, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:251, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:364, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:465, src/app/main/ui/workspace/tokens/management/token_pill.cljs:122 -#, fuzzy msgid "workspace.tokens.resolved-value" msgstr "Opgeloste waarde: %s" @@ -8245,7 +8241,7 @@ msgid "workspace.tokens.select-set" msgstr "Verzameling kiezen." #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "Token bevat cirkelverwijzing" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 @@ -8286,7 +8282,7 @@ msgid "workspace.tokens.shadow-blur" msgstr "Vervanging" #: src/app/main/data/workspace/tokens/errors.cljs:109 -msgid "workspace.tokens.shadow-blur-range" +msgid "errors.tokens.shadow-blur-range" msgstr "Schaduwonscherpte moet groter zijn dan of gelijk zijn aan 0." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:987, src/app/main/ui/workspace/tokens/management/create/form.cljs:988 @@ -8307,7 +8303,7 @@ msgid "workspace.tokens.shadow-spread" msgstr "Spreiding" #: src/app/main/data/workspace/tokens/errors.cljs:113 -msgid "workspace.tokens.shadow-spread-range" +msgid "errors.tokens.shadow-spread-range" msgstr "Schaduwspreiding moet groter zijn dan of gelijk zijn aan 0." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:1215 @@ -8337,7 +8333,7 @@ msgid "workspace.tokens.size" msgstr "Grootte" #: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 -msgid "workspace.tokens.stroke-width-range" +msgid "errors.tokens.stroke-width-range" msgstr "De dikte van de streek moet groter zijn dan of gelijk zijn aan 0." #: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:40, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:161 @@ -8379,7 +8375,6 @@ msgid "workspace.tokens.themes-list" msgstr "Lijst met thema's" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:275, src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:276 -#, fuzzy msgid "workspace.tokens.token-description" msgstr "Beschrijving" @@ -8448,11 +8443,11 @@ msgid "workspace.tokens.value-not-valid" msgstr "De waarde is niet geldig" #: src/app/main/data/workspace/tokens/errors.cljs:69 -msgid "workspace.tokens.value-with-percent" +msgid "errors.tokens.value-with-percent" msgstr "Ongeldige waarde: % is niet toegestaan." #: src/app/main/data/workspace/tokens/errors.cljs:65 -msgid "workspace.tokens.value-with-units" +msgid "errors.tokens.value-with-units" msgstr "Ongeldige waarde: Eenheden zijn niet toegestaan." #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 @@ -8784,3 +8779,66 @@ msgstr "Automatisch opgeslagen versies worden %s dagen bewaard." #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Klik om het pad te sluiten" + +#: src/app/main/ui/static.cljs:315 +msgid "errors.webgl-context-lost.desc-message" +msgstr "" +"WebGL is gestopt met werken. Laad de pagina opnieuw om deze opnieuw in te " +"stellen" + +#: src/app/main/ui/static.cljs:314 +msgid "errors.webgl-context-lost.main-message" +msgstr "Oeps! De context van het doek ging verloren" + +#: src/app/main/ui/dashboard/sidebar.cljs:347 +msgid "dashboard.create-new-org" +msgstr "Nieuwe org aanmaken" + +#: src/app/main/errors.cljs:105 +msgid "errors.unexpected-exception" +msgstr "Onverwachte for: %s" + +#: src/app/main/ui/static.cljs:318 +msgid "labels.reload-page" +msgstr "Pagina's opnieuw laden" + +#: src/app/main/ui/workspace/sidebar/debug.cljs:38 +msgid "workspace.debug.title" +msgstr "Hulpmiddelen voor foutopsporing" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:303 +msgid "workspace.tokens.missing-reference" +msgstr "Ontbrekende verwijzing" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:95 +msgid "workspace.tokens.not-remap" +msgstr "Niet opnieuw toewijzen" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:99 +msgid "workspace.tokens.remap" +msgstr "Tokens opnieuw toewijzen" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:86 +msgid "workspace.tokens.remap-token-references-title" +msgstr "Alle tokens die '%s' gebruiken toewijzen aan '%s'?" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:88 +msgid "workspace.tokens.remap-warning-effects" +msgstr "" +"Hierdoor worden alle lagen en verwijzingen die de oude tokennaam gebruiken, " +"gewijzigd." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:89 +msgid "workspace.tokens.remap-warning-time" +msgstr "Deze actie kan wel even duren." + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs +#, unused +msgid "workspace.tokens.warning-name-change" +msgstr "" +"Door dit token te hernoemen, wordt elke verwijzing naar de oude naam " +"verbroken" + +#: src/app/main/ui/workspace/top_toolbar.cljs:231 +msgid "workspace.toolbar.debug" +msgstr "Hulpmiddelen voor foutopsporing" diff --git a/frontend/translations/ro.po b/frontend/translations/ro.po index d0807b28a9..8995460842 100644 --- a/frontend/translations/ro.po +++ b/frontend/translations/ro.po @@ -4747,11 +4747,11 @@ msgid "subscription.settings.success.dialog.thanks" msgstr "Mulțumim pentru că ai ales planul Penpot %s!" #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "Bucură-te de abonament!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "Ești %s!" #: src/app/main/ui/settings/subscription.cljs:526 @@ -7492,7 +7492,7 @@ msgid "workspace.tokens.color" msgstr "Culoare" #: src/app/main/data/workspace/tokens/errors.cljs:101, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:251 -msgid "workspace.tokens.composite-line-height-needs-font-size" +msgid "errors.tokens.composite-line-height-needs-font-size" msgstr "" "Înălțimea liniei depinde de dimensiunea fontului. Adaugă o dimensiune " "pentru font pentru a primi valoarea rezultată." @@ -7542,7 +7542,7 @@ msgid "workspace.tokens.edit-token" msgstr "Editează token %s" #: src/app/main/data/workspace/tokens/errors.cljs:41 -msgid "workspace.tokens.empty-input" +msgid "errors.tokens.empty-input" msgstr "Valoarea token-ului nu poate fi goală" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 @@ -7550,7 +7550,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "Introdu numele token-ului %s" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "Eroare la import: Nu s-a putut interpreta JSON." #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -7610,7 +7610,7 @@ msgid "workspace.tokens.import-button-prefix" msgstr "Importă %s" #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "Eroare import:" #: src/app/main/ui/workspace/tokens/import/modal.cljs:273 @@ -7667,49 +7667,49 @@ msgid "workspace.tokens.individual-tokens" msgstr "Folosește token-uri individuale" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "Valoare culoare invalidă: %s" #: src/app/main/data/workspace/tokens/errors.cljs:89 -msgid "workspace.tokens.invalid-font-weight-token-value" +msgid "errors.tokens.invalid-font-weight-token-value" msgstr "" "Valoare greutate font invalidă: folosește valori numerice (100-950) sau " "nume standardizate (thin, light, regular, bold, etc.) opțional urmate de " "'italic'" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "Eroare import: Date token invalide în fișierul JSON." #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "Eroare import: Nume token invalid în fișierul JSON." #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "\"%s\" nu este nu nume valid pentru token.\n" "Numele token-urilor trebuie să conțină doar litere și cifre separate de " "caracterul . și nu trebuie să înceapă cu un semn $." #: src/app/main/data/workspace/tokens/errors.cljs:81 -msgid "workspace.tokens.invalid-text-case-token-value" +msgid "errors.tokens.invalid-text-case-token-value" msgstr "" "Valoare token invalidă: doar none, Uppercase, Lowercase sau Capitalize sunt " "acceptate" #: src/app/main/data/workspace/tokens/errors.cljs:85 -msgid "workspace.tokens.invalid-text-decoration-token-value" +msgid "errors.tokens.invalid-text-decoration-token-value" msgstr "Valoare token invalidă: doar none, underline și strike-trough sunt acceptate" #: src/app/main/data/workspace/tokens/errors.cljs:97 -msgid "workspace.tokens.invalid-token-value-typography" +msgid "errors.tokens.invalid-token-value-typography" msgstr "" "Valoare invalidă: trebuie să facă referință la un token de tipografie " "compozită." #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "Valoare token invalidă: %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -7749,7 +7749,7 @@ msgid "workspace.tokens.min-size" msgstr "Dimensiune minimă" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "Referințe token lipsă: " #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 @@ -7789,7 +7789,7 @@ msgid "workspace.tokens.no-themes-currently" msgstr "În prezent nu ai teme." #: src/app/main/data/workspace/tokens/errors.cljs:19 -msgid "workspace.tokens.no-token-files-found" +msgid "errors.tokens.no-token-files-found" msgstr "Nu s-au găsit token-uri, seturi sau teme în acest fișier." #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:134 @@ -7797,11 +7797,11 @@ msgid "workspace.tokens.num-active-sets" msgstr "%s seturi active" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "Valoare token invalidă. Valoarea rezultată este prea mare: %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "Opacitatea trebuie să fie între 0 și 100% sau 0 și 1 (ex: 50% sau 0.5)." #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:121 @@ -7844,7 +7844,7 @@ msgid "workspace.tokens.select-set" msgstr "Selectează setul." #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "Token-ul își face referință singur" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 @@ -7881,7 +7881,7 @@ msgid "workspace.tokens.size" msgstr "Dimensiune" #: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 -msgid "workspace.tokens.stroke-width-range" +msgid "errors.tokens.stroke-width-range" msgstr "Lățimea conturului trebuie să fie mai mare sau egală cu 0." #: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:40, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:161 @@ -7973,11 +7973,11 @@ msgid "workspace.tokens.value-not-valid" msgstr "Valoarea este invalidă" #: src/app/main/data/workspace/tokens/errors.cljs:69 -msgid "workspace.tokens.value-with-percent" +msgid "errors.tokens.value-with-percent" msgstr "Valoare invalidă: % nu este permis." #: src/app/main/data/workspace/tokens/errors.cljs:65 -msgid "workspace.tokens.value-with-units" +msgid "errors.tokens.value-with-units" msgstr "Valoare invalidă: Unitățile nu sunt permise." #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 diff --git a/frontend/translations/ru.po b/frontend/translations/ru.po index 95874fe4dc..58891a8f46 100644 --- a/frontend/translations/ru.po +++ b/frontend/translations/ru.po @@ -1,15 +1,15 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-02-16 08:35+0000\n" -"Last-Translator: The_BadUser \n" -"Language-Team: Russian " -"\n" +"PO-Revision-Date: 2026-04-28 15:09+0000\n" +"Last-Translator: bobsonzu0a5d198e17c343cb \n" +"Language-Team: Russian \n" "Language: ru\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -"X-Generator: Weblate 5.16-dev\n" +"X-Generator: Weblate 5.17.1-dev\n" #: src/app/main/ui/auth/register.cljs:215, src/app/main/ui/static.cljs:158, src/app/main/ui/viewer/login.cljs:100 msgid "auth.already-have-account" @@ -676,7 +676,7 @@ msgstr "Файлы с ошибками загружены не будут." msgid "dashboard.import.import-message" msgid_plural "dashboard.import.import-message" msgstr[0] "1 файл был успешно импортирован." -msgstr[1] "Успешно импортировано файлов: %s" +msgstr[1] "%s файлов было успешно импортировано." #: src/app/main/ui/dashboard/import.cljs:474 msgid "dashboard.import.import-warning" @@ -5279,19 +5279,19 @@ msgstr "Поведение" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:393 msgid "workspace.options.interaction-easing-ease" -msgstr "Ease" +msgstr "Плавно" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:394 msgid "workspace.options.interaction-easing-ease-in" -msgstr "Ease in" +msgstr "Ускорение" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:396 msgid "workspace.options.interaction-easing-ease-in-out" -msgstr "Ease in out" +msgstr "Ускорение и замедление" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:395 msgid "workspace.options.interaction-easing-ease-out" -msgstr "Ease out" +msgstr "Замедление" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:392 msgid "workspace.options.interaction-easing-linear" @@ -6681,3 +6681,497 @@ msgstr "Автосохранённые версии будут хранитьс #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Нажмите для замыкания контура" + +#: src/app/main/ui/dashboard/deleted.cljs:313 +msgid "dashboard.clear-trash-button" +msgstr "Очистить корзину" + +#: src/app/main/ui/dashboard/deleted.cljs:262 +msgid "dashboard.delete-all-forever-confirmation.description" +msgstr "" +"Вы уверены, что хотите навсегда удалить все удаленные проекты и файлы? Это " +"необратимое действие." + +#: src/app/main/ui/dashboard/file_menu.cljs:221 +msgid "dashboard.delete-file-forever-confirmation.description" +msgstr "Вы уверены, что хотите навсегда удалить %s? Это необратимое действие." + +#: src/app/main/data/dashboard.cljs:778 +msgid "dashboard.delete-files-success-notification" +msgstr "%s файлов успешно удалено." + +#: src/app/main/ui/dashboard/deleted.cljs:51, src/app/main/ui/dashboard/deleted.cljs:53, src/app/main/ui/dashboard/deleted.cljs:261, src/app/main/ui/dashboard/deleted.cljs:263, src/app/main/ui/dashboard/file_menu.cljs:220, src/app/main/ui/dashboard/file_menu.cljs:222 +msgid "dashboard.delete-forever-confirmation.title" +msgstr "Удалить навсегда" + +#: src/app/main/ui/dashboard/deleted.cljs:85 +msgid "dashboard.delete-project-button" +msgstr "Удалить проект" + +#: src/app/main/ui/dashboard/deleted.cljs:52 +msgid "dashboard.delete-project-forever-confirmation.description" +msgstr "" +"Вы уверены, что хотите удалить проект %s навсегда? Вы собираетесь удалить " +"его и все находящиеся в нем файлы. Это необратимое действие." + +#: src/app/main/data/dashboard.cljs:777, src/app/main/data/dashboard.cljs:811 +msgid "dashboard.delete-success-notification" +msgstr "%s успешно удален." + +#: src/app/main/ui/dashboard/deleted.cljs:327 +msgid "dashboard.deleted.empty-state-description" +msgstr "Ваша корзина пуста. Удаленные файлы и проекты появятся здесь." + +#: src/app/main/ui/dashboard/grid.cljs:248 +msgid "dashboard.deleted.will-be-deleted-at" +msgstr "Будет удален %s" + +#, unused +msgid "dashboard.errors.error-on-delete-file" +msgstr "Произошла ошибка во время удаления файла %s." + +#: src/app/main/data/dashboard.cljs:781 +msgid "dashboard.errors.error-on-delete-files" +msgstr "Произошла ошибка во время удаления файлов." + +#: src/app/main/data/dashboard.cljs:814 +msgid "dashboard.errors.error-on-delete-project" +msgstr "Произошла ошибка во время удаления проекта %s." + +#: src/app/main/data/dashboard.cljs:909, src/app/main/ui/dashboard/file_menu.cljs:201 +msgid "dashboard.errors.error-on-restore-file" +msgstr "Произошла ошибка во время восстановления файла %s." + +#: src/app/main/data/dashboard.cljs:910 +msgid "dashboard.errors.error-on-restore-files" +msgstr "Произошла ошибка во время восстановления файлов." + +#: src/app/main/data/dashboard.cljs:942 +msgid "dashboard.errors.error-on-restoring-project" +msgstr "Произошла ошибка во время восстановления проекта %s и его файлов." + +#: src/app/main/ui/dashboard/file_menu.cljs:266 +msgid "dashboard.file-menu.delete-files-permanently-option" +msgid_plural "dashboard.file-menu.delete-files-permanently-option" +msgstr[0] "Удаленный файл" +msgstr[1] "Удаленный файлы" + +#: src/app/main/ui/dashboard/file_menu.cljs:263 +msgid "dashboard.file-menu.restore-files-option" +msgid_plural "dashboard.file-menu.restore-files-option" +msgstr[0] "Восстановить файл" +msgstr[1] "Восстановить файлы" + +#: src/app/main/data/dashboard.cljs:722 +msgid "dashboard.progress-notification.deleting-files" +msgstr "Удаление файлов…" + +#: src/app/main/data/dashboard.cljs:843 +msgid "dashboard.progress-notification.restoring-files" +msgstr "Восстановление файлов…" + +#: src/app/main/ui/dashboard/deleted.cljs:274 +msgid "dashboard.restore-all-confirmation.description" +msgstr "" +"Вы собираетесь восстановить все ваши проекты и файлы. Это может занять " +"некоторое время." + +#: src/app/main/ui/dashboard/deleted.cljs:273 +msgid "dashboard.restore-all-confirmation.title" +msgstr "Восстановить все проекты и файлы" + +#: src/app/main/ui/dashboard/deleted.cljs:308 +msgid "dashboard.restore-all-deleted-button" +msgstr "Восстановить все" + +#: src/app/main/data/dashboard.cljs:903 +msgid "dashboard.restore-files-success-notification" +msgstr "файлы %s были успешно восстановлены." + +#: src/app/main/ui/dashboard/deleted.cljs:82 +msgid "dashboard.restore-project-button" +msgstr "Восстановить проект" + +#: src/app/main/ui/dashboard/deleted.cljs:41 +msgid "dashboard.restore-project-confirmation.description" +msgstr "Вы собираетесь восстановить проект %s и все файлы в нем." + +#: src/app/main/ui/dashboard/deleted.cljs:40 +msgid "dashboard.restore-project-confirmation.title" +msgstr "Восстановить проект" + +#: src/app/main/data/dashboard.cljs:875, src/app/main/data/dashboard.cljs:902, src/app/main/data/dashboard.cljs:939, src/app/main/ui/dashboard/file_menu.cljs:198 +msgid "dashboard.restore-success-notification" +msgstr "%s был успешно восстановлен." + +#: src/app/main/ui/dashboard/deleted.cljs:298 +msgid "dashboard.trash-info-text-part1" +msgstr "Удаленные файлы останутся в корзине в течение" + +#: src/app/main/ui/dashboard/deleted.cljs:300 +msgid "dashboard.trash-info-text-part2" +msgstr " %s дней. " + +#: src/app/main/ui/dashboard/deleted.cljs:301 +msgid "dashboard.trash-info-text-part3" +msgstr "После этого они будут удалены навсегда." + +#: src/app/main/ui/comments.cljs:530 +msgid "comments.mentions.not-found" +msgstr "Людей не найдено для @%s" + +#: src/app/main/ui/dashboard/deleted.cljs:303 +msgid "dashboard.trash-info-text-part4" +msgstr "" +"Если вы передумаете, вы можете восстановить их или удалить навсегда через " +"меню каждого файла." + +#: src/app/main/errors.cljs:305 +msgid "errors.deprecated.contact.after" +msgstr "чтобы мы могли вам помочь." + +#: src/app/main/errors.cljs:200 +#, unused +msgid "errors.internal-assertion-error" +msgstr "Ошибка внутренней сертификации" + +#: src/app/main/errors.cljs:105 +msgid "errors.unexpected-exception" +msgstr "Неожиданная ошибка: %s" + +#: src/app/main/ui/static.cljs:314 +#, fuzzy +msgid "errors.webgl-context-lost.main-message" +msgstr "Упс! Контекст холста был потерян" + +#: src/app/main/ui/dashboard/team.cljs:933 +msgid "team.invitations-selected" +msgid_plural "team.invitations-selected" +msgstr[0] "1 приглашение выбрано" +msgstr[1] "%s приглашений выбрано" + +#: src/app/main/ui/workspace/libraries.cljs:100, src/app/main/ui/workspace/libraries.cljs:126 +msgid "workspace.libraries.colors" +msgid_plural "workspace.libraries.colors" +msgstr[0] "1 цвет" +msgstr[1] "%s цветов" + +#: src/app/main/ui/workspace/libraries.cljs:94, src/app/main/ui/workspace/libraries.cljs:118 +msgid "workspace.libraries.components" +msgid_plural "workspace.libraries.components" +msgstr[0] "1 компонент" +msgstr[1] "%s компонентов" + +#: src/app/main/data/dashboard.cljs:723 +msgid "dashboard.progress-notification.slow-delete" +msgstr "Удаление идет медленнее ожидаемого" + +#: src/app/main/data/dashboard.cljs:844 +msgid "dashboard.progress-notification.slow-restore" +msgstr "Восстановление идет неожиданно медленно" + +#: src/app/main/ui/static.cljs:315 +msgid "errors.webgl-context-lost.desc-message" +msgstr "" +"WebGL прекратил работу. Пожалуйста, перезагрузите страницу чтобы сбросить его" + +#: src/app/main/ui/settings/feedback.cljs:126 +msgid "feedback.penpot.link" +msgstr "" +"Если обратная связь связана с файлом или проектом, оставьте ссылку на файл " +"penpot здесь:" + +#: src/app/main/ui/exports/files.cljs:124 +msgid "files-download-modal.title" +msgstr "Скачать файлы" + +#: src/app/main/ui/inspect/right_sidebar.cljs:170 +msgid "inspect.color-space-label" +msgstr "Выберите цветовое пространство" + +#: src/app/main/ui/inspect/right_sidebar.cljs:238 +msgid "inspect.empty.more" +msgstr "Больше информации" + +#: src/app/main/ui/inspect/right_sidebar.cljs:110 +msgid "labels.computed" +msgstr "Вычисленные" + +#: src/app/main/ui/static.cljs:415 +msgid "labels.contact-support" +msgstr "Связаться с поддержкой" + +#: src/app/main/ui/dashboard/deleted.cljs:215 +msgid "labels.deleted" +msgstr "Удаленные" + +#: src/app/main/ui/settings/feedback.cljs:134, src/app/main/ui/static.cljs:409 +msgid "labels.download" +msgstr "Скачать %s" + +#: src/app/main/ui/static.cljs:405 +msgid "labels.internal-error.desc-message-first" +msgstr "Что-то пошло не так." + +#: src/app/main/ui/dashboard/file_menu.cljs:208 +msgid "dashboard-restore-file-confirmation.description" +msgstr "Вы собираетесь восстановить %s." + +#: src/app/main/ui/dashboard/file_menu.cljs:207 +msgid "dashboard-restore-file-confirmation.title" +msgstr "Восстановить файл" + +#: src/app/main/ui/dashboard/templates.cljs:87 +msgid "labels.show" +msgstr "Показать" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:261 +msgid "labels.switch" +msgstr "Переключить" + +#: src/app/main/ui/inspect/styles/style_box.cljs:25 +msgid "labels.text" +msgstr "Текст" + +#: src/app/main/data/workspace/tokens/errors.cljs:121 +msgid "labels.unknown-error" +msgstr "Неизвестная ошибка" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:204 +msgid "labels.unlock" +msgstr "Разблокировать" + +#: src/app/main/ui/inspect/right_sidebar.cljs:66, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1039 +msgid "labels.variant" +msgstr "Вариант" + +#: src/app/main/ui/inspect/styles/style_box.cljs:32 +msgid "labels.visibility" +msgstr "Видимость" + +#: src/app/main/ui/ds/product/loader.cljs:27 +msgid "loader.tips.04.message" +msgstr "Получите CSS и SVG код напрямую из ваших макетов." + +#: src/app/main/ui/ds/product/loader.cljs:26 +msgid "loader.tips.04.title" +msgstr "Экспорт в код" + +#: src/app/main/ui/ds/product/loader.cljs:31 +msgid "loader.tips.06.message" +msgstr "Вдохните жизнь в ваши идеи с помощью анимаций и переходов." + +#: src/app/main/ui/ds/product/loader.cljs:30 +msgid "loader.tips.06.title" +msgstr "Интерактивные прототипы" + +#: src/app/main/ui/ds/product/loader.cljs:34 +msgid "loader.tips.08.title" +msgstr "Горячие клавиши" + +#: src/app/main/ui/ds/product/loader.cljs:37 +msgid "loader.tips.09.message" +msgstr "Выберите тему, подходящую под ваш стиль." + +#: src/app/main/ui/ds/product/loader.cljs:36 +msgid "loader.tips.09.title" +msgstr "Темный & светлый режим" + +#: src/app/main/ui/ds/product/loader.cljs:39 +msgid "loader.tips.10.message" +msgstr "Дополните Penpot плагинами от сообщества для расширения функционала." + +#: src/app/main/ui/dashboard/team.cljs:222 +msgid "modals.invite-team-member.text" +msgstr "" +"Вы можете пригласить участников в команду, чтобы они получили доступ к этому " +"и другим файлам команды." + +#: src/app/main/ui/static.cljs:287 +msgid "not-found.desc-message.error" +msgstr "404 ошибка" + +#: src/app/main/ui/static.cljs:138 +msgid "not-found.login.free" +msgstr "" +"Penpot — бесплатный инструмент с открытым исходным кодом для совместной " +"работы дизайнеров и разработчиков" + +#: src/app/main/ui/auth/recovery_request.cljs:114 +msgid "not-found.login.sent-recovery" +msgstr "Вы направили письмо для восстановления на" + +#: src/app/main/ui/auth/recovery_request.cljs:116 +msgid "not-found.login.sent-recovery-check" +msgstr "Проверьте вашу почту и перейдите по ссылке, чтобы задать новый пароль." + +#: src/app/main/ui/static.cljs:152 +msgid "not-found.login.signup-free" +msgstr "Зарегистрироваться бесплатно" + +#: src/app/main/ui/static.cljs:153 +msgid "not-found.login.start-using" +msgstr "И начните использовать Penpot мгновенно!" + +#: src/app/main/ui/static.cljs:69 +msgid "not-found.made-with-love" +msgstr "Сделано с любовью и открытым исходным кодом" + +#: src/app/main/ui/static.cljs:248 +msgid "not-found.no-permission.already-requested.file" +msgstr "Вы уже запросили доступ к этому файлу." + +#: src/app/main/ui/dashboard/placeholder.cljs:61 +msgid "dashboard.empty-project.explore" +msgstr "Рассмотрите варианты для добавления" + +#: src/app/main/errors.cljs:214 +msgid "errors.internal-worker-error" +msgstr "Произошла ошибка в работе веб-воркера." + +#: src/app/main/ui/inspect/styles/rows/color_properties_row.cljs:120 +msgid "inspect.attributes.image.preview" +msgstr "Предварительный просмотр заливки фигуры" + +#: src/app/main/ui/inspect/styles/style_box.cljs:68 +msgid "inspect.tabs.styles.copy-shorthand" +msgstr "Копировать CSS-сокращение в буфер обмена" + +#: src/app/main/ui/dashboard/sidebar.cljs:1125 +msgid "labels.community-contributions" +msgstr "Сообщество и содействие" + +#: src/app/main/ui/static.cljs:67 +msgid "labels.copyright-period" +msgstr "Kaleidos © 2019–по настоящее время" + +#: src/app/main/ui/static.cljs:406 +msgid "labels.internal-error.desc-message-second" +msgstr "" +"Повторите попытку или свяжитесь с технической поддержкой, чтобы сообщить об " +"ошибке." + +#: src/app/main/ui/dashboard/sidebar.cljs:893 +msgid "labels.learning-center" +msgstr "Центр обучения" + +#: src/app/main/ui/comments.cljs:581 +msgid "labels.mention" +msgstr "Отметить" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:210 +msgid "labels.lock" +msgstr "Заблокировать" + +#: src/app/main/ui/static.cljs:61, src/app/main/ui/static.cljs:137 +msgid "labels.login" +msgstr "Логин" + +#: src/app/main/ui/ds/controls/numeric_input.cljs:631 +msgid "labels.mixed-values" +msgstr "Смешать" + +#: src/app/main/ui/dashboard/sidebar.cljs:347 +msgid "dashboard.create-new-org" +msgstr "Создать новую организацию" + +#: src/app/main/ui/dashboard/team.cljs:739 +msgid "labels.no-invitations-gather-people" +msgstr "Объединяйтесь и творите вместе." + +#: src/app/main/ui/comments.cljs:911, src/app/main/ui/comments.cljs:976, src/app/main/ui/workspace/palette.cljs:199, src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:107, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:906, src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:155, src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:213, src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:294, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:402, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1031, src/app/main/ui/workspace/sidebar/options/menus/text.cljs:316, src/app/main/ui/workspace/sidebar/options/menus/text.cljs:345, src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:146 +msgid "labels.options" +msgstr "Параметры" + +#: src/app/main/ui/dashboard/sidebar.cljs:899 +msgid "labels.penpot-hub" +msgstr "Хаб Penpot" + +#: src/app/main/ui/comments.cljs:680 +msgid "labels.post" +msgstr "Опубликовать" + +#: src/app/main/ui/dashboard/deleted.cljs:208 +msgid "labels.recent" +msgstr "Недавние" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:205, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:179 +msgid "labels.reference" +msgstr "Референс" + +#: src/app/main/data/common.cljs:83 +msgid "labels.refresh" +msgstr "Перезагрузить" + +#: src/app/main/ui/static.cljs:318 +msgid "labels.reload-page" +msgstr "Обновить страницу" + +#: src/app/main/ui/comments.cljs:642 +msgid "labels.replies" +msgstr "ответы" + +#, unused +msgid "labels.ok" +msgstr "Ok" + +#: src/app/main/ui/comments.cljs:647 +msgid "labels.replies.new" +msgstr "новые ответы" + +#: src/app/main/ui/comments.cljs:641 +msgid "labels.reply" +msgstr "ответить" + +#: src/app/main/ui/comments.cljs:646 +msgid "labels.reply.new" +msgstr "новый ответ" + +#: src/app/main/ui/comments.cljs:713 +msgid "labels.reply.thread" +msgstr "Ответить" + +#: src/app/main/ui/dashboard/team.cljs:788 +msgid "labels.resend" +msgstr "Отправить повторно" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:87, src/app/main/ui/workspace/sidebar/versions.cljs:197 +msgid "labels.restore" +msgstr "Восстановить" + +#: src/app/main/ui/workspace/tokens/sidebar.cljs:75 +msgid "labels.sets" +msgstr "Наборы" + +#: src/app/main/ui/inspect/styles/style_box.cljs:27, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:229 +msgid "labels.shadow" +msgstr "Тень" + +#: src/app/main/ui/inspect/styles/style_box.cljs:24, src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:46 +msgid "labels.stroke" +msgstr "Обводка" + +#: src/app/main/ui/inspect/styles/style_box.cljs:33 +#, fuzzy +msgid "labels.svg" +msgstr "SVG" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:189 +msgid "labels.typography" +msgstr "Типографика" + +#: src/app/main/ui/dashboard/sidebar.cljs:967 +msgid "labels.version-notes" +msgstr "Примечания к версии %s" + +#: src/app/main/ui/ds/product/loader.cljs:20 +msgid "loader.tips.01.title" +msgstr "Переиспользуемые компоненты" + +#: src/app/main/ui/ds/product/loader.cljs:23 +msgid "loader.tips.02.message" +msgstr "Работайте с командой в реальном времени, делитесь отзывами мгновенно." + +#: src/app/main/ui/ds/product/loader.cljs:22 +msgid "loader.tips.02.title" +msgstr "Совместная работа в реальном времени" diff --git a/frontend/translations/sv.po b/frontend/translations/sv.po index 891b1b5e17..a25e459c0b 100644 --- a/frontend/translations/sv.po +++ b/frontend/translations/sv.po @@ -4827,11 +4827,11 @@ msgid "subscription.settings.success.dialog.thanks" msgstr "Tack för att du valt Penpot %s-abonnemanget!" #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "Lycka till med ditt abonnemang!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "Du är %!" #: src/app/main/ui/settings/subscription.cljs:526 @@ -7567,7 +7567,7 @@ msgid "workspace.tokens.color" msgstr "Färg" #: src/app/main/data/workspace/tokens/errors.cljs:101, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:251 -msgid "workspace.tokens.composite-line-height-needs-font-size" +msgid "errors.tokens.composite-line-height-needs-font-size" msgstr "" "Radhöjden beror på teckenstorleken. Lägg till en teckenstorlek för att få " "det uträknade värdet." @@ -7617,7 +7617,7 @@ msgid "workspace.tokens.edit-token" msgstr "Redigera %s token" #: src/app/main/data/workspace/tokens/errors.cljs:41 -msgid "workspace.tokens.empty-input" +msgid "errors.tokens.empty-input" msgstr "Token-värdet kan inte vara tomt" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 @@ -7625,7 +7625,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "Ange %s tokennamn" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "Importeringsfel: Kunde inte tolka JSON." #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -7689,7 +7689,7 @@ msgid "workspace.tokens.import-button-prefix" msgstr "Importera %s" #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "Importeringsfel:" #: src/app/main/ui/workspace/tokens/import/modal.cljs:273 @@ -7746,58 +7746,58 @@ msgid "workspace.tokens.individual-tokens" msgstr "Använd individuella tokens" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "Ogiltigt färgvärde: %s" #: src/app/main/data/workspace/tokens/errors.cljs:93 -msgid "workspace.tokens.invalid-font-family-token-value" +msgid "errors.tokens.invalid-font-family-token-value" msgstr "Ogiltigt token-värde: du kan bara referera till en teckensnittsfamiljs-token" #: src/app/main/data/workspace/tokens/errors.cljs:89 -msgid "workspace.tokens.invalid-font-weight-token-value" +msgid "errors.tokens.invalid-font-weight-token-value" msgstr "" "Ogiltigt värde för typsnittsvikt: använd numeriska värden (100–950) eller " "standardnamn (smal, lätt, normal, fet, etc.) eventuellt följt av 'Kursiv'" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "Importeringsfel: Ogiltig token-data i JSON." #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "Importeringsfel: Ogiltigt token-namn i JSON." #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "\"%s\" är inte ett giltigt token-namn.\n" "Token-namn ska endast innehålla bokstäver och siffror separerade med . " "tecken och får inte börja med ett $-tecken." #: src/app/main/data/workspace/tokens/errors.cljs:105 -msgid "workspace.tokens.invalid-shadow-type-token-value" +msgid "errors.tokens.invalid-shadow-type-token-value" msgstr "Ogiltig skuggningstyp: endast 'innerShadow' eller 'dropShadow' accepteras" #: src/app/main/data/workspace/tokens/errors.cljs:81 -msgid "workspace.tokens.invalid-text-case-token-value" +msgid "errors.tokens.invalid-text-case-token-value" msgstr "" "Ogiltigt token-värde: endast ingen, versaler, gemener eller versalisera " "accepteras" #: src/app/main/data/workspace/tokens/errors.cljs:85 -msgid "workspace.tokens.invalid-text-decoration-token-value" +msgid "errors.tokens.invalid-text-decoration-token-value" msgstr "Ogiltigt token-värde: endast ingen, understruken och genomstruken accepteras" #: src/app/main/data/workspace/tokens/errors.cljs:117 -msgid "workspace.tokens.invalid-token-value-shadow" +msgid "errors.tokens.invalid-token-value-shadow" msgstr "Ogiltigt värde: måste referera till en sammansatt skuggnings-token." #: src/app/main/data/workspace/tokens/errors.cljs:97 -msgid "workspace.tokens.invalid-token-value-typography" +msgid "errors.tokens.invalid-token-value-typography" msgstr "Ogiltigt värde: måste referera till en sammansatt typografi-token." #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "Ogiltigt token-värde: %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -7837,7 +7837,7 @@ msgid "workspace.tokens.min-size" msgstr "Min. storlek" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "Saknade token-referenser: " #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 @@ -7877,7 +7877,7 @@ msgid "workspace.tokens.no-themes-currently" msgstr "Du har för närvarande inga teman." #: src/app/main/data/workspace/tokens/errors.cljs:19 -msgid "workspace.tokens.no-token-files-found" +msgid "errors.tokens.no-token-files-found" msgstr "Inga tokens, uppsättningar eller teman hittades i den här filen." #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:134 @@ -7885,11 +7885,11 @@ msgid "workspace.tokens.num-active-sets" msgstr "%s aktiva uppsättningar" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "Ogiltigt token-värde. Det uträknade värdet är för stort: %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "" "Opaciteten måste vara mellan 0 och 100 % eller 0 och 1 (t.ex. 50 % eller " "0,5)." @@ -7938,7 +7938,7 @@ msgid "workspace.tokens.select-set" msgstr "Välj uppsättning." #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "Token har självreferens" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 @@ -7977,7 +7977,7 @@ msgid "workspace.tokens.shadow-blur" msgstr "Oskärpa" #: src/app/main/data/workspace/tokens/errors.cljs:109 -msgid "workspace.tokens.shadow-blur-range" +msgid "errors.tokens.shadow-blur-range" msgstr "Skuggningsoskärpan måste vara större än eller lika med 0." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:987, src/app/main/ui/workspace/tokens/management/create/form.cljs:988 @@ -7998,7 +7998,7 @@ msgid "workspace.tokens.shadow-spread" msgstr "Spridning" #: src/app/main/data/workspace/tokens/errors.cljs:113 -msgid "workspace.tokens.shadow-spread-range" +msgid "errors.tokens.shadow-spread-range" msgstr "Skuggningsspridning måste vara större än eller lika med 0." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:1215 @@ -8019,7 +8019,7 @@ msgid "workspace.tokens.size" msgstr "Storlek" #: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 -msgid "workspace.tokens.stroke-width-range" +msgid "errors.tokens.stroke-width-range" msgstr "Streckbredden måste vara större än eller lika med 0." #: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:40, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:161 @@ -8120,11 +8120,11 @@ msgid "workspace.tokens.value-not-valid" msgstr "Värdet är inte giltigt" #: src/app/main/data/workspace/tokens/errors.cljs:69 -msgid "workspace.tokens.value-with-percent" +msgid "errors.tokens.value-with-percent" msgstr "Ogiltigt värde: % är inte tillåtet." #: src/app/main/data/workspace/tokens/errors.cljs:65 -msgid "workspace.tokens.value-with-units" +msgid "errors.tokens.value-with-units" msgstr "Ogiltigt värde: Enheter är ej tillåtna." #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 diff --git a/frontend/translations/tr.po b/frontend/translations/tr.po index e9da3bbc47..25dd416b81 100644 --- a/frontend/translations/tr.po +++ b/frontend/translations/tr.po @@ -1,15 +1,15 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-02-16 08:35+0000\n" -"Last-Translator: Anonymous \n" -"Language-Team: Turkish " -"\n" +"PO-Revision-Date: 2026-02-17 10:10+0000\n" +"Last-Translator: Oğuz Ersen \n" +"Language-Team: Turkish \n" "Language: tr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 5.16-dev\n" +"X-Generator: Weblate 5.16\n" #: src/app/main/ui/auth/register.cljs:215, src/app/main/ui/static.cljs:158, src/app/main/ui/viewer/login.cljs:100 msgid "auth.already-have-account" @@ -2091,7 +2091,6 @@ msgid "inspect.tabs.styles.active-themes" msgstr "Etkin temalar" #: src/app/main/ui/inspect/styles/style_box.cljs:68 -#, fuzzy msgid "inspect.tabs.styles.copy-shorthand" msgstr "CSS kısaltmasını panoya kopyala" @@ -2934,7 +2933,7 @@ msgstr "Sürüm %s notları" #: src/app/main/ui/workspace/sidebar/sitemap.cljs:271 msgid "labels.view-only" -msgstr "YALNIZCA GÖRÜNTÜLE" +msgstr "Yalnızca görüntüle" #: src/app/main/ui/dashboard/team.cljs:131, src/app/main/ui/dashboard/team.cljs:314, src/app/main/ui/dashboard/team.cljs:567, src/app/main/ui/dashboard/team.cljs:603, src/app/main/ui/onboarding/team_choice.cljs:56 msgid "labels.viewer" @@ -5002,15 +5001,14 @@ msgid "subscription.settings.success.dialog.thanks" msgstr "Penpot %s planını seçtiğiniz için teşekkür ederiz!" #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "Planınızın tadını çıkarın!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "%s oldunuz!" #: src/app/main/ui/settings/subscription.cljs:526 -#, fuzzy msgid "subscription.settings.support-us-since" msgstr "Bu planla bizi şu zamandan beri destekliyorsunuz: %s" @@ -7814,7 +7812,7 @@ msgid "workspace.tokens.color" msgstr "Renk" #: src/app/main/data/workspace/tokens/errors.cljs:101, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:251 -msgid "workspace.tokens.composite-line-height-needs-font-size" +msgid "errors.tokens.composite-line-height-needs-font-size" msgstr "" "Satır yüksekliği yazı tipi boyutuna bağlıdır. Çözülen değeri elde etmek " "için bir yazı tipi boyutu ekleyin." @@ -7864,7 +7862,7 @@ msgid "workspace.tokens.edit-token" msgstr "%s tokenini düzenle" #: src/app/main/data/workspace/tokens/errors.cljs:41 -msgid "workspace.tokens.empty-input" +msgid "errors.tokens.empty-input" msgstr "Token değeri boş olamaz" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 @@ -7872,7 +7870,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "%s token adını gir" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "İçe Aktarma Hatası: JSON ayrıştırılamadı." #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -7936,7 +7934,7 @@ msgid "workspace.tokens.import-button-prefix" msgstr "%s içe aktar" #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "İçe Aktarma Hatası:" #: src/app/main/ui/workspace/tokens/import/modal.cljs:273 @@ -7993,61 +7991,61 @@ msgid "workspace.tokens.individual-tokens" msgstr "Bireysel tokenler kullan" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "Geçersiz renk değeri: %s" #: src/app/main/data/workspace/tokens/errors.cljs:93 -msgid "workspace.tokens.invalid-font-family-token-value" +msgid "errors.tokens.invalid-font-family-token-value" msgstr "Geçersiz token değeri: yalnızca font-family tokenine referans verebilirsiniz" #: src/app/main/data/workspace/tokens/errors.cljs:89 -msgid "workspace.tokens.invalid-font-weight-token-value" +msgid "errors.tokens.invalid-font-weight-token-value" msgstr "" "Geçersiz yazı tipi kalınlığı değeri: sayısal değerler (100-950) veya " "standart adlar (ince, hafif, normal, kalın vb.) kullanın, isteğe bağlı " "olarak ardından 'İtalik' ekleyin" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "İçe Aktarma Hatası: JSON'da geçersiz token verisi." #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "İçe Aktarma Hatası: JSON'da geçersiz token adı." #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "\"%s\" geçerli bir token adı değil.\n" "Token adları yalnızca . karakterleriyle ayrılan harfler ve rakamlar " "içermeli ve $ işaretiyle başlamamalıdır." #: src/app/main/data/workspace/tokens/errors.cljs:105 -msgid "workspace.tokens.invalid-shadow-type-token-value" +msgid "errors.tokens.invalid-shadow-type-token-value" msgstr "Geçersiz gölge türü: yalnızca 'innerShadow' veya 'dropShadow' kabul edilir" #: src/app/main/data/workspace/tokens/errors.cljs:81 -msgid "workspace.tokens.invalid-text-case-token-value" +msgid "errors.tokens.invalid-text-case-token-value" msgstr "" "Geçersiz token değeri: yalnızca none, Uppercase, Lowercase veya Capitalize " "kabul edilir" #: src/app/main/data/workspace/tokens/errors.cljs:85 -msgid "workspace.tokens.invalid-text-decoration-token-value" +msgid "errors.tokens.invalid-text-decoration-token-value" msgstr "" "Geçersiz token değeri: yalnızca none, underline ve strike-through kabul " "edilir" #: src/app/main/data/workspace/tokens/errors.cljs:117 -msgid "workspace.tokens.invalid-token-value-shadow" +msgid "errors.tokens.invalid-token-value-shadow" msgstr "Geçersiz değer: bileşik gölge tokenine referans vermelidir." #: src/app/main/data/workspace/tokens/errors.cljs:97 -msgid "workspace.tokens.invalid-token-value-typography" +msgid "errors.tokens.invalid-token-value-typography" msgstr "Geçersiz değer: bileşik tipografi tokenine referans vermelidir." #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "Geçersiz token değeri: %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -8087,7 +8085,7 @@ msgid "workspace.tokens.min-size" msgstr "Asgari boyut" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "Eksik token referansları: " #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 @@ -8139,7 +8137,7 @@ msgid "workspace.tokens.no-themes-currently" msgstr "Şu anda hiç temanız yok." #: src/app/main/data/workspace/tokens/errors.cljs:19 -msgid "workspace.tokens.no-token-files-found" +msgid "errors.tokens.no-token-files-found" msgstr "Bu dosyada token, küme veya tema bulunamadı." #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:134 @@ -8147,15 +8145,14 @@ msgid "workspace.tokens.num-active-sets" msgstr "%s etkin küme" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "Geçersiz token değeri. Çözülen değer çok büyük: %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "Opaklık 0 ile %100 veya 0 ile 1 arasında olmalıdır (örneğin %50 veya 0.5)." #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:121 -#, fuzzy msgid "workspace.tokens.original-value" msgstr "Orijinal değer: %s" @@ -8190,7 +8187,6 @@ msgid "workspace.tokens.remapping-in-progress" msgstr "Token referansları yeniden eşleniyor..." #: src/app/main/data/workspace/tokens/warnings.cljs:15, src/app/main/data/workspace/tokens/warnings.cljs:19, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:56, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:84, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:103, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:285, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:459, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:176, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:311, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:251, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:364, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:465, src/app/main/ui/workspace/tokens/management/token_pill.cljs:122 -#, fuzzy msgid "workspace.tokens.resolved-value" msgstr "Çözülen değer: %s" @@ -8203,7 +8199,7 @@ msgid "workspace.tokens.select-set" msgstr "Küme seç." #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "Tokenin kendine referansı var" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 @@ -8244,7 +8240,7 @@ msgid "workspace.tokens.shadow-blur" msgstr "Bulanıklık" #: src/app/main/data/workspace/tokens/errors.cljs:109 -msgid "workspace.tokens.shadow-blur-range" +msgid "errors.tokens.shadow-blur-range" msgstr "Gölge bulanıklığı 0 veya 0'dan büyük olmalıdır." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:987, src/app/main/ui/workspace/tokens/management/create/form.cljs:988 @@ -8265,7 +8261,7 @@ msgid "workspace.tokens.shadow-spread" msgstr "Yayılım" #: src/app/main/data/workspace/tokens/errors.cljs:113 -msgid "workspace.tokens.shadow-spread-range" +msgid "errors.tokens.shadow-spread-range" msgstr "Gölge yayılımı 0 veya 0'dan büyük olmalıdır." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:1215 @@ -8295,7 +8291,7 @@ msgid "workspace.tokens.size" msgstr "Boyut" #: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 -msgid "workspace.tokens.stroke-width-range" +msgid "errors.tokens.stroke-width-range" msgstr "Çerçeve genişliği 0'dan büyük veya 0 olmalıdır." #: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:40, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:161 @@ -8337,7 +8333,6 @@ msgid "workspace.tokens.themes-list" msgstr "Tema listesi" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:275, src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:276 -#, fuzzy msgid "workspace.tokens.token-description" msgstr "Açıklama" @@ -8406,11 +8401,11 @@ msgid "workspace.tokens.value-not-valid" msgstr "Değer geçerli değil" #: src/app/main/data/workspace/tokens/errors.cljs:69 -msgid "workspace.tokens.value-with-percent" +msgid "errors.tokens.value-with-percent" msgstr "Geçersiz değer: % izin verilmiyor." #: src/app/main/data/workspace/tokens/errors.cljs:65 -msgid "workspace.tokens.value-with-units" +msgid "errors.tokens.value-with-units" msgstr "Geçersiz değer: Birimlere izin verilmiyor." #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 @@ -8742,3 +8737,63 @@ msgstr "Otomatik kaydedilen sürümler %s gün boyunca saklanacaktır." #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Yolu kapatmak için tıklayın" + +#: src/app/main/ui/dashboard/sidebar.cljs:347 +msgid "dashboard.create-new-org" +msgstr "Yeni organizasyon oluştur" + +#: src/app/main/errors.cljs:105 +msgid "errors.unexpected-exception" +msgstr "Beklenmeyen hata: %s" + +#: src/app/main/ui/static.cljs:315 +msgid "errors.webgl-context-lost.desc-message" +msgstr "" +"WebGL çalışmayı durdurdu. Sıfırlamak için lütfen sayfayı yeniden yükleyin" + +#: src/app/main/ui/static.cljs:314 +msgid "errors.webgl-context-lost.main-message" +msgstr "Oops! Tuval içeriği kayboldu" + +#: src/app/main/ui/static.cljs:318 +msgid "labels.reload-page" +msgstr "Sayfayı yeniden yükle" + +#: src/app/main/ui/workspace/sidebar/debug.cljs:38 +msgid "workspace.debug.title" +msgstr "Hata ayıklama araçları" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:303 +msgid "workspace.tokens.missing-reference" +msgstr "Eksik referans" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:95 +msgid "workspace.tokens.not-remap" +msgstr "Yeniden eşlenmesin" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:99 +msgid "workspace.tokens.remap" +msgstr "Tokenleri yeniden eşle" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:86 +msgid "workspace.tokens.remap-token-references-title" +msgstr "`%s` kullanan tüm tokenler `%s` olarak yeniden eşlensin mi?" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:88 +msgid "workspace.tokens.remap-warning-effects" +msgstr "" +"Bu, eski token adını kullanan tüm katmanları ve referansları değiştirecektir." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:89 +msgid "workspace.tokens.remap-warning-time" +msgstr "Bu işlem biraz zaman alabilir." + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs +#, unused +msgid "workspace.tokens.warning-name-change" +msgstr "" +"Bu tokenin adını değiştirmek, eski adına yapılan tüm referansları bozacaktır" + +#: src/app/main/ui/workspace/top_toolbar.cljs:231 +msgid "workspace.toolbar.debug" +msgstr "Hata ayıklama araçları" diff --git a/frontend/translations/ukr_UA.po b/frontend/translations/ukr_UA.po index e587f083dc..db6e7f5370 100644 --- a/frontend/translations/ukr_UA.po +++ b/frontend/translations/ukr_UA.po @@ -1,16 +1,16 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-02-16 08:35+0000\n" +"PO-Revision-Date: 2026-03-09 20:09+0000\n" "Last-Translator: Denys Kisil \n" -"Language-Team: Ukrainian " -"\n" +"Language-Team: Ukrainian \n" "Language: ukr_UA\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && " "n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n" -"X-Generator: Weblate 5.16-dev\n" +"X-Generator: Weblate 5.17-dev\n" #: src/app/main/ui/auth/register.cljs:215, src/app/main/ui/static.cljs:158, src/app/main/ui/viewer/login.cljs:100 msgid "auth.already-have-account" @@ -652,7 +652,7 @@ msgstr "" #: src/app/main/ui/dashboard.cljs:259 msgid "dashboard.import.bad-url" -msgstr "Імпортування не вдалось. Посилання шаблону неправильне" +msgstr "Імпортування не вдалось. URL шаблону неправильне" #: src/app/main/ui/dashboard.cljs:241 #, unused @@ -857,7 +857,7 @@ msgstr "Пришпилити/відшпилити" #: src/app/main/ui/dashboard.cljs:223 msgid "dashboard.plugins.bad-url" -msgstr "Посилання плагіну неправильне" +msgstr "Недійсний URL плагіну" #: src/app/main/ui/dashboard.cljs:221 msgid "dashboard.plugins.parse-error" @@ -1284,7 +1284,7 @@ msgstr "Під час роботи веб-виконавця сталась по #: src/app/main/ui/components/color_input.cljs:51 msgid "errors.invalid-color" -msgstr "Хибний колір" +msgstr "Недійсний колір" #: src/app/util/forms.cljs:35, src/app/util/forms.cljs:89 msgid "errors.invalid-data" @@ -1310,7 +1310,7 @@ msgstr "Помилковий текст" #: src/app/main/ui/static.cljs:74 msgid "errors.invite-invalid" -msgstr "Хибне запрошення" +msgstr "Недійсне запрошення" #: src/app/main/ui/static.cljs:75 msgid "errors.invite-invalid.info" @@ -1459,7 +1459,7 @@ msgstr "Помилка під'єднання, адреса недосяжна" #: src/app/main/ui/dashboard/team.cljs:1045 msgid "errors.webhooks.invalid-uri" -msgstr "Посилання не пройшло перевірку." +msgstr "URL не пройшов перевірку." #: src/app/main/ui/dashboard/team.cljs:1204 msgid "errors.webhooks.last-delivery" @@ -1687,7 +1687,7 @@ msgstr "Типографія" #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:308 msgid "inspect.attributes.typography.font-family" -msgstr "Сімейство шрифта" +msgstr "Сімейство Шрифта" #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:326, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:332 msgid "inspect.attributes.typography.font-size" @@ -2061,7 +2061,7 @@ msgstr "Фіґма" #: src/app/main/ui/dashboard/fonts.cljs:432 msgid "labels.font-family" -msgstr "Сімейство шрифтів" +msgstr "Сімейство Шрифтів" #, unused msgid "labels.font-providers" @@ -2745,7 +2745,7 @@ msgstr "Створити вебхук" #: src/app/main/ui/dashboard/team.cljs:1103 msgid "modals.create-webhook.url.label" -msgstr "Посилання на Payload" +msgstr "Адреса пейлоду" #: src/app/main/ui/dashboard/team.cljs:1104 msgid "modals.create-webhook.url.placeholder" @@ -4408,7 +4408,7 @@ msgstr "" #: src/app/main/ui/settings/subscription.cljs:259 msgid "subscription.settings.management.dialog.payment-explanation" -msgstr "(Платіж не буде зроблено)" +msgstr "Плата опісля пробного терміну. Наразі кредитна карта не потрібна." #: src/app/main/ui/settings/subscription.cljs:252, src/app/main/ui/settings/subscription.cljs:256 #, markdown @@ -4462,17 +4462,16 @@ msgstr "" "що у подробицях облікового запису." #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "Насолоджуйтесь планом!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "Ви %s!" #: src/app/main/ui/settings/subscription.cljs:526 -#, fuzzy msgid "subscription.settings.support-us-since" -msgstr "Ви підтримуєте нас за цим планом з %s" +msgstr "Ви підтримуєте нас цим планом з: %s" #: src/app/main/ui/settings/subscription.cljs:558, src/app/main/ui/settings/subscription.cljs:574 msgid "subscription.settings.try-it-free" @@ -4802,7 +4801,7 @@ msgstr "Сортувати" #: src/app/main/ui/dashboard/grid.cljs:165, src/app/main/ui/dashboard/grid.cljs:220, src/app/main/ui/workspace/sidebar/assets/typographies.cljs:396, src/app/main/ui/workspace/sidebar/assets.cljs:161 msgid "workspace.assets.typography" -msgstr "Типографіка" +msgstr "Типографіки" #: src/app/main/ui/workspace/sidebar/assets/typographies.cljs:404 msgid "workspace.assets.typography.add-typography" @@ -5430,7 +5429,7 @@ msgstr "Деякі з цих варіантів мають помилкові і #: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:433 msgid "workspace.options.component.variant.malformed.structure.example" -msgstr "[property] = [value], [property] = [value]" +msgstr "[property] = [value], [property] = [value]" #: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:431 msgid "workspace.options.component.variant.malformed.structure.title" @@ -5821,7 +5820,7 @@ msgstr "Вікрити накладення: %s" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:61, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:351 msgid "workspace.options.interaction-open-url" -msgstr "Відкрити посилання" +msgstr "Перейти за URL" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs #, unused @@ -5894,7 +5893,7 @@ msgstr "Подразник" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:469 msgid "workspace.options.interaction-url" -msgstr "Посилання" +msgstr "URL-адреса" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:39, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:339 msgid "workspace.options.interaction-while-hovering" @@ -6488,7 +6487,7 @@ msgstr "Підкреслення (%s)" #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs #, unused msgid "workspace.options.text-options.uppercase" -msgstr "ВЕРХНІЙ РЕГІСТР" +msgstr "Верхній Регістр" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:788 msgid "workspace.options.use-play-button" @@ -6571,7 +6570,7 @@ msgstr "Щоб використовувати цей плагін, Ви маєт #: src/app/main/ui/workspace/plugins.cljs:189 msgid "workspace.plugins.error.url" -msgstr "Плагін не існує або посилання на нього неправильне." +msgstr "Плагін не існує або його URL недійсний." #: src/app/main/ui/workspace/plugins.cljs:185 msgid "workspace.plugins.install" @@ -6653,7 +6652,7 @@ msgstr "Видалити плагін" #: src/app/main/ui/workspace/plugins.cljs:180 msgid "workspace.plugins.search-placeholder" -msgstr "Вкажіть посилання на плагін" +msgstr "Вкажіть URL плагіну" #, unused msgid "workspace.plugins.success" @@ -7106,7 +7105,7 @@ msgid "workspace.tokens.edit-themes" msgstr "Редагувати теми" #: src/app/main/data/workspace/tokens/errors.cljs:41 -msgid "workspace.tokens.empty-input" +msgid "errors.tokens.empty-input" msgstr "Значення токену не може бути порожнім" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 @@ -7114,7 +7113,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "Вкажіть %s ім'я токену" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "Помилка імпорту: Неможливо обробити JSON." #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -7160,7 +7159,7 @@ msgid "workspace.tokens.grouping-set-alert" msgstr "Групування наборів токенів поки не підтримується." #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "Помилка імпорту:" #: src/app/main/ui/workspace/tokens/import/modal.cljs:241 @@ -7197,26 +7196,26 @@ msgstr "" "області перегляду" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "Помилкове значення кольору: %s" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "Помилка імпорту: Помилкові дани токену в JSON." #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "Помилка імпорту: Помилкове імʼя токену в JSON." #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "\"%s\" це не дійсне імʼя токену.\n" "Імена токену можуть містити в собі літери та цифри, розділені крапкою та не " "повинні починатись з $." #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "Помилкове значення токену: %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -7248,7 +7247,7 @@ msgid "workspace.tokens.min-size" msgstr "Мін. розмір" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "Відсутні посилання на токен: " #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:135 @@ -7288,15 +7287,14 @@ msgid "workspace.tokens.num-active-sets" msgstr "%s активних наборів" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "Помилкове значення токену. Отримане значення завелике: %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "Непрозорість має бути між 0 та 100% або ж між 0 та 1 (де 0.5 - 50%)." #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:121 -#, fuzzy msgid "workspace.tokens.original-value" msgstr "Початкове значення: %s" @@ -7318,7 +7316,6 @@ msgid "workspace.tokens.reference-error" msgstr "Помилка посилання: " #: src/app/main/data/workspace/tokens/warnings.cljs:15, src/app/main/data/workspace/tokens/warnings.cljs:19, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:56, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:84, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:103, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:285, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:459, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:176, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:311, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:251, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:364, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:465, src/app/main/ui/workspace/tokens/management/token_pill.cljs:122 -#, fuzzy msgid "workspace.tokens.resolved-value" msgstr "Отримане значення: %s" @@ -7331,7 +7328,7 @@ msgid "workspace.tokens.select-set" msgstr "Обрати набір." #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "Токен має самопосилання" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 @@ -7366,7 +7363,7 @@ msgid "workspace.tokens.size" msgstr "Розмір" #: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 -msgid "workspace.tokens.stroke-width-range" +msgid "errors.tokens.stroke-width-range" msgstr "Ширина обведення має бути більшим ніж 0 або дорівнювати 0." #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:154 @@ -7385,7 +7382,6 @@ msgid "workspace.tokens.themes-list" msgstr "Список тем" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:275, src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:276 -#, fuzzy msgid "workspace.tokens.token-description" msgstr "Опис" @@ -7430,7 +7426,7 @@ msgid "workspace.tokens.value-not-valid" msgstr "Значення не є дійсним" #: src/app/main/data/workspace/tokens/errors.cljs:65 -msgid "workspace.tokens.value-with-units" +msgid "errors.tokens.value-with-units" msgstr "Помилкове значення: Одиниці не дозволені." #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 @@ -7750,3 +7746,1081 @@ msgstr "Автозбережені версії зберігатимуться #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Натисність щоб закінчити шлях" + +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:101, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:108 +msgid "color-row.token-color-row.deleted-token" +msgstr "Даний токен не існує або був видалений." + +#: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:35 +msgid "color-token.empty-state" +msgstr "" +"Відсутні токени кольорів. Перевірте активні набори/теми чи додайте нові " +"токени." + +#: src/app/main/ui/dashboard/file_menu.cljs:208 +msgid "dashboard-restore-file-confirmation.description" +msgstr "Ви збираєтесь відновити %s." + +#: src/app/main/ui/dashboard/file_menu.cljs:207 +msgid "dashboard-restore-file-confirmation.title" +msgstr "Відновити файл" + +#: src/app/main/ui/dashboard/deleted.cljs:313 +msgid "dashboard.clear-trash-button" +msgstr "Звільнити кошик" + +#: src/app/main/ui/dashboard/sidebar.cljs:347 +msgid "dashboard.create-new-org" +msgstr "Створити організацію" + +#: src/app/main/ui/dashboard/deleted.cljs:262 +msgid "dashboard.delete-all-forever-confirmation.description" +msgstr "" +"Ви впевнені що хочете назавжди позбутись видалених проєктів та файлів? Це " +"незворотня дія." + +#: src/app/main/ui/dashboard/file_menu.cljs:221 +msgid "dashboard.delete-file-forever-confirmation.description" +msgstr "Ви впевнені що хочете назавжди позбутись %s? Це незворотня дія." + +#: src/app/main/data/dashboard.cljs:778 +msgid "dashboard.delete-files-success-notification" +msgstr "%s файлів було успішно вилучено." + +#: src/app/main/ui/dashboard/deleted.cljs:51, src/app/main/ui/dashboard/deleted.cljs:53, src/app/main/ui/dashboard/deleted.cljs:261, src/app/main/ui/dashboard/deleted.cljs:263, src/app/main/ui/dashboard/file_menu.cljs:220, src/app/main/ui/dashboard/file_menu.cljs:222 +msgid "dashboard.delete-forever-confirmation.title" +msgstr "Позбутись назавжди" + +#: src/app/main/ui/dashboard/deleted.cljs:85 +msgid "dashboard.delete-project-button" +msgstr "Вилучити проєкт" + +#: src/app/main/ui/dashboard/deleted.cljs:52 +msgid "dashboard.delete-project-forever-confirmation.description" +msgstr "" +"Ви впевнені що хочете назавжди позбутись %s проєктів? Ви позбудетесь їх та " +"файлів, що в них містились, назавжди. Це незворотня дія." + +#: src/app/main/data/dashboard.cljs:777, src/app/main/data/dashboard.cljs:811 +msgid "dashboard.delete-success-notification" +msgstr "%s було успішно вилучено." + +#: src/app/main/ui/dashboard/deleted.cljs:327 +msgid "dashboard.deleted.empty-state-description" +msgstr "Ваш смітник порожній. Видалені файли та проєкти зʼявлятимуться тут." + +#: src/app/main/ui/dashboard/grid.cljs:248 +msgid "dashboard.deleted.will-be-deleted-at" +msgstr "Буде вилучено %s" + +#, unused +msgid "dashboard.errors.error-on-delete-file" +msgstr "Сталася помилка під час вилучення файлу %s." + +#: src/app/main/data/dashboard.cljs:781 +msgid "dashboard.errors.error-on-delete-files" +msgstr "Сталася помилка під час вилучення файлів." + +#: src/app/main/data/dashboard.cljs:814 +msgid "dashboard.errors.error-on-delete-project" +msgstr "Сталася помилка під час вилучення проєкту %s." + +#: src/app/main/data/dashboard.cljs:909, src/app/main/ui/dashboard/file_menu.cljs:201 +msgid "dashboard.errors.error-on-restore-file" +msgstr "Сталася помилка під час відновлення файлу %s." + +#: src/app/main/data/dashboard.cljs:910 +msgid "dashboard.errors.error-on-restore-files" +msgstr "Сталася помилка під час відновлення файлів." + +#: src/app/main/data/dashboard.cljs:942 +msgid "dashboard.errors.error-on-restoring-project" +msgstr "Сталася помилка під час відновлення проєкту %s та його файлів." + +#: src/app/main/ui/dashboard/file_menu.cljs:266 +msgid "dashboard.file-menu.delete-files-permanently-option" +msgid_plural "dashboard.file-menu.delete-files-permanently-option" +msgstr[0] "Вилучити файл" +msgstr[1] "Вилучити кільки файлів" +msgstr[2] "Вилучити файли" + +#: src/app/main/ui/dashboard/file_menu.cljs:263 +msgid "dashboard.file-menu.restore-files-option" +msgid_plural "dashboard.file-menu.restore-files-option" +msgstr[0] "Відновити файл" +msgstr[1] "Відновити кілька файлів" +msgstr[2] "Відновити файли" + +#: src/app/main/ui/dashboard/team.cljs:765 +msgid "dashboard.invitation-modal.delete" +msgstr "Ви збираєтесь вилучити запрошення до:" + +#: src/app/main/ui/dashboard/team.cljs:766 +msgid "dashboard.invitation-modal.resend" +msgstr "Ви збираєтесь перенадіслати запрошення до:" + +#: src/app/main/ui/dashboard/team.cljs:756 +msgid "dashboard.invitation-modal.title.delete-invitations" +msgstr "Вилучити запрошення" + +#: src/app/main/ui/dashboard/team.cljs:757 +msgid "dashboard.invitation-modal.title.resend-invitations" +msgstr "Перенадіслати запрошення" + +#: src/app/main/ui/dashboard/team.cljs:949 +msgid "dashboard.order-invitations-by-role" +msgstr "Сортувати за роллю" + +#: src/app/main/ui/dashboard/team.cljs:958 +msgid "dashboard.order-invitations-by-status" +msgstr "Сортувати за статусом" + +#: src/app/main/data/dashboard.cljs:722 +msgid "dashboard.progress-notification.deleting-files" +msgstr "Вилучаю файли…" + +#: src/app/main/data/dashboard.cljs:843 +msgid "dashboard.progress-notification.restoring-files" +msgstr "Відновлюю файли…" + +#: src/app/main/data/dashboard.cljs:723 +msgid "dashboard.progress-notification.slow-delete" +msgstr "Вилучення триває неочікувано довго" + +#: src/app/main/data/dashboard.cljs:844 +msgid "dashboard.progress-notification.slow-restore" +msgstr "Відновлення триває неочікувано довго" + +#: src/app/main/ui/dashboard/deleted.cljs:274 +msgid "dashboard.restore-all-confirmation.description" +msgstr "" +"Ви збираєтесь відновити усі проєкти та їх файли. Це може зайняти деякий час." + +#: src/app/main/ui/dashboard/deleted.cljs:273 +msgid "dashboard.restore-all-confirmation.title" +msgstr "Відновити усі проєкти й файли" + +#: src/app/main/ui/dashboard/deleted.cljs:308 +msgid "dashboard.restore-all-deleted-button" +msgstr "Відновити Все" + +#: src/app/main/data/dashboard.cljs:903 +msgid "dashboard.restore-files-success-notification" +msgstr "%s файлів було успішно відновлено." + +#: src/app/main/ui/dashboard/deleted.cljs:82 +msgid "dashboard.restore-project-button" +msgstr "Відновити проєкт" + +#: src/app/main/ui/dashboard/deleted.cljs:41 +msgid "dashboard.restore-project-confirmation.description" +msgstr "Ви збираєтесь відновити %s проєктів та всі їхні файли." + +#: src/app/main/ui/dashboard/deleted.cljs:40 +msgid "dashboard.restore-project-confirmation.title" +msgstr "Відновити Проєкт" + +#: src/app/main/data/dashboard.cljs:875, src/app/main/data/dashboard.cljs:902, src/app/main/data/dashboard.cljs:939, src/app/main/ui/dashboard/file_menu.cljs:198 +msgid "dashboard.restore-success-notification" +msgstr "%s було успішно відновлено." + +#: src/app/main/ui/dashboard/deleted.cljs:298 +msgid "dashboard.trash-info-text-part1" +msgstr "Вилучені файли лишатимуться у смітнику на" + +#: src/app/main/ui/dashboard/deleted.cljs:300 +msgid "dashboard.trash-info-text-part2" +msgstr " %s днів. " + +#: src/app/main/ui/dashboard/deleted.cljs:301 +msgid "dashboard.trash-info-text-part3" +msgstr "Після цього, ви позбудетесь від них назавжди." + +#: src/app/main/ui/dashboard/deleted.cljs:303 +msgid "dashboard.trash-info-text-part4" +msgstr "" +"Якщо вирішете інакше, Ви маєте змогу відновити їх або позбутись їх назавжди " +"у меню кожного з них." + +#: src/app/main/ui/ds/controls/numeric_input.cljs:98 +msgid "ds.inputs.numeric-input.no-applicable-tokens" +msgstr "У активних наборах й темах не знайдено застосовних токенів." + +#: src/app/main/ui/ds/controls/numeric_input.cljs:99 +msgid "ds.inputs.numeric-input.no-matches" +msgstr "Збігів не виявлено." + +#: src/app/main/ui/ds/controls/numeric_input.cljs:652, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:141 +msgid "ds.inputs.numeric-input.open-token-list-dropdown" +msgstr "Відкрити список токенів" + +#: src/app/main/ui/ds/controls/utilities/token_field.cljs:91, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:136 +msgid "ds.inputs.token-field.detach-token" +msgstr "Відʼєднати токен" + +#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106 +msgid "ds.inputs.token-field.no-active-token-option" +msgstr "" +"Цей токен не міститься в жодному з активних наборів або має недійсне " +"значення." + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:296, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:240 +msgid "errors.field-max-length" +msgstr "Повинно містити щонайменше %s символів." + +#: src/app/main/errors.cljs:105 +msgid "errors.unexpected-exception" +msgstr "Неочікувана помилка: %s" + +#: src/app/main/ui/static.cljs:315 +msgid "errors.webgl-context-lost.desc-message" +msgstr "" +"WebGL припинив працювати. Просимо Вас перезавантажити сторінку, щоб скинути " +"його" + +#: src/app/main/ui/static.cljs:314 +msgid "errors.webgl-context-lost.main-message" +msgstr "От халепа! Полотно загубилось" + +#: src/app/main/ui/settings/feedback.cljs:122 +msgid "feedback.description-placeholder" +msgstr "Просимо Вас описати причину відгуку" + +#: src/app/main/ui/settings/feedback.cljs:143 +msgid "feedback.other-ways-contact" +msgstr "Інші способи звʼязатись з нами" + +#: src/app/main/ui/settings/feedback.cljs:126 +msgid "feedback.penpot.link" +msgstr "" +"Якщо відгук якось повʼязаний з файлом, чи проєктом, вкажіть посилання на " +"нього сюди:" + +#: src/app/main/ui/settings/feedback.cljs:101 +msgid "feedback.title-contact-us" +msgstr "Звʼяжіться з нами" + +#: src/app/main/ui/settings/feedback.cljs:110, src/app/main/ui/settings/feedback.cljs:111 +msgid "feedback.type" +msgstr "Тип" + +#: src/app/main/ui/settings/feedback.cljs:115 +msgid "feedback.type.doubt" +msgstr "Сумнів" + +#: src/app/main/ui/settings/feedback.cljs:113 +msgid "feedback.type.idea" +msgstr "Ідея" + +#: src/app/main/ui/settings/feedback.cljs:114 +msgid "feedback.type.issue" +msgstr "Вада" + +#: src/app/main/ui/exports/files.cljs:124 +msgid "files-download-modal.title" +msgstr "Вивантажити файли" + +#: src/app/main/ui/inspect/styles/rows/color_properties_row.cljs:120 +msgid "inspect.attributes.image.preview" +msgstr "Перегляд зображення заповнення фігури" + +#: src/app/main/ui/inspect/right_sidebar.cljs:170 +msgid "inspect.color-space-label" +msgstr "Обрати простір кольорів" + +#: src/app/main/ui/inspect/right_sidebar.cljs:238 +msgid "inspect.empty.more" +msgstr "Більше інфо" + +#: src/app/main/ui/inspect/right_sidebar.cljs:166 +msgid "inspect.layer-info" +msgstr "Дані шару" + +#: src/app/main/ui/inspect/styles/panels/tokens_panel.cljs:26 +msgid "inspect.tabs.styles.active-sets" +msgstr "Діючі набори" + +#: src/app/main/ui/inspect/styles/panels/tokens_panel.cljs:21 +msgid "inspect.tabs.styles.active-themes" +msgstr "Діючі теми" + +#: src/app/main/ui/inspect/styles/style_box.cljs:68 +msgid "inspect.tabs.styles.copy-shorthand" +msgstr "Копіювати скоропис CSS до буферу обміну" + +#: src/app/main/ui/inspect/styles/property_detail_copiable.cljs:51 +msgid "inspect.tabs.styles.copy-to-clipboard" +msgstr "Копіювати до буферу обміну" + +#: src/app/main/ui/inspect/styles/style_box.cljs:22 +#, unused +msgid "inspect.tabs.styles.geometry-panel" +msgstr "Розміри й Позиції" + +#: src/app/main/ui/inspect/styles/style_box.cljs:60, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:178 +msgid "inspect.tabs.styles.toggle-style" +msgstr "Перемкнути панель %s" + +#: src/app/main/ui/inspect/styles/style_box.cljs:21 +msgid "inspect.tabs.styles.token-panel" +msgstr "Набори й Теми Токенів" + +#: src/app/main/ui/inspect/styles/rows/color_properties_row.cljs:102, src/app/main/ui/inspect/styles/rows/properties_row.cljs:60 +msgid "inspect.tabs.styles.token-resolved-value" +msgstr "Знайдене значення:" + +#: src/app/main/ui/inspect/styles/style_box.cljs:20 +msgid "inspect.tabs.styles.variants-panel" +msgstr "Параметри Варіанту" + +#: src/app/main/ui/dashboard/sidebar.cljs:1138 +msgid "labels.about-penpot" +msgstr "Про Penpot" + +#: src/app/main/ui/inspect/styles/style_box.cljs:26 +msgid "labels.blur" +msgstr "Розмиття" + +#: src/app/main/ui/workspace/colorpicker.cljs:424 +msgid "labels.color" +msgstr "Колір" + +#: src/app/main/ui/dashboard/sidebar.cljs:1125 +msgid "labels.community-contributions" +msgstr "Спільнота й Внески" + +#: src/app/main/ui/inspect/right_sidebar.cljs:110 +msgid "labels.computed" +msgstr "Обчислено" + +#: src/app/main/ui/static.cljs:415 +msgid "labels.contact-support" +msgstr "Звʼязатись з підтримкою" + +#: src/app/main/ui/settings/sidebar.cljs:136 +msgid "labels.contact-us" +msgstr "Звʼяжіться з нами" + +#: src/app/main/ui/static.cljs:67 +msgid "labels.copyright-period" +msgstr "Kaleidos © 2019-дотепер" + +#: src/app/main/ui/dashboard/deleted.cljs:215 +msgid "labels.deleted" +msgstr "Вилучено" + +#: src/app/main/ui/settings/feedback.cljs:134, src/app/main/ui/static.cljs:409 +msgid "labels.download" +msgstr "Завантажити %s" + +#: src/app/main/ui/inspect/styles/style_box.cljs:23 +msgid "labels.fill" +msgstr "Заповнити" + +#: src/app/main/ui/dashboard/sidebar.cljs:1114 +msgid "labels.help-learning" +msgstr "Допомога й Навчання" + +#: src/app/main/ui/static.cljs:405 +msgid "labels.internal-error.desc-message-first" +msgstr "Сталася халепа." + +#: src/app/main/ui/static.cljs:406 +msgid "labels.internal-error.desc-message-second" +msgstr "Спробуйте знову чи повідомте службу підтримки про помилку." + +#: src/app/main/ui/inspect/styles/style_box.cljs:28 +msgid "labels.layout" +msgstr "Шаблон" + +#: src/app/main/ui/dashboard/sidebar.cljs:893 +msgid "labels.learning-center" +msgstr "Навчальний Центр" + +#: src/app/main/ui/ds/controls/numeric_input.cljs:631 +msgid "labels.mixed-values" +msgstr "Змішано" + +#: src/app/main/ui/dashboard/sidebar.cljs:973 +msgid "labels.penpot-changelog" +msgstr "Список Змін Penpot" + +#: src/app/main/ui/dashboard/sidebar.cljs:899 +msgid "labels.penpot-hub" +msgstr "Хаб Penpot" + +#: src/app/main/ui/dashboard/sidebar.cljs:846 +msgid "labels.pinned-projects" +msgstr "Пришпилені проєкти" + +#: src/app/main/ui/dashboard/deleted.cljs:208 +msgid "labels.recent" +msgstr "Нещодавні" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:205, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:179 +msgid "labels.reference" +msgstr "Посилання" + +#: src/app/main/ui/static.cljs:318 +msgid "labels.reload-page" +msgstr "Перезавантажити сторінку" + +#: src/app/main/ui/dashboard/team.cljs:788 +msgid "labels.resend" +msgstr "Перенадіслати" + +#: src/app/main/ui/inspect/styles/style_box.cljs:27, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:229 +msgid "labels.shadow" +msgstr "Тінь" + +#: src/app/main/ui/dashboard/sidebar.cljs:825 +msgid "labels.sources" +msgstr "Джерела" + +#: src/app/main/ui/inspect/styles/style_box.cljs:24, src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:46 +msgid "labels.stroke" +msgstr "Обрамлення" + +#: src/app/main/ui/inspect/right_sidebar.cljs:108, src/app/main/ui/inspect/styles.cljs:135 +msgid "labels.styles" +msgstr "Стилі" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:261 +msgid "labels.switch" +msgstr "Перемикач" + +#: src/app/main/ui/inspect/styles/style_box.cljs:25 +msgid "labels.text" +msgstr "Текст" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:189 +msgid "labels.typography" +msgstr "Типографіка" + +#: src/app/main/ui/workspace/libraries.cljs:103, src/app/main/ui/workspace/libraries.cljs:130 +msgid "workspace.libraries.typography" +msgid_plural "workspace.libraries.typography" +msgstr[0] "%s типографіка" +msgstr[1] "%s типографіки" +msgstr[2] "%s типографік" + +#: src/app/main/ui/inspect/right_sidebar.cljs:66, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1039 +msgid "labels.variant" +msgstr "Варіянт" + +#: src/app/main/ui/dashboard/sidebar.cljs:967 +msgid "labels.version-notes" +msgstr "Примітки версії %s" + +#: src/app/main/ui/inspect/styles/style_box.cljs:32 +msgid "labels.visibility" +msgstr "Видимість" + +#: src/app/main/ui/dashboard/team.cljs:825 +msgid "notifications.invitation-deleted" +msgstr "Запрошення вилучено успішно" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:97 +msgid "shortcuts.create-component-variant" +msgstr "Створити компонент/варіянт" + +#: src/app/main/ui/dashboard/subscription.cljs:84 +msgid "subscription.dashboard.power-up.professional.bottom-button" +msgstr "Підсилитись!" + +#: src/app/main/ui/dashboard/subscription.cljs:83 +msgid "subscription.dashboard.power-up.professional.bottom-description" +msgstr "Отримайте більше сховища, відновлення файлів та інше для Ваших команд." + +#: src/app/main/ui/dashboard/subscription.cljs:196 +msgid "subscription.dashboard.professional-dashboard-cta-title" +msgstr "" +"Ви маєте %s редакторів поміж Вашими командами, коли як професійний план " +"покриває до 8." + +#: src/app/main/ui/dashboard/subscription.cljs:204 +#, markdown +msgid "subscription.dashboard.professional-dashboard-cta-upgrade-owner" +msgstr "" +"Будь ласка оновіться до Безлімітного чи Корпоративного щоб мати більше " +"редакторів, сховища та відновлення файлів. [Підʼєднайте зараз.|target:self]" +"(%s)" + +#: src/app/main/ui/dashboard/subscription.cljs:199 +msgid "subscription.dashboard.unlimited-dashboard-cta-title" +msgstr "" +"Ваша команда продовжує рости! Ваш безлімітний план покриває до %s " +"редакторів, проте Ви маєте %s." + +#: src/app/main/ui/dashboard/subscription.cljs:207 +#, markdown +msgid "subscription.dashboard.unlimited-dashboard-cta-upgrade-owner" +msgstr "" +"Будь ласка оновіть план щоб відповідати поточній кількості редакторів. " +"[Підʼєднайте зараз.|target:self](%s)" + +#: src/app/main/ui/dashboard/subscription.cljs:184 +msgid "subscription.dashboard.unlimited-members-extra-editors-cta-text" +msgstr "" +"Тільки нові редактори поміж Ваших команд враховуються в майбутній платіж. " +"Фіксовані $175/місяць досі застосовуються на 25+ редакторів." + +#: src/app/main/ui/dashboard/subscription.cljs:180 +msgid "subscription.dashboard.unlimited-members-extra-editors-cta-title" +msgstr "Запрошення людей з Безлімітним планом" + +#: src/app/main/ui/settings/subscription.cljs:503, src/app/main/ui/settings/subscription.cljs:513, src/app/main/ui/settings/subscription.cljs:571 +msgid "subscription.settings.enterprise.unlimited-storage-benefit" +msgstr "Безлімітне сховище" + +#: src/app/main/ui/settings/subscription.cljs:298 +msgid "subscription.settings.management-dialog.step-2-add-payment-button" +msgstr "Додати платіжні засоби" + +#: src/app/main/ui/settings/subscription.cljs:285 +msgid "subscription.settings.management-dialog.step-2-description" +msgstr "" +"Додайте платіжні засоби щоб продовжити Вашу підписку після кінця пробного " +"терміну й продовжити підтримувати наш відкритий проєкт. Плату поки не буде " +"стягнуто." + +#: src/app/main/ui/settings/subscription.cljs:293 +msgid "subscription.settings.management-dialog.step-2-skip-button" +msgstr "Пропустити й розпочати пробний період" + +#: src/app/main/ui/settings/subscription.cljs:203 +msgid "subscription.settings.management-dialog.step-2-title" +msgstr "Допоможіть нам рости й зробіть свій пробний період простіше" + +#: src/app/main/ui/settings/subscription.cljs:209 +msgid "subscription.settings.management.dialog.currently-editors-title" +msgid_plural "subscription.settings.management.dialog.currently-editors-title" +msgstr[0] "Наразі Ви маєте %s людину, які можуть редагувати, поміж Ваших команд." +msgstr[1] "Наразі Ви маєте %s людини, які можуть редагувати, поміж Ваших команд." +msgstr[2] "Наразі Ви маєте %s людей, які можуть редагувати, поміж Ваших команд." + +#: src/app/main/ui/settings/subscription.cljs:211 +msgid "subscription.settings.management.dialog.editors" +msgstr "Редактори" + +#: src/app/main/ui/settings/subscription.cljs:218 +msgid "subscription.settings.management.dialog.editors-explanation" +msgstr "(Власники, Адміни та Редаткори. Глядачі не є Редакторами)" + +#: src/app/main/ui/settings/subscription.cljs:263 +msgid "subscription.settings.management.dialog.input-error" +msgstr "" +"Ви не можете встановити менше редакторів ніж маєте зараз. Змініть роль " +"(редактор/адмін на глядача) людей, які не редагують файли, в параметрах " +"команди." + +#: src/app/main/ui/settings/subscription.cljs:471, src/app/main/ui/settings/subscription.cljs:542 +msgid "subscription.settings.professional.autosave-benefit" +msgstr "7-денні автозбережені версії та відновлення файлів" + +#: src/app/main/ui/settings/subscription.cljs:470, src/app/main/ui/settings/subscription.cljs:541 +msgid "subscription.settings.professional.storage-benefit" +msgstr "10ГБ сховища" + +#: src/app/main/ui/settings/subscription.cljs:472, src/app/main/ui/settings/subscription.cljs:543 +msgid "subscription.settings.professional.teams-editors-benefit" +msgstr "Безлімітні команди. До 8 редакторів поміж Ваших команд." + +#: src/app/main/ui/settings/subscription.cljs:50 +msgid "subscription.settings.recommended" +msgstr "Рекомендовано" + +#: src/app/main/ui/settings/subscription.cljs:343 +msgid "subscription.settings.success.dialog.thanks" +msgstr "Дякуємо за вибір плану %s Penpot!" + +#: src/app/main/ui/settings/subscription.cljs:480, src/app/main/ui/settings/subscription.cljs:492, src/app/main/ui/settings/subscription.cljs:556 +msgid "subscription.settings.unlimited.autosave-benefit" +msgstr "30-денні автозбережені версії та відновлення файлів" + +#: src/app/main/ui/settings/subscription.cljs:479, src/app/main/ui/settings/subscription.cljs:491, src/app/main/ui/settings/subscription.cljs:555 +msgid "subscription.settings.unlimited.storage-benefit" +msgstr "25ГБ сховища" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:57 +#, markdown +msgid "subscription.workspace.versions.warning.enterprise.subtext-owner" +msgstr "Якщо бажаєте збільшити цей ліміт, напишіть нам на [%s](mailto)" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:59 +#, markdown +msgid "subscription.workspace.versions.warning.subtext-member" +msgstr "" +"Якщо бажаєте збільшити цей ліміт, звʼяжіться з власником команди: [%s]" +"(mailto)" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:58 +#, markdown +msgid "subscription.workspace.versions.warning.subtext-owner" +msgstr "Якщо бажаєте збільшити цей ліміт, [оновіть план|target:self](%s)" + +#: src/app/main/ui/dashboard/team.cljs:933 +msgid "team.invitations-selected" +msgid_plural "team.invitations-selected" +msgstr[0] "%s запрошення обрано" +msgstr[1] "%s запрошення обрано" +msgstr[2] "%s запрошень обрано" + +#: src/app/main/ui/viewer/header.cljs:187 +msgid "viewer.header.edit-in-workspace" +msgstr "Змінити в робочому просторі" + +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:81 +msgid "workspace.assets.component-group-options" +msgstr "Параметри групи компонентів" + +#: src/app/main/ui/workspace/colorpicker.cljs:428, src/app/main/ui/workspace/colorpicker.cljs:441 +msgid "workspace.colorpicker.color-tokens" +msgstr "Токени кольорів" + +#: src/app/main/ui/workspace/colorpicker.cljs:434 +msgid "workspace.colorpicker.get-color" +msgstr "Отримати колір" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:506 +msgid "workspace.component.swap.loop-error" +msgstr "Компоненти не можна вкладати в них же." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:505 +msgid "workspace.component.switch.loop-error-multi" +msgstr "Деякі копії не можна поміняти. Компоненти не можна вкладати в них же." + +#: src/app/main/ui/workspace/sidebar/debug.cljs:38 +msgid "workspace.debug.title" +msgstr "Інструменти відлагодження" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:422 +msgid "workspace.layout-grid.editor.margin.expand" +msgstr "Показати параметри внутрішнього відступу з 4 боків" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:449 +msgid "workspace.layout-item.fit-content-horizontal" +msgstr "Заповнити вміст (Горизонтально)" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:477 +msgid "workspace.layout-item.fit-content-vertical" +msgstr "Заповнити вміст (Вертикально)" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:465 +msgid "workspace.layout-item.fix-height" +msgstr "Зафіксувати висоту" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:439 +msgid "workspace.layout-item.fix-width" +msgstr "Зафіксувати ширину" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:471 +msgid "workspace.layout-item.height-100" +msgstr "Висота 100%" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:444 +msgid "workspace.layout-item.width-100" +msgstr "Ширина 100%" + +#: src/app/main/ui/workspace/libraries.cljs:100, src/app/main/ui/workspace/libraries.cljs:126 +msgid "workspace.libraries.colors" +msgid_plural "workspace.libraries.colors" +msgstr[0] "%s колір" +msgstr[1] "%s кольори" +msgstr[2] "%s кольорів" + +#: src/app/main/ui/workspace/libraries.cljs:94, src/app/main/ui/workspace/libraries.cljs:118 +msgid "workspace.libraries.components" +msgid_plural "workspace.libraries.components" +msgstr[0] "%s компонент" +msgstr[1] "%s компоненти" +msgstr[2] "%s компонентів" + +#: src/app/main/ui/workspace/libraries.cljs:338 +msgid "workspace.libraries.connected-to" +msgstr "Підʼєднано до" + +#: src/app/main/ui/workspace/libraries.cljs:97, src/app/main/ui/workspace/libraries.cljs:122 +msgid "workspace.libraries.graphics" +msgid_plural "workspace.libraries.graphics" +msgstr[0] "%s фігура" +msgstr[1] "%s фігури" +msgstr[2] "%s фігур" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:557 +msgid "workspace.options.component.variant.malformed.copy" +msgstr "" +"Цей компонент містить варіянти з недійсними іменами. Впевніться що кожен з " +"варіантів слідує відповідній структурі." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:560 +msgid "workspace.options.component.variant.malformed.locate" +msgstr "Знайти недійсні варіанти" + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:54 +msgid "workspace.options.component.variants-help-modal.intro" +msgstr "" +"Щоб зберегти зміни при перемиканні між варіянтами, Penpot зʼєднує шари, що:" + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:91 +msgid "workspace.options.component.variants-help-modal.outro" +msgstr "" +"Зміна одного з цих (тобто перейменування чи групування шару) ламає звʼязок, " +"та відкидання змін відновить це." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:67 +msgid "workspace.options.component.variants-help-modal.rule1" +msgstr "Розділяють одне імʼя." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:76 +msgid "workspace.options.component.variants-help-modal.rule2" +msgstr "Мають один й той самий тип." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:77 +msgid "workspace.options.component.variants-help-modal.rule2.detail" +msgstr "" +"Прямокутник, еліпс, криві та логічні операції вважаються за той самий тип." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:87 +msgid "workspace.options.component.variants-help-modal.rule3" +msgstr "Мають однаковий рівень у ієрархії." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:88 +msgid "workspace.options.component.variants-help-modal.rule3.detail" +msgstr "Групи, дошки та шаблони вважаються рівними." + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:616 +msgid "workspace.options.interaction-animation-direction-down" +msgstr "Вниз" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:593 +msgid "workspace.options.interaction-animation-direction-in" +msgstr "У" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:612 +msgid "workspace.options.interaction-animation-direction-left" +msgstr "Вліво" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:596 +msgid "workspace.options.interaction-animation-direction-out" +msgstr "З" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:608 +msgid "workspace.options.interaction-animation-direction-right" +msgstr "Вправо" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:620 +msgid "workspace.options.interaction-animation-direction-up" +msgstr "Вгору" + +#: src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs:265 +msgid "workspace.options.more-token-colors" +msgstr "Більше токенів кольорів" + +#: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:108, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:401 +msgid "workspace.options.orientation.horizontal" +msgstr "Горизонтально" + +#: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:104, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:397 +msgid "workspace.options.orientation.vertical" +msgstr "Вертикально" + +#: src/app/main/ui/workspace/plugins.cljs:287 +msgid "workspace.plugins.permissions.allow-localstorage" +msgstr "Зберігати дані в бровзері." + +#: src/app/main/ui/workspace/context_menu.cljs:619, src/app/main/ui/workspace/sidebar/assets/components.cljs:633, src/app/main/ui/workspace/sidebar/assets/groups.cljs:75, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1106 +msgid "workspace.shape.menu.combine-as-variants" +msgstr "Поєднати як варіянти" + +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:635 +msgid "workspace.shape.menu.combine-as-variants-error" +msgstr "Компонентам потрібно знаходитись на одній сторінці" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1156 +msgid "workspace.shape.menu.remove-variant-property.last-property" +msgstr "Варіант повинен містити принаймні один параметр" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:297 +msgid "workspace.sidebar.layers.filter" +msgstr "Фільтр" + +#: src/app/main/data/workspace/tokens/errors.cljs:101, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:251 +msgid "workspace.tokens.composite-line-height-needs-font-size" +msgstr "" +"Висота Лінії залежить від Розміру Шрифта. Додайте Розмір Шрифта щоб знайти " +"значення." + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:234 +msgid "workspace.tokens.edit-token" +msgstr "Змінити токен %s" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:129 +msgid "workspace.tokens.font-size-value-enter" +msgstr "Розмір шрифту чи {alias}" + +#: src/app/main/data/workspace/tokens/application.cljs:325 +msgid "workspace.tokens.font-variant-not-found" +msgstr "" +"Помилка під час встановлення розміру/стилю шрифта. Цей стиль шрифта не існує " +"у поточному шрифті" + +#: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:42, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:137 +msgid "workspace.tokens.font-weight-value-enter" +msgstr "Вага шрифту (300, Жирний Курсив...) чи {alias}" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:233 +msgid "workspace.tokens.import-button-prefix" +msgstr "Імпортувати %s" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:273 +msgid "workspace.tokens.import-menu-folder-option" +msgstr "Тека" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:271 +msgid "workspace.tokens.import-menu-json-option" +msgstr "Єдиний файл JSON" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:272 +msgid "workspace.tokens.import-menu-zip-option" +msgstr "ZIP-архів" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:240, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:195 +msgid "workspace.tokens.individual-tokens" +msgstr "Використовувати індивідуальні токени" + +#: src/app/main/data/workspace/tokens/errors.cljs:93 +msgid "workspace.tokens.invalid-font-family-token-value" +msgstr "Недійсне значення токену: можна лише посилатись на токен сімʼї шрифтів" + +#: src/app/main/data/workspace/tokens/errors.cljs:89 +msgid "workspace.tokens.invalid-font-weight-token-value" +msgstr "" +"Недійсне значення ваги шрифту: використовуйте числове значення (100-950) чи " +"стандартні назви (тонший, тонкий, звичайний, жирний, інше.) з опціональним " +"'Курсив' перед ними" + +#: src/app/main/data/workspace/tokens/errors.cljs:105 +msgid "workspace.tokens.invalid-shadow-type-token-value" +msgstr "Недійсний тип тіні: дозволено тільки 'innerShadow' чи 'dropShadow'" + +#: src/app/main/data/workspace/tokens/errors.cljs:81 +msgid "workspace.tokens.invalid-text-case-token-value" +msgstr "" +"Недійсне значення токену: тільки верхній чи нижній регістр, великі на " +"початку чи жодне з цих" + +#: src/app/main/data/workspace/tokens/errors.cljs:85 +msgid "workspace.tokens.invalid-text-decoration-token-value" +msgstr "" +"Недійсне значення токену: тільки жодне, нижнє підкреслення та закреслення" + +#: src/app/main/data/workspace/tokens/errors.cljs:117 +msgid "workspace.tokens.invalid-token-value-shadow" +msgstr "Недійсне значення: повинно посилатись на збірний токен тіні." + +#: src/app/main/data/workspace/tokens/errors.cljs:97 +msgid "workspace.tokens.invalid-token-value-typography" +msgstr "Недійсне значення: повинно посилатись на збірний токен типографіки." + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:153 +msgid "workspace.tokens.letter-spacing-value-enter-composite" +msgstr "Літеральний відступ чи {alias}" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:145 +msgid "workspace.tokens.line-height-value-enter" +msgstr "Висота лінії (множник, px, %) чи {alias}" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:303 +msgid "workspace.tokens.missing-reference" +msgstr "Відсутнє посилання" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 +msgid "workspace.tokens.more-options" +msgstr "Праве натискання миші для показу параметрів" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:87 +#, unused +msgid "workspace.tokens.no-references-found" +msgstr "Не знайдено посилань" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs +#, unused +msgid "workspace.tokens.no-remap-needed" +msgstr "" +"Цей токен наразі не застосовується у Вашому дизайні, потрібний перерозподіл." + +#: src/app/main/data/workspace/tokens/errors.cljs:19 +msgid "workspace.tokens.no-token-files-found" +msgstr "Жодних токенів, наборів чи тем у файлі не виявлено." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:95 +msgid "workspace.tokens.not-remap" +msgstr "Не перерозподіляти" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:178 +msgid "workspace.tokens.reference-composite" +msgstr "Введіть псевдо токену типографіки" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:204 +msgid "workspace.tokens.reference-composite-shadow" +msgstr "Введіть псевдо токену тіні" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:99 +msgid "workspace.tokens.remap" +msgstr "Перерозподілити токени" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:86 +msgid "workspace.tokens.remap-token-references-title" +msgstr "Перерозподілити усі токени що використовуть `%s` до `%s`?" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:88 +msgid "workspace.tokens.remap-warning-effects" +msgstr "Це змінить усі шари й посилання що використовують старе імʼя токену." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:89 +msgid "workspace.tokens.remap-warning-time" +msgstr "Ця дія може тривати довго." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:92 +#, unused +msgid "workspace.tokens.remapping-in-progress" +msgstr "Перерозподіляю посилання на токени..." + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:232 +msgid "workspace.tokens.shadow-add-shadow" +msgstr "Додати Тінь" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:161, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:162, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:165 +msgid "workspace.tokens.shadow-blur" +msgstr "Розмиття" + +#: src/app/main/data/workspace/tokens/errors.cljs:109 +msgid "workspace.tokens.shadow-blur-range" +msgstr "Розмиття тіні повинне бути більшим чи дорівнювати 0." + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:987, src/app/main/ui/workspace/tokens/management/create/form.cljs:988 +#, unused +msgid "workspace.tokens.shadow-color" +msgstr "Колір" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:114 +msgid "workspace.tokens.shadow-inset" +msgstr "Вставка" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:123 +msgid "workspace.tokens.shadow-remove-shadow" +msgstr "Вилучити Тінь" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:173, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:174, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:177 +msgid "workspace.tokens.shadow-spread" +msgstr "Розкид" + +#: src/app/main/data/workspace/tokens/errors.cljs:113 +msgid "workspace.tokens.shadow-spread-range" +msgstr "Розкид тіні має бути більшим чи дорівнювати 0." + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:1215 +#, unused +msgid "workspace.tokens.shadow-title" +msgstr "Тіні" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:281 +msgid "workspace.tokens.shadow-token-blur-value-error" +msgstr "Значення розмиття не може бути відʼємним" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:287 +#, unused +msgid "workspace.tokens.shadow-token-spread-value-error" +msgstr "Значення розкиду не може бути відʼємним" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:139, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:141 +msgid "workspace.tokens.shadow-x" +msgstr "Х" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:150, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:152 +msgid "workspace.tokens.shadow-y" +msgstr "" + +#: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:40, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:161 +msgid "workspace.tokens.text-case-value-enter" +msgstr "жоден | верхній регістр | нижній регістр | верхня на початку чи {alias}" + +#: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:41, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:169 +msgid "workspace.tokens.text-decoration-value-enter" +msgstr "жоден | підкреслення | закреслення чи {alias}" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:51 +#, unused +msgid "workspace.tokens.theme-name-already-exists" +msgstr "Тема з цим імʼям вже існує" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:100 +#, unused +msgid "workspace.tokens.theme.disable" +msgstr "Вимкнути" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:96 +#, unused +msgid "workspace.tokens.theme.enable" +msgstr "Задіяти" + +#: src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:122, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:246 +msgid "workspace.tokens.token-font-family-select" +msgstr "Обрати сімейство шрифтів" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:121 +msgid "workspace.tokens.token-font-family-value" +msgstr "Сімейство шрифта" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:120 +msgid "workspace.tokens.token-font-family-value-enter" +msgstr "Сімейство шрифта або список шрифтів, розділені комою (,)" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:268, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:223 +msgid "workspace.tokens.token-name-duplication-validation-error" +msgstr "Токен вже існує на шляху: %s" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:265, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:220 +msgid "workspace.tokens.token-name-length-validation-error" +msgstr "Імʼя повинне містити принаймні 1 символ" + +#: src/app/main/data/workspace/tokens/import_export.cljs:46 +msgid "workspace.tokens.unknown-token-type-message" +msgstr "Імпротування пройшло успішно. Деякі токени не були додані." + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:244, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:199 +msgid "workspace.tokens.use-reference" +msgstr "Використати посилання" + +#: src/app/main/data/workspace/tokens/errors.cljs:69 +msgid "workspace.tokens.value-with-percent" +msgstr "Недійсне значення: %s не дозволений." + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs +#, unused +msgid "workspace.tokens.warning-name-change" +msgstr "Перейменування токену зламає усі посилання на старе імʼя" + +#: src/app/main/ui/workspace/top_toolbar.cljs:231 +msgid "workspace.toolbar.debug" +msgstr "Інструменти відлагодження" + +#, unused +msgid "workspace.versions.locked-by-other" +msgstr "Ця версія заблокована %s і не може бути змінена" + +#, unused +msgid "workspace.versions.locked-by-you" +msgstr "Ця версія заблокована Вами" + +#, unused +msgid "workspace.versions.tooltip.locked-version" +msgstr "Заблокована версія - тільки творець може вносити зміни" + +#: src/app/main/ui/settings/subscription.cljs:266 +msgid "subscription.settings.management.dialog.unlimited-capped-warning" +msgstr "" +"Порада: Кількість місць можна збільшити зараз, щоб бути попереду запрошень. " +"На 25+ редакторів поміж команд, Ви насолоджуватиметесь фіксованою платою у " +"$175 на місяць." diff --git a/frontend/translations/zh_CN.po b/frontend/translations/zh_CN.po index 2eb79194f5..49dfdfabdc 100644 --- a/frontend/translations/zh_CN.po +++ b/frontend/translations/zh_CN.po @@ -4318,11 +4318,11 @@ msgid "subscription.settings.subscribe" msgstr "订阅" #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "享受您的计划!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "你是 %s!" #: src/app/main/ui/settings/subscription.cljs:526 @@ -6903,7 +6903,7 @@ msgid "workspace.tokens.edit-themes" msgstr "编辑主题" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "导入错误:不能解析 JSON。" #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -6940,7 +6940,7 @@ msgid "workspace.tokens.import-button-prefix" msgstr "导入 %s" #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "导入错误:" #: src/app/main/ui/workspace/tokens/import/modal.cljs:273 @@ -6973,37 +6973,37 @@ msgid "workspace.tokens.inactive-set-description" msgstr "此设置未启用。请更改主题或启用此设置以在视口中查看更改" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "无效的颜色值:%s" #: src/app/main/data/workspace/tokens/errors.cljs:89 -msgid "workspace.tokens.invalid-font-weight-token-value" +msgid "errors.tokens.invalid-font-weight-token-value" msgstr "字体粗细值无效:使用数值(100-950)或标准名称(细、亮、常规、粗体等),后跟可选的“斜体”" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "导入错误:JSON 中存在无效的令牌数据。" #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "导入错误;JSON中存在无效的令牌名。" #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "“%s” 不是有效的 token 名称。\n" "token 名称只能包含字母和数字,并以 . 字符分隔,并且不能以 $ 符号开头。" #: src/app/main/data/workspace/tokens/errors.cljs:81 -msgid "workspace.tokens.invalid-text-case-token-value" +msgid "errors.tokens.invalid-text-case-token-value" msgstr "无效的token值:仅接受无、大写、小写或大写" #: src/app/main/data/workspace/tokens/errors.cljs:85 -msgid "workspace.tokens.invalid-text-decoration-token-value" +msgid "errors.tokens.invalid-text-decoration-token-value" msgstr "无效的token值:仅接受无、下划线和删除线" #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "无效的令牌值:%s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -7035,7 +7035,7 @@ msgid "workspace.tokens.min-size" msgstr "最小尺寸" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "缺少token引用: " #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 @@ -7075,7 +7075,7 @@ msgid "workspace.tokens.no-themes-currently" msgstr "您目前没有主题。" #: src/app/main/data/workspace/tokens/errors.cljs:19 -msgid "workspace.tokens.no-token-files-found" +msgid "errors.tokens.no-token-files-found" msgstr "在此文件中未找到任何标记、集合或主题。" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:134 @@ -7118,7 +7118,7 @@ msgid "workspace.tokens.select-set" msgstr "选择集。" #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "Token存在自我引用" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 diff --git a/library/deps.edn b/library/deps.edn index 9edca59650..18861c635e 100644 --- a/library/deps.edn +++ b/library/deps.edn @@ -9,12 +9,12 @@ :aliases {:outdated - {:extra-deps {com.github.liquidz/antq {:mvn/version "RELEASE"}} + {:extra-deps {com.github.liquidz/antq {:mvn/version "2.11.1276"}} :main-opts ["-m" "antq.core"]} :jvm-repl {:extra-deps - {com.bhauman/rebel-readline {:mvn/version "RELEASE"}} + {com.bhauman/rebel-readline {:mvn/version "0.1.5"}} :main-opts ["-m" "rebel-readline.main"] :jvm-opts ["--sun-misc-unsafe-memory-access=allow"]} @@ -22,9 +22,9 @@ {:extra-paths ["dev"] :extra-deps {thheller/shadow-cljs {:mvn/version "3.2.1"} - com.bhauman/rebel-readline {:mvn/version "RELEASE"} - org.clojure/tools.namespace {:mvn/version "RELEASE"} - criterium/criterium {:mvn/version "RELEASE"}}} + com.bhauman/rebel-readline {:mvn/version "0.1.5"} + org.clojure/tools.namespace {:mvn/version "1.5.0"} + criterium/criterium {:mvn/version "0.4.6"}}} :shadow-cljs {:main-opts ["-m" "shadow.cljs.devtools.cli"] diff --git a/manage.sh b/manage.sh index eb87d41b14..332febdfe8 100755 --- a/manage.sh +++ b/manage.sh @@ -108,13 +108,57 @@ function log-devenv { } function run-devenv-tmux { + local extra_env_args=() + + while [[ $# -gt 0 ]]; do + case "$1" in + -e) + extra_env_args+=(-e "$2"); shift 2;; + -e*) + extra_env_args+=(-e "${1#-e}"); shift;; + *) + shift;; + esac + done + if [[ ! $(docker ps -f "name=penpot-devenv-main" -q) ]]; then start-devenv echo "Waiting for containers fully start (5s)..." sleep 5; fi - docker exec -ti penpot-devenv-main sudo -EH -u penpot PENPOT_PLUGIN_DEV=$PENPOT_PLUGIN_DEV /home/start-tmux.sh + docker exec -ti \ + "${extra_env_args[@]}" \ + penpot-devenv-main sudo -EH -u penpot PENPOT_PLUGIN_DEV=$PENPOT_PLUGIN_DEV /home/start-tmux.sh +} + + +function run-devenv-agentic { + local serena_context="desktop-app" + local serena_external_port="14281" + local serena_dashboard_external_port="14282" + + while [[ $# -gt 0 ]]; do + case "$1" in + --serena-context) + serena_context="$2"; shift 2;; + *) + shift;; + esac + done + + if [[ ! $(docker ps -f "name=penpot-devenv-main" -q) ]]; then + SERENA_EXTERNAL_PORT="$serena_external_port" \ + SERENA_DASHBOARD_EXTERNAL_PORT="$serena_dashboard_external_port" \ + start-devenv + echo "Waiting for containers fully start (5s)..." + sleep 5; + fi + + run-devenv-tmux \ + -e SERENA_ENABLED=true \ + -e SERENA_CONTEXT="$serena_context" \ + -e PENPOT_FLAGS="${PENPOT_FLAGS} enable-mcp" } function run-devenv-shell { @@ -358,6 +402,9 @@ function usage { echo "- stop-devenv Stops the development oriented docker compose service." echo "- drop-devenv Remove the development oriented docker compose containers, volumes and clean images." echo "- run-devenv Attaches to the running devenv container and starts development environment" + echo " Optional -e flags are forwarded to 'docker exec' (e.g. -e MY_VAR=value)." + echo "- run-devenv-agentic Like run-devenv but with additional processes for agentic development enabled." + echo " Options: --serena-context CONTEXT (default: desktop-app)" echo "- run-devenv-shell Attaches to the running devenv container and starts a bash shell." echo "- isolated-shell Starts a bash shell in a new devenv container." echo "- log-devenv Show logs of the running devenv docker compose service." @@ -405,6 +452,9 @@ case $1 in run-devenv) run-devenv-tmux ${@:2} ;; + run-devenv-agentic) + run-devenv-agentic ${@:2} + ;; run-devenv-shell) run-devenv-shell ${@:2} ;; diff --git a/mcp/.serena/memories/code_style_conventions.md b/mcp/.serena/memories/code_style_conventions.md deleted file mode 100644 index cc9b1c07ae..0000000000 --- a/mcp/.serena/memories/code_style_conventions.md +++ /dev/null @@ -1,25 +0,0 @@ -# Code Style and Conventions - -## General Principles -- **Object-Oriented Design**: VERY IMPORTANT: Use idiomatic, object-oriented style with explicit abstractions -- **Strategy Pattern**: Prefer explicitly typed interfaces over bare functions for non-trivial functionality -- **Clean Architecture**: Tools implement a common interface for consistent registration and execution - -## TypeScript Configuration -- **Strict Mode**: All strict TypeScript options enabled -- **Target**: ES2022 -- **Module System**: CommonJS -- **Declaration Files**: Generated with source maps - -## Naming Conventions -- **Classes**: PascalCase (e.g., `ExeceuteCodeTool`, `PenpotMcpServer`) -- **Interfaces**: PascalCase (e.g., `Tool`) -- **Methods**: camelCase (e.g., `execute`, `registerTools`) -- **Constants**: camelCase for readonly properties (e.g., `definition`) -- **Files**: PascalCase for classes (e.g., `ExecuteCodeTool.ts`) - -## Documentation Style -- **JSDoc**: Use comprehensive JSDoc comments for classes, methods, and interfaces -- **Description Format**: Initial elliptical phrase that defines *what* it is, followed by details -- **Comment Style**: VERY IMPORTANT: Start with lowercase for comments of code blocks (unless lengthy explanation with multiple sentences) - diff --git a/mcp/.serena/memories/project_overview.md b/mcp/.serena/memories/project_overview.md index 7e80a5e2d5..64460c0289 100644 --- a/mcp/.serena/memories/project_overview.md +++ b/mcp/.serena/memories/project_overview.md @@ -1,53 +1,69 @@ -# Penpot MCP Project Overview - Updated +# Penpot MCP -## Purpose -This project is a Model Context Protocol (MCP) server for Penpot integration. +This subproject provides an MCP server for Penpot integration. The MCP server communicates with a Penpot plugin via WebSockets, allowing -the MCP server to send tasks to the plugin and receive results, +the MCP server to send tasks to the plugin and receive results, enabling advanced AI-driven features in Penpot. ## Tech Stack -- **Language**: TypeScript -- **Runtime**: Node.js -- **Framework**: MCP SDK (@modelcontextprotocol/sdk) -- **Build Tool**: TypeScript Compiler (tsc) + esbuild -- **Package Manager**: pnpm -- **WebSocket**: ws library for real-time communication -## Project Structure +- Language: TypeScript +- Runtime: Node.js +- Framework: MCP SDK (@modelcontextprotocol/sdk) +- Build Tool: TypeScript Compiler (tsc) + esbuild +- Package Manager: pnpm + +## General Principles + +IMPORTANT: Use an idiomatic, object-oriented style. +In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions +rather than mere functions (i.e. use the strategy pattern, for example). + +Comments: +When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase +clearly defines *what* it is. Any details then follow in subsequent sentences. + +When describing what blocks of code do, you also use an elliptical style and start with a lower-case letter unless +the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is +required for sentences). + +## Project Structure (Excerpt) + ``` / (project root) ├── packages/common/ # Shared type definitions │ ├── src/ -│ │ ├── index.ts # Exports for shared types +│ │ ├── index.ts # exports for shared types │ │ └── types.ts # PluginTaskResult, request/response interfaces │ └── package.json # @penpot-mcp/common package -├── packages/server/ # Main MCP server implementation +├── packages/server/ # MCP server subproject │ ├── src/ -│ │ ├── index.ts # Main server entry point -│ │ ├── PenpotMcpServer.ts # Enhanced with request/response correlation -│ │ ├── PluginTask.ts # Now supports result promises +│ │ ├── index.ts # entry point +│ │ ├── PenpotMcpServer.ts # MCP server implementation (connection handling, tool registration, etc.) +│ │ ├── Tool.ts # base class for tools +│ │ ├── PluginTask.ts # base class for plugin tasks │ │ ├── tasks/ # PluginTask implementations │ │ └── tools/ # Tool implementations -| ├── data/ # Contains resources, such as API info and prompts -│ └── package.json # Includes @penpot-mcp/common dependency -├── packages/plugin/ # Penpot plugin with response capability +| ├── data/ # contains resources, such as API info and prompts +│ └── package.json +├── packages/plugin/ # Penpot plugin subproject │ ├── src/ -│ │ ├── main.ts # Enhanced WebSocket handling with response forwarding -│ │ └── plugin.ts # Now sends task responses back to server +│ │ ├── main.ts # handles communication +│ │ └── plugin.ts # plugin implementation │ └── package.json # Includes @penpot-mcp/common dependency └── prepare-api-docs # Python project for the generation of API docs ``` -## Key Tasks +## Key Development Tasks -### Adjusting the System Prompt +### Adjusting the Prompts -The system prompt file is located in `packages/server/data/initial_instructions.md`. +The system prompt file (aka Penpot High-Level Overview) is located in +`packages/server/data/initial_instructions.md`. ### Adding a new Tool -1. Implement the tool class in `packages/server/src/tools/` following the `Tool` interface. +1. Implement the tool class in `packages/server/src/tools/` following the `Tool` interface. IMPORTANT: Do not catch any exceptions in the `executeCore` method. Let them propagate to be handled centrally. 2. Register the tool in `PenpotMcpServer`. @@ -62,3 +78,10 @@ Many tools build on `ExecuteCodePluginTask`, as many operations can be reduced t * In the success case, call `task.sendSuccess`. * In the failure case, just throw an exception, which will be handled centrally! 4. Register the task handler in `packages/plugin/src/plugin.ts` in the `taskHandlers` list. + +## Dev Tooling + +From the project root directory, run + +* `pnpm run build` to test the build of all package +* `pnpm run fmt` to apply the auto-formatter diff --git a/mcp/.serena/memories/suggested_commands.md b/mcp/.serena/memories/suggested_commands.md deleted file mode 100644 index 34f92fbae7..0000000000 --- a/mcp/.serena/memories/suggested_commands.md +++ /dev/null @@ -1,70 +0,0 @@ -# Suggested Commands - -## Development Commands -```bash -# Navigate to MCP server directory -cd penpot/mcp/server - -# Install dependencies -pnpm install - -# Build the TypeScript project -pnpm run build - -# Start the server (production) -pnpm run start - -# Start the server in development mode -npm run start:dev -``` - -## Testing and Development -```bash -# Run TypeScript compiler in watch mode -pnpx tsc --watch - -# Check TypeScript compilation without emitting files -pnpx tsc --noEmit -``` - -## Windows-Specific Commands -```cmd -# Directory navigation -cd penpot/mcp/server -dir # List directory contents -type package.json # Display file contents - -# Git operations -git status -git add . -git commit -m "message" -git push - -# File operations -copy src\file.ts backup\file.ts # Copy files -del dist\* # Delete files -mkdir new-directory # Create directory -rmdir /s directory # Remove directory recursively -``` - -## Project Structure Navigation -```bash -# Key directories -cd penpot/mcp/server/src # Source code -cd penpot/mcp/server/src/tools # Tool implementations -cd penpot/mcp/server/src/interfaces # Type definitions -cd penpot/mcp/server/dist # Compiled output -``` - -## Common Utilities -```cmd -# Search for text in files -findstr /s /i "HelloWorld" *.ts - -# Find files by name -dir /s /b *Tool.ts - -# Process management -tasklist | findstr node # Find Node.js processes -taskkill /f /im node.exe # Kill Node.js processes -``` diff --git a/mcp/.serena/memories/task_completion_guidelines.md b/mcp/.serena/memories/task_completion_guidelines.md deleted file mode 100644 index 678fd83e55..0000000000 --- a/mcp/.serena/memories/task_completion_guidelines.md +++ /dev/null @@ -1,56 +0,0 @@ -# Task Completion Guidelines - -## After Making Code Changes - -### 1. Build and Test -```bash -cd mcp-server -npm run build:full # or npm run build for faster bundling only -``` - -### 2. Verify TypeScript Compilation -```bash -npx tsc --noEmit -``` - -### 3. Test the Server -```bash -# Start in development mode to test changes -npm run dev -``` - -### 4. Code Quality Checks -- Ensure all code follows the established conventions -- Verify JSDoc comments are complete and accurate -- Check that error handling is appropriate -- Use clean imports WITHOUT file extensions (esbuild handles resolution) -- Validate that tool interfaces are properly implemented - -### 5. Integration Testing -- Test tool registration in the main server -- Verify MCP protocol compliance -- Ensure tool definitions match implementation - -## Before Committing Changes -1. **Build Successfully**: `npm run build:full` completes without errors -2. **No TypeScript Errors**: `npx tsc --noEmit` passes -3. **Documentation Updated**: JSDoc comments reflect changes -4. **Tool Registry Updated**: New tools added to `registerTools()` method -5. **Interface Compliance**: All tools implement the `Tool` interface correctly - -## File Organization -- Place new tools in `src/tools/` directory -- Update main server registration in `src/index.ts` -- Follow existing naming conventions - -## Common Patterns -- All tools must implement the `Tool` interface -- Use readonly properties for tool definitions -- Include comprehensive error handling -- Follow the established documentation style -- Import WITHOUT file extensions (esbuild resolves them automatically) - -## Build System -- Uses esbuild for fast bundling and TypeScript for declarations -- Import statements should omit file extensions entirely -- IDE refactoring is safe - no extension-related build failures \ No newline at end of file diff --git a/mcp/.serena/project.yml b/mcp/.serena/project.yml index e5729836cd..7fac475f69 100644 --- a/mcp/.serena/project.yml +++ b/mcp/.serena/project.yml @@ -1,11 +1,10 @@ -# whether to use the project's gitignore file to ignore files -# Added on 2025-04-07 + +# whether to use project's .gitignore files to ignore files ignore_all_files_in_gitignore: true -# list of additional paths to ignore -# same syntax as gitignore, so you can use * and ** -# Was previously called `ignored_dirs`, please update your config if you are using that. -# Added (renamed) on 2025-04-07 +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. ignored_paths: [] # whether the project is in read-only mode @@ -13,65 +12,15 @@ ignored_paths: [] # Added on 2025-04-18 read_only: false - - -# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. -# Below is the complete list of tools for convenience. -# To make sure you have the latest list of tools, and to view their descriptions, -# execute `uv run scripts/print_tool_overview.py`. -# -# * `activate_project`: Activates a project by name. -# * `check_onboarding_performed`: Checks whether project onboarding was already performed. -# * `create_text_file`: Creates/overwrites a file in the project directory. -# * `delete_lines`: Deletes a range of lines within a file. -# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. -# * `execute_shell_command`: Executes a shell command. -# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. -# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). -# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). -# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. -# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. -# * `initial_instructions`: Gets the initial instructions for the current project. -# Should only be used in settings where the system prompt cannot be set, -# e.g. in clients you have no control over, like Claude Desktop. -# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. -# * `insert_at_line`: Inserts content at a given line in a file. -# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. -# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). -# * `list_memories`: Lists memories in Serena's project-specific memory store. -# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). -# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). -# * `read_file`: Reads a file within the project directory. -# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. -# * `remove_project`: Removes a project from the Serena configuration. -# * `replace_lines`: Replaces a range of lines within a file with new content. -# * `replace_symbol_body`: Replaces the full definition of a symbol. -# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. -# * `search_for_pattern`: Performs a search for a pattern in the project. -# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. -# * `switch_modes`: Activates modes by providing a list of their names -# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. -# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. -# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. -# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html excluded_tools: [] # initial prompt for the project. It will always be given to the LLM upon activating the project # (contrary to the memories, which are loaded on demand). initial_prompt: | - IMPORTANT: You use an idiomatic, object-oriented style. - In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions - rather than mere functions (i.e. use the strategy pattern, for example). - - Always read the "project_overview" memory. - - Comments: - When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase - clearly defines *what* it is. Any details then follow in subsequent sentences. - - When describing what blocks of code do, you also use an elliptical style and start with a lower-case letter unless - the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is - required for sentences). + CRITICAL: Always read the "project_overview" memory. # the name by which the project can be referenced within Serena project_name: "penpot-mcp" @@ -83,18 +32,24 @@ project_name: "penpot-mcp" # Set this to a list of mode names to always include the respective modes for this project. base_modes: -# list of mode names that are to be activated by default. -# The full set of modes to be activated is base_modes + default_modes. -# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. # Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. # This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes default_modes: -# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default) +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html included_optional_tools: [] # fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. # This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html fixed_tools: [] # the encoding used by text files in the project @@ -102,23 +57,28 @@ fixed_tools: [] encoding: utf-8 - # list of languages for which language servers are started; choose from: -# al bash clojure cpp csharp -# csharp_omnisharp dart elixir elm erlang -# fortran fsharp go groovy haskell -# java julia kotlin lua markdown -# matlab nix pascal perl php -# powershell python python_jedi r rego -# ruby ruby_solargraph rust scala swift -# terraform toml typescript typescript_vts vue -# yaml zig +# al angular ansible bash clojure +# cpp cpp_ccls crystal csharp csharp_omnisharp +# dart elixir elm erlang fortran +# fsharp go groovy haskell haxe +# hlsl html java json julia +# kotlin lean4 lua luau markdown +# matlab msl nix ocaml pascal +# perl php php_phpactor powershell python +# python_jedi python_ty r rego ruby +# ruby_solargraph rust scala scss solidity +# svelte swift systemverilog terraform toml +# typescript typescript_vts vue yaml zig # (This list may be outdated. For the current list, see values of Language enum here: # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py # For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) # Note: # - For C, use cpp # - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) # - For Free Pascal/Lazarus, use pascal # Special requirements: # Some languages require additional setup/installations. @@ -164,3 +124,19 @@ ignored_memory_patterns: [] # Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. # No documentation on options means no options are available. ls_specific_settings: {} + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: + +# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries. +# Currently supported for: TypeScript. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +additional_workspace_folders: [] diff --git a/mcp/README.md b/mcp/README.md index 250bf792c5..b607db8016 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -264,21 +264,22 @@ The Penpot MCP server can be configured using environment variables. ### Server Configuration -| Environment Variable | Description | Default | -|------------------------------------|----------------------------------------------------------------------------|--------------| -| `PENPOT_MCP_SERVER_HOST` | Address on which the MCP server listens (binds to) | `localhost` | -| `PENPOT_MCP_SERVER_PORT` | Port for the HTTP/SSE server | `4401` | -| `PENPOT_MCP_WEBSOCKET_PORT` | Port for the WebSocket server (plugin connection) | `4402` | -| `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` | -| `PENPOT_MCP_SERVER_ADDRESS` | Hostname or IP address via which clients can reach the MCP server | `localhost` | -| `PENPOT_MCP_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` | +| Environment Variable | Description | Default | +|--------------------------------------------------|----------------------------------------------------------------------------|----------------| +| `PENPOT_MCP_SERVER_HOST` | Address on which the MCP server listens (binds to) | `localhost` | +| `PENPOT_MCP_SERVER_PORT` | Port for the HTTP/SSE server | `4401` | +| `PENPOT_MCP_WEBSOCKET_PORT` | Port for the WebSocket server (plugin connection) | `4402` | +| `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` | +| `PENPOT_MCP_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_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS` | Maximum number of parallel export shape requests (multi-user mode only). | `0` (no limit) | ### Logging Configuration | Environment Variable | Description | Default | |------------------------|------------------------------------------------------|----------| | `PENPOT_MCP_LOG_LEVEL` | Log level: `trace`, `debug`, `info`, `warn`, `error` | `info` | -| `PENPOT_MCP_LOG_DIR` | Directory for log files | `logs` | +| `PENPOT_MCP_LOG_DIR` | Directory for log files; file logging is enabled iff this is set to a non-empty value | (unset) | ### Plugin Server Configuration diff --git a/mcp/bin/mcp-local.js b/mcp/bin/mcp-local.js index 65a2b0f763..b77b245dea 100644 --- a/mcp/bin/mcp-local.js +++ b/mcp/bin/mcp-local.js @@ -5,6 +5,12 @@ const fs = require("fs"); const path = require("path"); const root = path.resolve(__dirname, ".."); +const pkg = require(path.join(root, "package.json")); + +function pnpmVersion() { + const match = (pkg.packageManager || "").match(/^pnpm@([^+]+)/); + return match ? match[1] : "latest"; +} function run(command) { execSync(command, { cwd: root, stdio: "inherit" }); @@ -19,13 +25,7 @@ if (fs.existsSync(distLock)) { } try { - run("corepack pnpm run bootstrap"); + run(`npx -y pnpm@${pnpmVersion()} run bootstrap`); } catch (error) { - if (error.code === "ENOENT") { - console.error( - "corepack is required but was not found. It ships with Node.js >= 16." - ); - process.exit(1); - } process.exit(error.status ?? 1); } diff --git a/mcp/packages/plugin/src/TaskHandler.ts b/mcp/packages/plugin/src/TaskHandler.ts index b09a6c0c17..a8f05a4229 100644 --- a/mcp/packages/plugin/src/TaskHandler.ts +++ b/mcp/packages/plugin/src/TaskHandler.ts @@ -24,8 +24,10 @@ export class Task { return; } + // create response object const response = { type: "task-response", + // NOTE: This inner response schema also constructed in main.ts/sendTaskResponse. response: { id: this.requestId, success: success, @@ -35,7 +37,29 @@ export class Task { }; // Send to main.ts which will forward to MCP server via WebSocket - penpot.ui.sendMessage(response); + try { + // TODO: Remove ts-ignore once Penpot types have been updated + // @ts-ignore + penpot.ui.sendMessage(response, true); + } catch (sendError) { + console.error("Failed to transfer task response for transmission to MCP server", sendError); + if (success) { + console.info("Original response was a success response, sending an error response instead."); + // If we were trying to send a success response, send an error response instead + let cause = sendError instanceof Error ? sendError.message : String(sendError); + this.sendError( + "Unexpected error sending task response: " + + cause + + " If you are an LLM and tried to serialize a regular object (shape, token, etc.), " + + "tell the user to report this error, providing information on the request you made." + ); + } else { + console.info( + "Original response was an error response, abandoning attempt to send alternative response to avoid infinite error loop." + ); + } + return; + } console.log("Sent task response:", response); this.isResponseSent = true; } diff --git a/mcp/packages/plugin/src/main.ts b/mcp/packages/plugin/src/main.ts index 40b5bd7ba8..94210da288 100644 --- a/mcp/packages/plugin/src/main.ts +++ b/mcp/packages/plugin/src/main.ts @@ -1,12 +1,29 @@ import "./style.css"; +/** + * the maximum allowed size for task responses sent back to the MCP server in the integrated remote MCP mode. + * This bounds the JSON response size. + * Note that in the remote MCP case, responses are transferred to LLMs (not the file system) and LLMs have + * size limitations. This serves to bound the size of returned images in particular. + * Too many overly large simultaneous responses can cause OOM issues in the MCP server, so this contributes + * to bounding memory usage in the centrally provided MCP server. + */ +const MAX_TASK_RESPONSE_SIZE_REMOTE_MCP = 15_000_000; + // get the current theme from the URL const searchParams = new URLSearchParams(window.location.hash.split("?")[1]); document.body.dataset.theme = searchParams.get("theme") ?? "light"; -// WebSocket connection management +// WebSocket connection to the MCP server let ws: WebSocket | null = null; +/** + * indicates whether the plugin is running with the Penpot-integrated remote MCP server enabled + * (as opposed to a local server used with the explicitly loaded plugin); + * set via the "mcp-mode" message sent by plugin.ts on initialization + */ +let isIntegratedRemoteMcp = false; + const statusPill = document.getElementById("connection-status") as HTMLElement; const statusText = document.getElementById("status-text") as HTMLElement; const currentTaskEl = document.getElementById("current-task") as HTMLElement; @@ -79,7 +96,22 @@ function updateExecutedCode(code: string | null): void { */ function sendTaskResponse(response: any): void { if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify(response)); + let responseString = JSON.stringify(response); + if (isIntegratedRemoteMcp && responseString.length > MAX_TASK_RESPONSE_SIZE_REMOTE_MCP) { + const errorMessage = `Serialised response size (${responseString.length}) exceeds maximum of ${MAX_TASK_RESPONSE_SIZE_REMOTE_MCP}.`; + console.warn( + errorMessage + + " [integrated remote MCP mode restriction]; sending error response instead; original response:", + response + ); + response = { + id: response.id, + success: false, + error: errorMessage, + }; + responseString = JSON.stringify(response); + } + ws.send(responseString); console.log("Sent response to MCP server:", response); } else { console.error("WebSocket not connected, cannot send response"); @@ -176,6 +208,9 @@ disconnectBtn?.addEventListener("click", () => { // Listen plugin.ts messages window.addEventListener("message", (event) => { + if (event.data.type === "mcp-mode") { + isIntegratedRemoteMcp = event.data.integratedRemoteMcp; + } if (event.data.type === "start-server") { connectToMcpServer(event.data.url, event.data.token); } diff --git a/mcp/packages/plugin/src/plugin.ts b/mcp/packages/plugin/src/plugin.ts index 3827db70eb..34e0349e96 100644 --- a/mcp/packages/plugin/src/plugin.ts +++ b/mcp/packages/plugin/src/plugin.ts @@ -1,6 +1,12 @@ import { ExecuteCodeTaskHandler } from "./task-handlers/ExecuteCodeTaskHandler"; import { Task, TaskHandler } from "./TaskHandler"; +/** + * indicates whether the plugin is running in an environment with the Penpot-integrated remote MCP server + * enabled (as opposed to a local server used with the explicitly loaded plugin) + */ +const isIntegratedRemoteMcp = !!mcp; + /** * Extracts the major.minor.patch prefix from a version string. * @@ -23,12 +29,17 @@ const taskHandlers: TaskHandler[] = [new ExecuteCodeTaskHandler()]; penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}`, { width: 236, height: 210, - hidden: !!mcp, + hidden: isIntegratedRemoteMcp, } as any); // Register message handlers penpot.ui.onMessage((message) => { if (typeof message === "object" && message.type === "ui-initialized") { + // Inform the UI about the operating mode + penpot.ui.sendMessage({ + type: "mcp-mode", + integratedRemoteMcp: isIntegratedRemoteMcp, + }); // Check Penpot version compatibility const penpotVersionPrefix = penpot.version ? extractVersionPrefix(penpot.version) : "<2.15"; // pre-2.15 versions don't have version info const mcpVersionPrefix = extractVersionPrefix(PENPOT_MCP_VERSION); @@ -42,7 +53,7 @@ penpot.ui.onMessage { console.log("Code execution result:", result); + // transform a top-level Uint8Array result into a compact base64 envelope to avoid the + // ~10x JSON expansion that occurs when JSON.stringify serializes typed arrays as objects + // with numeric string keys (see penpot/penpot#9420) + if (result instanceof Uint8Array) { + result = ExecuteCodeTaskHandler.encodeBytesAsBase64Envelope(result); + } + // return result and captured log let resultData: ExecuteCodeTaskResultData = { result: result, @@ -243,4 +250,23 @@ export class ExecuteCodeTaskHandler extends TaskHandler { }; task.sendSuccess(resultData); } + + /** + * Base64-encodes the given bytes and wraps the result in a tagged envelope that the + * server side recognizes (see `ImageContent.byteData`). + * + * @param bytes - the raw binary data to encode + * @returns an envelope of the form `{ __type: "base64", data: }` + */ + private static encodeBytesAsBase64Envelope(bytes: Uint8Array): { __type: "base64"; data: string } { + // build the binary string in chunks; calling `String.fromCharCode(...bytes)` directly + // would overflow the call stack for large arrays + const chunkSize = 0x8000; + let binary = ""; + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize); + binary += String.fromCharCode.apply(null, chunk as unknown as number[]); + } + return { __type: "base64", data: btoa(binary) }; + } } diff --git a/mcp/packages/server/data/api_types.yml b/mcp/packages/server/data/api_types.yml index 901037c249..54b4100e4a 100644 --- a/mcp/packages/server/data/api_types.yml +++ b/mcp/packages/server/data/api_types.yml @@ -26,6 +26,7 @@ Penpot: props?: { [key: string]: unknown }, ): symbol; off(listenerId: symbol): void; + version: string; root: Shape | null; currentFile: File | null; currentPage: Page | null; @@ -72,7 +73,7 @@ Penpot: generateFontFaces(shapes: Shape[]): Promise; openViewer(): void; createPage(): Page; - openPage(page: Page, newWindow?: boolean): void; + openPage(page: string | Page, newWindow?: boolean): void; alignHorizontal( shapes: Shape[], direction: "center" | "left" | "right", @@ -162,6 +163,12 @@ Penpot: ``` penpot.closePlugin(); ``` + version: |- + ``` + readonly version: string + ``` + + Returns the current penpot version. root: |- ``` readonly root: Shape | null @@ -725,19 +732,19 @@ Penpot: Returns Page openPage: |- ``` - openPage(page: Page, newWindow?: boolean): void + openPage(page: string | Page, newWindow?: boolean): void ``` Changes the current open page to given page. Requires `content:read` permission. Parameters - * page: Page + * page: string | Page - the page to open + the page to open (a Page object or a page UUID string) * newWindow: boolean - if true opens the page in a new window + if true opens the page in a new window, defaults to false Returns void @@ -4785,6 +4792,7 @@ Context: ``` interface Context { + version: string; root: Shape | null; currentFile: File | null; currentPage: Page | null; @@ -4837,7 +4845,7 @@ Context: removeListener(listenerId: symbol): void; openViewer(): void; createPage(): Page; - openPage(page: Page, newWindow?: boolean): void; + openPage(page: string | Page, newWindow?: boolean): void; alignHorizontal( shapes: Shape[], direction: "center" | "left" | "right", @@ -4854,6 +4862,12 @@ Context: ``` members: Properties: + version: |- + ``` + readonly version: string + ``` + + Returns the current penpot version. root: |- ``` readonly root: Shape | null @@ -5392,19 +5406,19 @@ Context: Returns Page openPage: |- ``` - openPage(page: Page, newWindow?: boolean): void + openPage(page: string | Page, newWindow?: boolean): void ``` Changes the current open page to given page. Requires `content:read` permission. Parameters - * page: Page + * page: string | Page - the page to open + the page to open (a Page object or a page UUID string) * newWindow: boolean - if true opens the page in a new window + if true opens the page in a new window, defaults to false Returns void @@ -6845,7 +6859,7 @@ Export: ``` interface Export { - type: "svg" | "png" | "jpeg" | "pdf"; + type: "svg" | "png" | "jpeg" | "webp" | "pdf"; scale?: number; suffix?: string; skipChildren?: boolean; @@ -6857,10 +6871,10 @@ Export: Properties: type: |- ``` - type: "svg" | "png" | "jpeg" | "pdf" + type: "svg" | "png" | "jpeg" | "webp" | "pdf" ``` - Type of the file to export. Can be one of the following values: png, jpeg, svg, pdf + Type of the file to export. Can be one of the following values: png, jpeg, webp, svg, pdf scale: |- ``` scale?: number @@ -7249,6 +7263,7 @@ Flags: ``` interface Flags { naturalChildOrdering: boolean; + throwValidationErrors: boolean; } ``` @@ -7264,6 +7279,14 @@ Flags: Also, appendChild method will be append the children in the top-most position. The insertchild method is changed acordingly to respect this ordering. Defaults to false + throwValidationErrors: |- + ``` + throwValidationErrors: boolean + ``` + + If `true` the validation errors will throw an exception instead of displaying an + error in the debugger console. + Defaults to false FlexLayout: overview: |- Interface FlexLayout diff --git a/mcp/packages/server/data/initial_instructions.md b/mcp/packages/server/data/initial_instructions.md index dbd2f565b7..0c129d6f38 100644 --- a/mcp/packages/server/data/initial_instructions.md +++ b/mcp/packages/server/data/initial_instructions.md @@ -269,8 +269,9 @@ Using library components: * create a new instance of the component on the current page: `const instance: Shape = component.instance();` This returns a `Shape` (often a `Board` containing child elements). - After instantiation, modify the instance's properties as desired. - * get the reference to the main component shape: + - After instantiation, modify the instance's properties as desired. + - Get a reference to the component an instance was created from via `instance.component()`. + * get the reference to the main instance (shape that serves as the source for new instances): `const mainShape: Shape = component.mainInstance();` Adding a component to a library: @@ -295,6 +296,7 @@ Variants are a system for grouping related component versions along named proper - `properties: string[]` (ordered list of property names); `addProperty(): void`, `renameProperty(pos, name)`, `currentValues(property)` - `variantComponents(): LibraryVariantComponent[]` * `LibraryVariantComponent` (extends `LibraryComponent`): full library component with metadata, for which `isVariant()` returns true. + - `variants: Variants` - `variantProps: { [property: string]: string }` (this component's value for each property) - `variantError` (non-null if e.g. two variants share the same combination of property values) - `setVariantProperty(pos, value)` @@ -313,6 +315,7 @@ Use `variantContainer.appendChild(mainInstance)` to move a component's main inst **Using Variants**: - `compInstance.switchVariant(pos, value)`: On a component instance, switches to the nearest variant that has the given value at property position `pos`, keeping all other property values the same. - To instantiate a specific variant, find the right `LibraryVariantComponent` by checking `variantProps`, then call `.instance()`. +- Given a variant component instance, access the component it was instantiated from via `instance.component()` and the `Variants` instance via `instance.component().variants`. # Design Tokens diff --git a/mcp/packages/server/package.json b/mcp/packages/server/package.json index 922be86975..58f8641887 100644 --- a/mcp/packages/server/package.json +++ b/mcp/packages/server/package.json @@ -5,13 +5,14 @@ "type": "module", "main": "dist/index.js", "scripts": { - "build:server": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js --external:@modelcontextprotocol/* --external:ws --external:express --external:class-transformer --external:class-validator --external:reflect-metadata --external:pino --external:pino-pretty --external:js-yaml --external:sharp", + "build:server": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js --external:@modelcontextprotocol/* --external:ws --external:express --external:class-transformer --external:class-validator --external:reflect-metadata --external:pino --external:pino-pretty --external:pino-loki --external:js-yaml --external:sharp --external:nrepl-client", "build": "pnpm run build:server && node scripts/copy-resources.js", "build:types": "tsc --emitDeclarationOnly --outDir dist", "start": "node dist/index.js", "start:multi-user": "node dist/index.js --multi-user", "start:dev": "node --import ts-node/register src/index.ts", "start:dev:multi-user": "node --loader ts-node/esm src/index.ts --multi-user", + "test:integration:export-sema": "tsx scripts/integration-test-export-image-semaphore.ts", "types:check": "tsc --noEmit", "clean": "rm -rf dist/" }, @@ -29,8 +30,10 @@ "class-validator": "^0.14.3", "express": "^5.1.0", "js-yaml": "^4.1.1", + "nrepl-client": "^0.3.0", "penpot-mcp": "file:..", "pino": "^9.10.0", + "pino-loki": "^2.6.0", "pino-pretty": "^13.1.1", "reflect-metadata": "^0.1.13", "sharp": "^0.34.5", @@ -38,14 +41,15 @@ "zod": "^4.3.6" }, "devDependencies": { - "cross-env": "^7.0.3", "@penpot/mcp-common": "workspace:../common", "@types/express": "^4.17.0", "@types/js-yaml": "^4.0.9", "@types/node": "^20.0.0", "@types/ws": "^8.5.10", + "cross-env": "^7.0.3", "esbuild": "^0.25.0", "ts-node": "^10.9.2", + "tsx": "^4.22.3", "typescript": "^5.0.0" }, "ts-node": { diff --git a/mcp/packages/server/scripts/integration-test-export-image-semaphore.ts b/mcp/packages/server/scripts/integration-test-export-image-semaphore.ts new file mode 100644 index 0000000000..807ae82afd --- /dev/null +++ b/mcp/packages/server/scripts/integration-test-export-image-semaphore.ts @@ -0,0 +1,116 @@ +/** + * One-off integration test for the parallelism bound around image exports. + * + * Setup: + * - Stubs ExportShapeTool.exportImage to sleep SLEEP_MS instead of doing real work, + * so no actual plugin connection is needed. + * - Replaces the static parallelism semaphore with one of size N. + * - Starts a PenpotMcpServer in multi-user mode on three random free ports. + * - Fires M > N parallel MCP clients that each call the export_shape tool. + * + * Expectations (observed manually from the server's console output): + * - "Semaphore 'ExportShapeTool' saturated; request queued (k waiting)" lines appear + * at INFO level (at least M - N of them). + * - All M tool calls return successfully. + * - Total elapsed wall-clock time is approximately ceil(M / N) * SLEEP_MS. + * + * Invoke from packages/server with: + * pnpm run test:integration:export + */ +import * as net from "node:net"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +import { PenpotMcpServer } from "../src/PenpotMcpServer"; +import { ExportShapeTool } from "../src/tools/ExportShapeTool"; +import { TextResponse } from "../src/ToolResponse"; +import { Semaphore } from "../src/utils/Semaphore"; + +// === parameters === + +const N = 3; +const M = 6; +const SLEEP_MS = 5_000; + +// === helpers === + +async function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.unref(); + srv.on("error", reject); + srv.listen(0, "127.0.0.1", () => { + const port = (srv.address() as net.AddressInfo).port; + srv.close(() => resolve(port)); + }); + }); +} + +async function callExportShape(url: URL, idx: number): Promise { + const client = new Client({ name: `integration-test-client-${idx}`, version: "0.0.0" }); + const transport = new StreamableHTTPClientTransport(url); + await client.connect(transport); + try { + return await client.callTool({ + name: "export_shape", + arguments: { shapeId: "selection" }, + }); + } finally { + await client.close(); + } +} + +// === main === + +async function main(): Promise { + // dynamic ports must be set before PenpotMcpServer is constructed + const httpPort = await findFreePort(); + process.env.PENPOT_MCP_SERVER_HOST = "127.0.0.1"; + process.env.PENPOT_MCP_SERVER_PORT = String(httpPort); + process.env.PENPOT_MCP_WEBSOCKET_PORT = String(await findFreePort()); + process.env.PENPOT_MCP_REPL_PORT = String(await findFreePort()); + + // shrink the gate and stub the worker + (ExportShapeTool as any).parallelismSemaphore = new Semaphore("ExportShapeTool", N); + (ExportShapeTool.prototype as any).exportImage = async (): Promise => { + await new Promise((r) => setTimeout(r, SLEEP_MS)); + return new TextResponse("stubbed export"); + }; + + const server = new PenpotMcpServer(/* isMultiUser */ true); + await server.start(); + + console.log(`\n=== integration test: N=${N} permits, M=${M} clients, sleep=${SLEEP_MS}ms ===\n`); + + // fire M parallel tool calls, each via its own MCP session with a distinct userToken + const start = Date.now(); + const results = await Promise.allSettled( + Array.from({ length: M }, (_, i) => + callExportShape(new URL(`http://127.0.0.1:${httpPort}/mcp?userToken=test-token-${i}`), i) + ) + ); + const elapsed = Date.now() - start; + + await server.stop(); + + // report + const failures = results.map((r, i) => ({ r, i })).filter(({ r }) => r.status === "rejected"); + + console.log(`\n=== results ===`); + console.log(` elapsed: ${elapsed}ms (expected ~${Math.ceil(M / N) * SLEEP_MS}ms)`); + console.log(` succeeded: ${M - failures.length}/${M}`); + if (failures.length > 0) { + console.log(` failures:`); + for (const { r, i } of failures) { + console.log(` [${i}] ${(r as PromiseRejectedResult).reason}`); + } + process.exit(1); + } + console.log(`\nAll ${M} tool calls succeeded. Scroll up to verify saturation log lines.\n`); + process.exit(0); +} + +main().catch((err) => { + console.error("Integration test crashed:", err); + process.exit(1); +}); diff --git a/mcp/packages/server/src/NreplClient.ts b/mcp/packages/server/src/NreplClient.ts new file mode 100644 index 0000000000..d550812594 --- /dev/null +++ b/mcp/packages/server/src/NreplClient.ts @@ -0,0 +1,217 @@ +import nreplClient from "nrepl-client"; +import type { NreplConnection, NreplMessage } from "nrepl-client"; +import { createLogger } from "./logger"; + +/** + * Result of evaluating a ClojureScript expression via nREPL. + */ +export interface NreplEvalResult { + /** the returned value(s) as strings */ + values: string[]; + /** captured stdout output */ + out: string; + /** captured stderr output */ + err: string; + /** the namespace after evaluation */ + ns: string; +} + +/** + * A client for communicating with a shadow-cljs nREPL server. + * + * This client maintains a persistent nREPL session, so that definitions, + * requires, and other state are preserved across evaluations — providing + * a full REPL experience. + */ +export class NreplClient { + private static readonly NREPL_PORT = 3447; + private static readonly NREPL_HOST = "localhost"; + private static readonly EVAL_TIMEOUT_MS = 30_000; + + private readonly logger = createLogger("NreplClient"); + + /** the persistent connection to the nREPL server, established lazily */ + private connection: NreplConnection | null = null; + + /** the cloned session ID that persists state across evaluations */ + private sessionId: string | null = null; + + /** + * Evaluates a Clojure expression on the nREPL server within the persistent session. + * + * @param code - the Clojure expression to evaluate + * @returns the evaluation result + */ + async eval(code: string): Promise { + this.logger.debug("Evaluating Clojure expression: %s", code); + const conn = await this.ensureConnection(); + const sessionId = await this.ensureSession(conn); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`nREPL evaluation timed out after ${NreplClient.EVAL_TIMEOUT_MS}ms`)); + }, NreplClient.EVAL_TIMEOUT_MS); + + conn.send({ op: "eval", code, session: sessionId }, (err: Error | null, result: NreplMessage[]) => { + clearTimeout(timeout); + if (err) { + reject(err); + return; + } + try { + resolve(this.parseEvalResult(result)); + } catch (parseErr) { + reject(parseErr); + } + }); + }); + } + + /** + * Evaluates a ClojureScript expression via the shadow-cljs CLJS eval API. + * + * The expression is wrapped in a call to `shadow.cljs.devtools.api/cljs-eval` + * targeting the `:main` build, so it is evaluated in the browser runtime. + * + * @param cljsCode - the ClojureScript expression to evaluate + * @returns the evaluation result + */ + async evalCljs(cljsCode: string): Promise { + // escape the CLJS code for embedding in a Clojure string + const escapedCode = cljsCode.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const wrappedCode = `(shadow.cljs.devtools.api/cljs-eval :main "${escapedCode}" {})`; + this.logger.debug("Evaluating CLJS expression via shadow-cljs: %s", cljsCode); + return this.eval(wrappedCode); + } + + /** + * Closes the persistent connection and session, releasing all resources. + */ + async close(): Promise { + if (this.connection) { + this.logger.info("Closing nREPL connection"); + this.connection.end(); + this.connection = null; + this.sessionId = null; + } + } + + /** + * Ensures a connection to the nREPL server is established, creating one if necessary. + * + * If the existing connection has been closed or errored, a new one is created. + */ + private async ensureConnection(): Promise { + if (this.connection && !this.connection.destroyed) { + return this.connection; + } + + // reset state since the old connection is gone + this.connection = null; + this.sessionId = null; + + this.logger.info("Connecting to nREPL server at %s:%d", NreplClient.NREPL_HOST, NreplClient.NREPL_PORT); + + return new Promise((resolve, reject) => { + const conn = nreplClient.connect({ + port: NreplClient.NREPL_PORT, + host: NreplClient.NREPL_HOST, + }); + + conn.once("connect", () => { + this.connection = conn; + + // handle unexpected disconnects so the next eval reconnects + conn.once("close", () => { + this.logger.warn("nREPL connection closed unexpectedly"); + this.connection = null; + this.sessionId = null; + }); + + conn.once("error", (err: Error) => { + this.logger.error("nREPL connection error: %s", err); + this.connection = null; + this.sessionId = null; + }); + + resolve(conn); + }); + + conn.once("error", (err: Error) => { + reject( + new Error( + `Failed to connect to nREPL server at ${NreplClient.NREPL_HOST}:${NreplClient.NREPL_PORT}: ${err.message}` + ) + ); + }); + }); + } + + /** + * Ensures a persistent nREPL session exists, cloning one from the server if necessary. + * + * A cloned session maintains its own state (namespace bindings, definitions, etc.) + * independently of other sessions. + */ + private async ensureSession(conn: NreplConnection): Promise { + if (this.sessionId) { + return this.sessionId; + } + + this.logger.info("Cloning new nREPL session"); + + return new Promise((resolve, reject) => { + conn.clone((err: Error | null, result: NreplMessage[]) => { + if (err) { + reject(new Error(`Failed to clone nREPL session: ${err.message}`)); + return; + } + + const sessionMsg = result.find((msg) => msg["new-session"] !== undefined) as any; + if (!sessionMsg) { + reject(new Error("nREPL clone response did not contain a new session ID")); + return; + } + + this.sessionId = sessionMsg["new-session"]; + this.logger.info("Cloned nREPL session: %s", this.sessionId); + resolve(this.sessionId!); + }); + }); + } + + /** + * Parses the raw nREPL response messages into a structured result. + */ + private parseEvalResult(messages: NreplMessage[]): NreplEvalResult { + const values: string[] = []; + const outParts: string[] = []; + const errParts: string[] = []; + let ns = "user"; + + for (const msg of messages) { + if (msg.value !== undefined) { + values.push(msg.value); + } + if (msg.out) { + outParts.push(msg.out); + } + if (msg.err) { + errParts.push(msg.err); + } + if (msg.ns) { + ns = msg.ns; + } + if (msg.ex) { + throw new Error(`nREPL evaluation error: ${msg.ex}${msg.err ? "\n" + msg.err : ""}`); + } + } + + return { + values, + out: outParts.join(""), + err: errParts.join(""), + ns, + }; + } +} diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index ae95724a09..44b058b358 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -11,6 +11,12 @@ import { HighLevelOverviewTool } from "./tools/HighLevelOverviewTool"; import { PenpotApiInfoTool } from "./tools/PenpotApiInfoTool"; import { ExportShapeTool } from "./tools/ExportShapeTool"; import { ImportImageTool } from "./tools/ImportImageTool"; +import { CljsReplTool } from "./tools/CljsReplTool"; +import { ImportPenpotFileTool } from "./tools/ImportPenpotFileTool"; +import { CljsCompilerOutputTool } from "./tools/CljsCompilerOutputTool"; +import { CljCheckParentheses } from "./tools/CljCheckParentheses"; +import { ReadTaigaIssueTool } from "./tools/ReadTaigaIssueTool"; +import { NreplClient } from "./NreplClient"; import { ReplServer } from "./ReplServer"; import { ApiDocs } from "./ApiDocs"; @@ -45,10 +51,32 @@ class ToolInfo { export class PenpotMcpServer { /** - * Timeout, in minutes, for idle Streamable HTTP sessions before they are automatically closed and removed. + * Timeout, in minutes, for idle sessions (Streamable HTTP and SSE) before they are automatically closed and removed. */ private static readonly SESSION_TIMEOUT_MINUTES = 60; + /** + * Returns a short, non-reversible fingerprint of a user token, suitable for + * correlating log lines without exposing the full credential. + * + * Penpot tokens are JWEs in compact serialization (RFC 7516 §7.1) with five + * dot-separated segments; we use the first 8 chars of the wrapped CEK + * (segment 1) as a stable per-token identifier. For malformed tokens (e.g. + * test stubs that aren't real JWEs), we fall back to the first 8 chars of + * the raw token. + * + * @param token - the token to fingerprint, or `undefined` + * @returns a short fingerprint, or `` if no token was given + */ + private static tokenFingerprint(token: string | undefined): string { + if (!token) { + return ""; + } + const segments = token.split("."); + const source = segments.length === 5 ? segments[1] : token; + return source.slice(0, 8); + } + private readonly logger = createLogger("PenpotMcpServer"); private readonly tools: ToolInfo[]; public readonly configLoader: ConfigurationLoader; @@ -65,7 +93,10 @@ export class PenpotMcpServer { private readonly sessionContext = new AsyncLocalStorage(); private readonly streamableTransports: Record = {}; - private readonly sseTransports: Record = {}; + private readonly sseTransports: Record< + string, + { transport: SSEServerTransport; userToken?: string; lastActiveTime: number } + > = {}; public readonly host: string; public readonly port: number; @@ -92,7 +123,7 @@ export class PenpotMcpServer { this.tools = this.initTools(); this.pluginBridge = new PluginBridge(this, this.webSocketPort); - this.replServer = new ReplServer(this.pluginBridge, this.replPort); + this.replServer = new ReplServer(this.pluginBridge, this.replPort, this.host); } /** @@ -126,6 +157,16 @@ export class PenpotMcpServer { return !this.isRemoteMode(); } + /** + * Indicates whether the server is running in a Penpot development environment. + * + * When enabled (by setting the environment variable PENPOT_MCP_DEVENV to "true"), + * additional developer tools such as ClojureScript expression evaluation are exposed. + */ + public isDevEnv(): boolean { + return process.env.PENPOT_MCP_DEVENV === "true"; + } + /** * Retrieves the high-level overview instructions explaining core Penpot usage. */ @@ -152,6 +193,14 @@ export class PenpotMcpServer { if (this.isFileSystemAccessEnabled()) { toolInstances.push(new ImportImageTool(this)); } + if (this.isDevEnv()) { + const nreplClient = new NreplClient(); + toolInstances.push(new CljsReplTool(this, nreplClient)); + toolInstances.push(new ImportPenpotFileTool(this, nreplClient)); + toolInstances.push(new CljsCompilerOutputTool(this, nreplClient)); + toolInstances.push(new CljCheckParentheses(this)); + toolInstances.push(new ReadTaigaIssueTool(this)); + } return toolInstances.map((instance) => { this.logger.info(`Registering tool: ${instance.getToolName()}`); @@ -179,7 +228,7 @@ export class PenpotMcpServer { } /** - * Starts a periodic timer that closes and removes Streamable HTTP sessions that have been + * Starts a periodic timer that closes and removes Streamable HTTP and SSE sessions that have been * idle for longer than {@link SESSION_TIMEOUT_MINUTES}. */ private startSessionTimeoutChecker(): void { @@ -195,8 +244,18 @@ export class PenpotMcpServer { removed++; } } + for (const [id, session] of Object.entries(this.sseTransports)) { + if (now - session.lastActiveTime > timeoutMs) { + this.logger.info(`Closing stale SSE session ${id}`); + session.transport.close(); + delete this.sseTransports[id]; + removed++; + } + } this.logger.info( - `Removed ${removed} stale session(s); total sessions remaining: ${Object.keys(this.streamableTransports).length}` + `Removed ${removed} stale session(s); total sessions remaining: ${ + Object.keys(this.streamableTransports).length + Object.keys(this.sseTransports).length + }` ); }, checkIntervalMs); } @@ -222,12 +281,14 @@ export class PenpotMcpServer { userToken = session.userToken; session.lastActiveTime = Date.now(); this.logger.info( - `Received request for existing session with id=${sessionId}; userToken=${session.userToken}` + `Received request for existing session with id=${sessionId}; userTokenFp=${PenpotMcpServer.tokenFingerprint(session.userToken)}` ); } else { // new session: create a fresh McpServer and transport userToken = req.query.userToken as string | undefined; - this.logger.info(`Received new session request; userToken=${userToken}`); + this.logger.info( + `Received new session request; userTokenFp=${PenpotMcpServer.tokenFingerprint(userToken)}` + ); const { randomUUID } = await import("node:crypto"); const server = this.createMcpServer(); transport = new StreamableHTTPServerTransport({ @@ -235,13 +296,15 @@ export class PenpotMcpServer { onsessioninitialized: (id) => { this.streamableTransports[id] = new StreamableSession(transport, userToken, Date.now()); this.logger.info( - `Session initialized with id=${id} for userToken=${userToken}; total sessions: ${Object.keys(this.streamableTransports).length}` + `Session initialized with id=${id} for userTokenFp=${PenpotMcpServer.tokenFingerprint(userToken)}; total sessions: ${Object.keys(this.streamableTransports).length}` ); }, }); transport.onclose = () => { if (transport.sessionId) { - this.logger.info(`Closing session with id=${transport.sessionId} for userToken=${userToken}`); + this.logger.info( + `Closing session with id=${transport.sessionId} for userTokenFp=${PenpotMcpServer.tokenFingerprint(userToken)}` + ); delete this.streamableTransports[transport.sessionId]; } }; @@ -262,7 +325,7 @@ export class PenpotMcpServer { await this.sessionContext.run({ userToken }, async () => { const transport = new SSEServerTransport("/messages", res); - this.sseTransports[transport.sessionId] = { transport, userToken }; + this.sseTransports[transport.sessionId] = { transport, userToken, lastActiveTime: Date.now() }; const server = this.createMcpServer(); await server.connect(transport); @@ -281,6 +344,7 @@ export class PenpotMcpServer { const session = this.sseTransports[sessionId]; if (session) { + session.lastActiveTime = Date.now(); await this.sessionContext.run({ userToken: session.userToken }, async () => { await session.transport.handlePostMessage(req, res, req.body); }); @@ -301,6 +365,7 @@ export class PenpotMcpServer { this.app.listen(this.port, this.host, async () => { this.logger.info(`Multi-user mode: ${this.isMultiUserMode()}`); this.logger.info(`Remote mode: ${this.isRemoteMode()}`); + this.logger.info(`DevEnv mode: ${this.isDevEnv()}`); this.logger.info(`Modern Streamable HTTP endpoint: http://${this.host}:${this.port}/mcp`); this.logger.info(`Legacy SSE endpoint: http://${this.host}:${this.port}/sse`); this.logger.info(`WebSocket server URL: ws://${this.host}:${this.webSocketPort}`); diff --git a/mcp/packages/server/src/PluginBridge.ts b/mcp/packages/server/src/PluginBridge.ts index 5147d361fd..35d39aa728 100644 --- a/mcp/packages/server/src/PluginBridge.ts +++ b/mcp/packages/server/src/PluginBridge.ts @@ -10,6 +10,7 @@ const KEEP_ALIVE_TIME = 30000; // 30 seconds interface ClientConnection { socket: WebSocket; userToken: string | null; + pingInterval: NodeJS.Timeout; } /** @@ -40,8 +41,6 @@ export class PluginBridge { * channel between the MCP mcpServer and Penpot plugin instances. */ private setupWebSocketHandlers(): void { - let interval: NodeJS.Timeout | undefined; - this.wsServer.on("connection", (ws: WebSocket, request: http.IncomingMessage) => { // extract userToken from query parameters const url = new URL(request.url!, `ws://${request.headers.host}`); @@ -60,13 +59,19 @@ export class PluginBridge { this.logger.info("New WebSocket connection established"); } + // start the per-connection keep-alive ping interval + const pingInterval = setInterval(() => { + ws.ping(); + }, KEEP_ALIVE_TIME); + // register the client connection with both indexes - const connection: ClientConnection = { socket: ws, userToken }; + const connection: ClientConnection = { socket: ws, userToken, pingInterval }; this.connectedClients.set(ws, connection); if (userToken) { // ensure only one connection per userToken if (this.clientsByToken.has(userToken)) { this.logger.warn("Duplicate connection for given user token; rejecting new connection"); + this.removeConnection(ws); ws.close(1008, "Duplicate connection for given user token; close previous connection first."); return; } @@ -86,36 +91,39 @@ export class PluginBridge { ws.on("close", () => { this.logger.info("WebSocket connection closed"); - const connection = this.connectedClients.get(ws); - this.connectedClients.delete(ws); - if (connection?.userToken) { - this.clientsByToken.delete(connection.userToken); - } - if (interval) { - clearInterval(interval); - } + this.removeConnection(ws); }); ws.on("error", (error) => { this.logger.error(error, "WebSocket connection error"); - const connection = this.connectedClients.get(ws); - this.connectedClients.delete(ws); - if (connection?.userToken) { - this.clientsByToken.delete(connection.userToken); - } - if (interval) { - clearInterval(interval); - } + this.removeConnection(ws); }); - - interval = setInterval(() => { - ws?.ping(); - }, KEEP_ALIVE_TIME); }); this.logger.info("WebSocket mcpServer started on port %d", this.port); } + /** + * Removes a client connection and releases all resources associated with it. + * + * Clears the per-connection keep-alive interval and removes the connection + * from both the socket-keyed and token-keyed indexes. Safe to call with a + * socket that is not (or no longer) registered. + * + * @param ws - The WebSocket whose connection state should be removed + */ + private removeConnection(ws: WebSocket): void { + const connection = this.connectedClients.get(ws); + if (!connection) { + return; + } + clearInterval(connection.pingInterval); + this.connectedClients.delete(ws); + if (connection.userToken) { + this.clientsByToken.delete(connection.userToken); + } + } + /** * Handles responses from the plugin for completed tasks. * diff --git a/mcp/packages/server/src/ReplServer.ts b/mcp/packages/server/src/ReplServer.ts index 1496037521..1894ade79a 100644 --- a/mcp/packages/server/src/ReplServer.ts +++ b/mcp/packages/server/src/ReplServer.ts @@ -17,13 +17,16 @@ export class ReplServer { private readonly logger = createLogger("ReplServer"); private readonly app: express.Application; private readonly port: number; + private readonly host: string; private server: any; constructor( private readonly pluginBridge: PluginBridge, - port: number = 4403 + port: number = 4403, + host: string = "localhost" ) { this.port = port; + this.host = host; this.app = express(); this.setupMiddleware(); this.setupRoutes(); @@ -86,9 +89,9 @@ export class ReplServer { */ public async start(): Promise { return new Promise((resolve) => { - this.server = this.app.listen(this.port, () => { + this.server = this.app.listen(this.port, this.host, () => { this.logger.info(`REPL server started on port ${this.port}`); - this.logger.info(`REPL interface URL: http://${this.pluginBridge.mcpServer.host}:${this.port}`); + this.logger.info(`REPL interface URL: http://${this.host}:${this.port}`); resolve(); }); }); diff --git a/mcp/packages/server/src/ToolResponse.ts b/mcp/packages/server/src/ToolResponse.ts index 6055f24ccf..db5babe4db 100644 --- a/mcp/packages/server/src/ToolResponse.ts +++ b/mcp/packages/server/src/ToolResponse.ts @@ -37,19 +37,26 @@ export class ImageContent implements ImageItem { /** * Utility function for ensuring a consistent Uint8Array representation of byte data. - * Input can be either a Uint8Array or an object (as obtained from JSON conversion of Uint8Array - * from the plugin). + * Input can be one of: + * - a `Uint8Array` (already in the desired form); + * - a base64 envelope `{ __type: "base64", data: }` produced by the plugin + * to avoid the ~10x JSON expansion of typed arrays (see penpot/penpot#9420); + * - a numeric-keyed object obtained from `JSON.stringify`-ing a `Uint8Array` (legacy fallback). * - * @param data - data as Uint8Array or as object (from JSON conversion of Uint8Array) - * @return data as Uint8Array + * @param data - data as `Uint8Array`, base64 envelope, or numeric-keyed object + * @return data as `Uint8Array` */ public static byteData(data: Uint8Array | object): Uint8Array { - if (typeof data === "object") { - // convert object (as obtained from JSON conversion of Uint8Array) back to Uint8Array - return new Uint8Array(Object.values(data) as number[]); - } else { + if (data instanceof Uint8Array) { return data; } + // recognize the base64 envelope produced by the plugin's ExecuteCodeTaskHandler + const envelope = data as { __type?: unknown; data?: unknown }; + if (envelope.__type === "base64" && typeof envelope.data === "string") { + return new Uint8Array(Buffer.from(envelope.data, "base64")); + } + // legacy fallback: object (as obtained from JSON conversion of Uint8Array) back to Uint8Array + return new Uint8Array(Object.values(data) as number[]); } } diff --git a/mcp/packages/server/src/index.ts b/mcp/packages/server/src/index.ts index 356c2cea81..84ef23f204 100644 --- a/mcp/packages/server/src/index.ts +++ b/mcp/packages/server/src/index.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { PenpotMcpServer } from "./PenpotMcpServer"; -import { createLogger, logFilePath } from "./logger"; +import { createLogger, logActiveTransports } from "./logger"; /** * Entry point for Penpot MCP Server @@ -14,8 +14,8 @@ import { createLogger, logFilePath } from "./logger"; async function main(): Promise { const logger = createLogger("main"); - // log the file path early so it appears before any potential errors - logger.info(`Logging to file: ${logFilePath}`); + // announce active transports early so they appear before any potential errors + logActiveTransports(logger); try { const args = process.argv.slice(2); diff --git a/mcp/packages/server/src/logger.ts b/mcp/packages/server/src/logger.ts index 1e8f96e5ec..45cb359e90 100644 --- a/mcp/packages/server/src/logger.ts +++ b/mcp/packages/server/src/logger.ts @@ -1,12 +1,21 @@ -import pino from "pino"; +import pino, { type TransportTargetOptions } from "pino"; import { join, resolve } from "path"; /** - * Configuration for log file location and level. + * Configured log level (defaults to `info`). */ -const LOG_DIR = process.env.PENPOT_MCP_LOG_DIR || "logs"; const LOG_LEVEL = process.env.PENPOT_MCP_LOG_LEVEL || "info"; +/** + * Configured log directory; file logging is enabled iff this is set to a non-empty value. + */ +const LOG_DIR = process.env.PENPOT_MCP_LOG_DIR; + +/** + * Loki host URI; if set and non-empty, Loki logging is enabled. + */ +const LOKI_URI = process.env.PENPOT_LOGGERS_LOKI_URI; + /** * Generates a timestamped log file name. * @@ -24,56 +33,204 @@ function generateLogFileName(): string { } /** - * Absolute path to the log file being written. + * The pino transport target spec, as expected in `transport.targets[]`. */ -export const logFilePath = resolve(join(LOG_DIR, generateLogFileName())); +type TransportTargetSpec = TransportTargetOptions; /** - * Logger instance configured for both console and file output with metadata. + * Provides a single pino transport target, either active or inactive. * - * Both console and file output use pretty formatting for human readability. - * Console output includes colors, while file output is plain text. + * Implementations decide their own activation based on environment configuration. + * An inactive provider returns `null` from {@link getTarget} and is skipped. + */ +interface LogTransportProvider { + /** + * Returns the pino transport target spec, or `null` if this transport is disabled. + */ + getTarget(): TransportTargetSpec | null; + + /** + * Returns a human-readable startup message describing the transport, or `null` if disabled. + */ + getStartupMessage(): string | null; +} + +/** + * Console transport with pretty-printed, colorized output. Always active. + */ +class ConsoleLogTransport implements LogTransportProvider { + public getTarget(): TransportTargetSpec { + return { + target: "pino-pretty", + level: LOG_LEVEL, + options: { + colorize: true, + translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l", + ignore: "pid,hostname", + messageFormat: "{msg}", + levelFirst: true, + }, + }; + } + + public getStartupMessage(): string { + return "Logging to console"; + } +} + +/** + * File transport writing pretty-formatted logs to a timestamped file in a configurable directory. + * Active iff `PENPOT_MCP_LOG_DIR` is set and non-empty. + */ +class FileLogTransport implements LogTransportProvider { + private readonly enabled: boolean; + private readonly filePath: string | null; + + public constructor(logDir: string | undefined) { + this.enabled = logDir !== undefined && logDir !== ""; + this.filePath = this.enabled ? resolve(join(logDir as string, generateLogFileName())) : null; + } + + public isEnabled(): boolean { + return this.enabled; + } + + public getTarget(): TransportTargetSpec | null { + if (!this.enabled) { + return null; + } + return { + target: "pino-pretty", + level: LOG_LEVEL, + options: { + destination: this.filePath, + colorize: false, + translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l", + ignore: "pid,hostname", + messageFormat: "{msg}", + levelFirst: true, + mkdir: true, + }, + }; + } + + public getStartupMessage(): string | null { + return this.enabled ? `Logging to file: ${this.filePath}` : null; + } + + /** + * Returns the absolute path of the active log file, or `undefined` if file logging is disabled. + */ + public getFilePath(): string | undefined { + return this.filePath ?? undefined; + } +} + +/** + * Loki transport forwarding logs to a Grafana Loki instance via `pino-loki`. + * + * Active iff `PENPOT_LOGGERS_LOKI_URI` is set and non-empty. + */ +class LokiLogTransport implements LogTransportProvider { + private readonly host: string | null; + + public constructor(lokiUri: string | undefined) { + this.host = lokiUri !== undefined && lokiUri !== "" ? lokiUri : null; + } + + public isEnabled(): boolean { + return this.host !== null; + } + + public getTarget(): TransportTargetSpec | null { + if (this.host === null) { + return null; + } + return { + target: "pino-loki", + level: LOG_LEVEL, + options: { + host: this.host, + json: false, + batching: true, + interval: 5, + replaceTimestamp: true, + labels: this.buildLabels(), + }, + }; + } + + /** + * Builds the set of static labels to attach to every log entry sent to Loki. + * + * The `environment` and `instance` labels are only included if their respective + * environment variables are set and non-empty. + */ + private buildLabels(): Record { + const labels: Record = { + job: process.env.PENPOT_LOGGERS_LOKI_JOB || "mcp", + }; + const environment = process.env.PENPOT_LOGGERS_LOKI_ENVIRONMENT; + if (environment) { + labels.environment = environment; + } + const instance = process.env.PENPOT_LOGGERS_LOKI_INSTANCE; + if (instance) { + labels.instance = instance; + } + return labels; + } + + public getStartupMessage(): string | null { + return this.host !== null ? `Logging to Loki: ${this.host}` : null; + } +} + +// build the transport providers; each decides its own activation independently +const consoleTransport = new ConsoleLogTransport(); +const fileTransport = new FileLogTransport(LOG_DIR); +const lokiTransport = new LokiLogTransport(LOKI_URI); + +const transports: LogTransportProvider[] = [consoleTransport, fileTransport, lokiTransport]; + +/** + * Absolute path to the log file being written, or `undefined` if file logging is disabled. + */ +export const logFilePath: string | undefined = fileTransport.getFilePath(); + +/** + * Logger instance configured with the active transports (console, optional file, optional Loki). */ export const logger = pino({ level: LOG_LEVEL, timestamp: pino.stdTimeFunctions.isoTime, transport: { - targets: [ - { - // console transport with pretty formatting - target: "pino-pretty", - level: LOG_LEVEL, - options: { - colorize: true, - translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l", - ignore: "pid,hostname", - messageFormat: "{msg}", - levelFirst: true, - }, - }, - { - // file transport with pretty formatting (same as console) - target: "pino-pretty", - level: LOG_LEVEL, - options: { - destination: logFilePath, - colorize: false, - translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l", - ignore: "pid,hostname", - messageFormat: "{msg}", - levelFirst: true, - mkdir: true, - }, - }, - ], + targets: transports + .map((t) => t.getTarget()) + .filter((target): target is TransportTargetSpec => target !== null), }, }); +/** + * Logs a startup line for each active transport, allowing the user to see at a glance + * where logs are being written. + * + * @param log - the logger to emit the startup messages on + */ +export function logActiveTransports(log: pino.Logger): void { + for (const t of transports) { + const msg = t.getStartupMessage(); + if (msg !== null) { + log.info(msg); + } + } +} + /** * Creates a child logger with the specified name/origin. * - * @param name - The name/origin identifier for the logger - * @returns Child logger instance with the specified name + * @param name - the name/origin identifier for the logger + * @returns child logger instance with the specified name */ export function createLogger(name: string) { return logger.child({ name }); diff --git a/mcp/packages/server/src/tools/CljCheckParentheses.ts b/mcp/packages/server/src/tools/CljCheckParentheses.ts new file mode 100644 index 0000000000..81e2a9628c --- /dev/null +++ b/mcp/packages/server/src/tools/CljCheckParentheses.ts @@ -0,0 +1,242 @@ +import { z } from "zod"; +import { Tool } from "../Tool"; +import "reflect-metadata"; +import type { ToolResponse } from "../ToolResponse"; +import { TextResponse } from "../ToolResponse"; +import type { PenpotMcpServer } from "../PenpotMcpServer"; +import * as fs from "fs"; + +/** + * Arguments for the FindUnclosedParensTool. + */ +export class CljCheckParenthesesArgs { + static schema = { + file: z.string().min(1).describe("Absolute path to a Clojure/ClojureScript source file"), + }; + + file!: string; +} + +interface OpenDelim { + id: number; + line: number; // 0-based + col: number; // 0-based + char: string; + baselineKey: string; // the baseline key this delimiter owns +} + +interface ParenIssue { + line: number; // 1-based + col: number; // 1-based + char: string; + detectedAtLine?: number; // 1-based line where the stack-state mismatch was observed +} + +/** + * Finds unclosed delimiters in Clojure/ClojureScript source files using a + * stack-state invariant derived from cljfmt formatting conventions. + * + * Invariant: in cljfmt-formatted code, every opening delimiter of type T at + * column C must see the same stack depth each time that (T, C) combination + * occurs. A depth mismatch means delimiters opened between the baseline + * occurrence and the current one were never closed. + * + * The parser correctly handles string literals (including multi-line and escape + * sequences), comment lines, character literals, and regex literals. + */ +export class CljCheckParentheses extends Tool { + constructor(mcpServer: PenpotMcpServer) { + super(mcpServer, CljCheckParenthesesArgs.schema); + } + + public getToolName(): string { + return "clj_check_parentheses"; + } + + public getToolDescription(): string { + return "Analyzes a Clojure/ClojureScript source file for unclosed delimiters and reports the area of interest."; + } + + protected async executeCore(args: CljCheckParenthesesArgs): Promise { + const filePath = args.file; + + if (!fs.existsSync(filePath)) { + return new TextResponse(`File not found: ${filePath}`); + } + + const content = fs.readFileSync(filePath, "utf-8"); + const issues = analyzeParens(content); + + if (issues.length === 0) { + return new TextResponse("All delimiters are properly balanced."); + } + + const sourceLines = content.split("\n"); + const parts: string[] = [`Found ${issues.length} unclosed delimiter(s):\n`]; + + for (const issue of issues) { + const srcLine = (sourceLines[issue.line - 1] ?? "").trimEnd(); + const pointer = " ".repeat(String(issue.line).length) + " " + " ".repeat(issue.col - 1) + "^"; + + if (issue.detectedAtLine != null) { + const detectedSrcLine = (sourceLines[issue.detectedAtLine - 1] ?? "").trimEnd(); + parts.push( + ` Unclosed '${issue.char}' at line ${issue.line}, col ${issue.col}:\n` + + ` ${issue.line} | ${srcLine}\n` + + ` ${pointer}\n` + + ` Stack-state mismatch detected at line ${issue.detectedAtLine}:\n` + + ` ${issue.detectedAtLine} | ${detectedSrcLine}\n` + ); + } else { + parts.push( + ` Unclosed '${issue.char}' at line ${issue.line}, col ${issue.col} (still open at end of file):\n` + + ` ${issue.line} | ${srcLine}\n` + + ` ${pointer}\n` + ); + } + } + + return new TextResponse(parts.join("\n")); + } +} + +/** + * Analyses delimiter balance in a Clojure/ClojureScript source string. + * + * Algorithm + * --------- + * Maintain a stack of open delimiters and a map from (delimiter-type, column) + * to the stack depth recorded on the first occurrence of that combination. + * + * Each time an opening delimiter of type T appears at column C: + * 1. Look up the key (T, C) in the map. + * 2. If absent, record the current stack depth as the baseline. + * 3. If present, compare the current depth with the baseline. + * - If deeper: the extra stack entries (from baseline depth to current + * depth) are delimiters that should have been closed. Report them. + * - If shallower: more delimiters were closed than opened between the + * baseline and here (over-closed). Update the baseline downward so + * subsequent occurrences don't cascade. + * 4. Push the delimiter onto the stack. + * + * After the full file is processed, any delimiter still on the stack is + * unclosed. If it was already reported via a mismatch, the report includes + * the detection line; otherwise it is reported as open-at-EOF. + */ +function analyzeParens(content: string): ParenIssue[] { + // Precompute line-start offsets for O(1) column lookup. + const lineStarts: number[] = [0]; + for (let i = 0; i < content.length; i++) { + if (content[i] === "\n") lineStarts.push(i + 1); + } + + let nextId = 0; + const stack: OpenDelim[] = []; + + // (type, column) → baseline stack depth. + // Each baseline is owned by the delimiter that established it (stored + // as baselineKey on the stack entry). When that delimiter is popped, + // its baseline is discarded — it was scoped to that delimiter's lifetime. + const baseline: Map = new Map(); + + let inString = false; + let inComment = false; + let escape = false; + let currentLine = 0; + + for (let i = 0; i < content.length; i++) { + const ch = content[i]; + + // ── Newline ────────────────────────────────────────────────────── + if (ch === "\n") { + inComment = false; + currentLine++; + if (!inString) escape = false; + continue; + } + + // ── Escape: skip next character ────────────────────────────────── + if (escape) { + escape = false; + continue; + } + + // ── Inside comment: skip until newline ─────────────────────────── + if (inComment) continue; + + // ── Inside string literal ──────────────────────────────────────── + if (inString) { + if (ch === "\\") escape = true; + else if (ch === '"') inString = false; + continue; + } + + // ── Outside string / comment ───────────────────────────────────── + if (ch === "\\") { + escape = true; + continue; + } + if (ch === '"') { + inString = true; + continue; + } + if (ch === ";") { + inComment = true; + continue; + } + + // ── Opening delimiter ──────────────────────────────────────────── + if (ch === "(" || ch === "[" || ch === "{") { + const col = i - lineStarts[currentLine]; + const key = `${ch}:${col}`; + const currentDepth = stack.length; + + const recorded = baseline.get(key); + if (recorded !== undefined && currentDepth > recorded) { + // Stack is deeper than expected. The entries from index + // `recorded` to `currentDepth - 1` are unclosed delimiters + // that should have been closed before reaching this + // position. Return immediately — further parsing would be + // against a corrupted stack and only produce cascading noise. + return stack.slice(recorded, currentDepth).map((delim) => ({ + line: delim.line + 1, + col: delim.col + 1, + char: delim.char, + detectedAtLine: currentLine + 1, + })); + } + + // Establish or re-establish the baseline for this key, + // owned by this delimiter. Discarded when it is popped. + baseline.set(key, currentDepth); + + stack.push({ + id: nextId++, + line: currentLine, + col, + char: ch, + baselineKey: key, + }); + } + + // ── Closing delimiter ──────────────────────────────────────────── + else if (ch === ")" || ch === "]" || ch === "}") { + if (stack.length > 0) { + const closed = stack.pop()!; + + // The baseline this delimiter owned is no longer valid — + // the context it was recorded in has closed. + baseline.delete(closed.baselineKey); + } + } + } + + // ── EOF: no mismatch was found, but the stack is not empty ────────── + // This happens when the unclosed delimiter has no second occurrence of + // the same (type, column) to compare against (e.g. last form in file). + return stack.map((delim) => ({ + line: delim.line + 1, + col: delim.col + 1, + char: delim.char, + })); +} diff --git a/mcp/packages/server/src/tools/CljsCompilerOutputTool.ts b/mcp/packages/server/src/tools/CljsCompilerOutputTool.ts new file mode 100644 index 0000000000..11989c6303 --- /dev/null +++ b/mcp/packages/server/src/tools/CljsCompilerOutputTool.ts @@ -0,0 +1,52 @@ +import { Tool, EmptyToolArgs } from "../Tool"; +import "reflect-metadata"; +import type { ToolResponse } from "../ToolResponse"; +import { TextResponse } from "../ToolResponse"; +import { PenpotMcpServer } from "../PenpotMcpServer"; +import { NreplClient } from "../NreplClient"; + +/** + * Reports the compiler status of the shadow-cljs `:main` build. + * + * If the most recent build failed, returns the relevant fields of the failure data + * (tag, message, resource name, line, column, etc.); otherwise returns `:ok`. + */ +export class CljsCompilerOutputTool extends Tool { + private static readonly STATUS_CODE = + "(require (quote [shadow.cljs.devtools.api :as shadow])) " + + "(let [fd (-> (shadow/get-worker :main) :state-ref deref :failure-data)] " + + "(if fd (pr-str fd) :ok))"; + + private readonly nreplClient: NreplClient; + + constructor(mcpServer: PenpotMcpServer, nreplClient: NreplClient) { + super(mcpServer, EmptyToolArgs.schema); + this.nreplClient = nreplClient; + } + + public getToolName(): string { + return "cljs_compiler_output"; + } + + public getToolDescription(): string { + return ( + "Reports the status of the most recent shadow-cljs `:main` build. " + + "Use this to diagnose compilation errors when needed. For syntax errors, " + + "consider using the clj_check_parentheses tool on the relevant source files." + ); + } + + protected async executeCore(_args: EmptyToolArgs): Promise { + const result = await this.nreplClient.eval(CljsCompilerOutputTool.STATUS_CODE); + + // multiple top-level forms produce multiple values; the build status is the last one + const status = result.values[result.values.length - 1] ?? "nil"; + + const parts: string[] = [status]; + if (result.err) { + parts.push(`stderr:\n${result.err}`); + } + + return new TextResponse(parts.join("\n\n")); + } +} diff --git a/mcp/packages/server/src/tools/CljsReplTool.ts b/mcp/packages/server/src/tools/CljsReplTool.ts new file mode 100644 index 0000000000..bd894caef3 --- /dev/null +++ b/mcp/packages/server/src/tools/CljsReplTool.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; +import { Tool } from "../Tool"; +import "reflect-metadata"; +import type { ToolResponse } from "../ToolResponse"; +import { TextResponse } from "../ToolResponse"; +import { PenpotMcpServer } from "../PenpotMcpServer"; +import { NreplClient } from "../NreplClient"; + +/** + * Arguments for the CljsReplTool. + */ +export class CljsReplArgs { + static schema = { + code: z.string().min(1, "Code cannot be empty"), + }; + + /** + * The ClojureScript code to evaluate in the frontend runtime. + */ + code!: string; +} + +/** + * A ClojureScript REPL for the Penpot frontend runtime. + * + * This tool provides a persistent REPL session connected to the shadow-cljs nREPL server. + * Definitions, requires, and other state are preserved across calls, enabling iterative + * exploration and manipulation of the running Penpot application. + */ +export class CljsReplTool extends Tool { + private readonly nreplClient: NreplClient; + + /** + * Creates a new CljsReplTool instance. + * + * @param mcpServer - the MCP server instance + * @param nreplClient - the nREPL client for communicating with shadow-cljs + */ + constructor(mcpServer: PenpotMcpServer, nreplClient: NreplClient) { + super(mcpServer, CljsReplArgs.schema); + this.nreplClient = nreplClient; + } + + public getToolName(): string { + return "cljs_repl"; + } + + public getToolDescription(): string { + return ( + "Persistent ClojureScript REPL in the Penpot frontend runtime (via shadow-cljs nREPL). " + + "Definitions, requires, and state are preserved across calls — use it to build up helpers incrementally. " + + "Multiple top-level expressions per call are supported; each produces a result line." + ); + } + + protected async executeCore(args: CljsReplArgs): Promise { + const result = await this.nreplClient.evalCljs(args.code); + + const parts: string[] = []; + if (result.values.length > 0) { + parts.push(result.values.join("\n")); + } + if (result.out) { + parts.push(`stdout:\n${result.out}`); + } + if (result.err) { + parts.push(`stderr:\n${result.err}`); + } + if (parts.length === 0) { + parts.push("nil"); + } + + return new TextResponse(parts.join("\n\n")); + } +} diff --git a/mcp/packages/server/src/tools/ExportShapeTool.ts b/mcp/packages/server/src/tools/ExportShapeTool.ts index 7b8ceed4a6..0d84df9380 100644 --- a/mcp/packages/server/src/tools/ExportShapeTool.ts +++ b/mcp/packages/server/src/tools/ExportShapeTool.ts @@ -1,10 +1,12 @@ import { z } from "zod"; import { Tool } from "../Tool"; -import { ImageContent, PNGImageContent, PNGResponse, TextContent, TextResponse, ToolResponse } from "../ToolResponse"; +import { ImageContent, PNGResponse, TextContent, TextResponse, ToolResponse } from "../ToolResponse"; import "reflect-metadata"; import { PenpotMcpServer } from "../PenpotMcpServer"; import { ExecuteCodePluginTask } from "../tasks/ExecuteCodePluginTask"; +import { createLogger } from "../logger"; import { FileUtils } from "../utils/FileUtils"; +import { Semaphore } from "../utils/Semaphore"; import sharp from "sharp"; /** @@ -49,6 +51,38 @@ export class ExportShapeArgs { * Tool for executing JavaScript code in the Penpot plugin context */ export class ExportShapeTool extends Tool { + /** + * Maximum number of image-export operations that may run concurrently in multi-user mode. + * Configurable via the PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS environment variable; + * defaults to 0, meaning no limit. + * + * When set to a positive value (and combined with the plugin-side per-response cap + * MAX_TASK_RESPONSE_SIZE_REMOTE_MCP, ~15 MB JSON), this caps the in-flight memory + * footprint of image exports at roughly N x cap on the centrally hosted MCP server. + */ + private static readonly MAX_PARALLEL_EXPORTS = parseInt( + process.env.PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS ?? "0", + 10 + ); + + /** + * Gates concurrent export operations across all tool instances (one per session in + * multi-user mode). Static because instances are per-session, but the bound has to + * apply across the whole process. Permits beyond the maximum queue in FIFO order. + * Undefined when MAX_PARALLEL_EXPORTS is non-positive, indicating no limit. + */ + private static readonly parallelismSemaphore: Semaphore | undefined = + ExportShapeTool.MAX_PARALLEL_EXPORTS > 0 + ? new Semaphore("ExportShapeTool", ExportShapeTool.MAX_PARALLEL_EXPORTS) + : undefined; + + static { + createLogger("ExportShapeTool").info( + "Max parallel exports (multi-user mode): %d (0 = unbounded)", + ExportShapeTool.MAX_PARALLEL_EXPORTS + ); + } + /** * Creates a new ExecuteCode tool instance. * @@ -79,6 +113,25 @@ export class ExportShapeTool extends Tool { } protected async executeCore(args: ExportShapeArgs): Promise { + // bound concurrent exports in multi-user mode to keep peak server memory under control; + // in single-user mode (or when no limit is configured) the gate is irrelevant + // and the export runs directly + if (this.mcpServer.isMultiUserMode() && ExportShapeTool.parallelismSemaphore) { + return ExportShapeTool.parallelismSemaphore.withPermit(() => this.exportImage(args)); + } else { + return this.exportImage(args); + } + } + + /** + * Performs the actual image export: requests the image via the plugin and either + * returns it as a tool response or saves it to the requested file path. The bulk + * of the memory pressure (parsed plugin response, decoded image buffer, optional + * re-encoding via sharp) lives here, which is why executeCore gates the call. + * + * @param args - the validated tool arguments + */ + private async exportImage(args: ExportShapeArgs): Promise { // check arguments if (args.filePath) { FileUtils.checkPathIsAbsolute(args.filePath); diff --git a/mcp/packages/server/src/tools/ImportPenpotFileTool.ts b/mcp/packages/server/src/tools/ImportPenpotFileTool.ts new file mode 100644 index 0000000000..f55ae194f2 --- /dev/null +++ b/mcp/packages/server/src/tools/ImportPenpotFileTool.ts @@ -0,0 +1,370 @@ +import { z } from "zod"; +import { Tool } from "../Tool"; +import { TextResponse, ToolResponse } from "../ToolResponse"; +import "reflect-metadata"; +import { PenpotMcpServer } from "../PenpotMcpServer"; +import { NreplClient } from "../NreplClient"; +import { createLogger } from "../logger"; +import * as crypto from "crypto"; +import * as fs from "fs"; +import * as path from "path"; +import * as https from "https"; +import * as http from "http"; + +/** + * Arguments for ImportPenpotFileTool. + */ +export class ImportPenpotFileArgs { + static schema = { + url: z.url().describe("URL of the .penpot file to import."), + }; + + /** URL of the .penpot file to import */ + url!: string; +} + +/** + * Tool for importing a .penpot file into the running Penpot instance. + * + * Downloads the file from the given URL to a temporary location in the frontend's + * static directory, then triggers the import via the Penpot frontend's web worker + * using the ClojureScript REPL. The temporary file is cleaned up after the import + * completes (or fails). + * + * Only available in devenv mode, as it requires the ClojureScript nREPL connection. + */ +export class ImportPenpotFileTool extends Tool { + private static readonly POLL_INTERVAL_MS = 1_000; + private static readonly IMPORT_TIMEOUT_MS = 120_000; + + // assumes cwd is the server package root (same assumption as ConfigurationLoader) + private static readonly PUBLIC_DIR = path.resolve("../../../frontend/resources/public"); + + private static readonly NAVIGATION_HINT = + "To open an imported file in the workspace, use cljs_repl with:\n" + + "(do (require '[app.main.data.common :as dcm])\n" + + " (app.main.store/emit! (dcm/go-to-workspace\n" + + ' :team-id (parse-uuid "")\n' + + ' :file-id (parse-uuid "")\n' + + ' :page-id (parse-uuid ""))))'; + + private readonly log = createLogger("ImportPenpotFileTool"); + private readonly nreplClient: NreplClient; + + /** + * Creates a new ImportPenpotFileTool instance. + * + * @param mcpServer - the MCP server instance + * @param nreplClient - the nREPL client for communicating with shadow-cljs + */ + constructor(mcpServer: PenpotMcpServer, nreplClient: NreplClient) { + super(mcpServer, ImportPenpotFileArgs.schema); + this.nreplClient = nreplClient; + } + + public getToolName(): string { + return "import_penpot_file"; + } + + public getToolDescription(): string { + return ( + "Imports a .penpot file into the running Penpot instance from a given URL. " + + "The file is imported into the user's Drafts project. " + + "Returns the name(s) of the imported file(s)." + ); + } + + protected async executeCore(args: ImportPenpotFileArgs): Promise { + // generate a random filename for the temporary file + const randomName = `_import_${crypto.randomUUID()}.penpot`; + const tempFilePath = path.join(ImportPenpotFileTool.PUBLIC_DIR, randomName); + const servePath = `/${randomName}`; + + try { + // download the file + this.log.info("Downloading .penpot file from %s", args.url); + await this.downloadFile(args.url, tempFilePath); + const fileSize = fs.statSync(tempFilePath).size; + this.log.info("Downloaded %d bytes to %s", fileSize, tempFilePath); + + // set up the import via CLJS REPL + const atomName = `import-result-${crypto.randomUUID().slice(0, 8)}`; + const setupCode = this.buildImportCode(atomName, servePath); + + this.log.info("Initiating import via CLJS REPL"); + const setupResult = await this.nreplClient.evalCljs(setupCode); + this.log.debug("CLJS setup result: %s", JSON.stringify(setupResult)); + + // check for immediate errors in the setup + if (setupResult.err) { + throw new Error(`CLJS evaluation error: ${setupResult.err}`); + } + + // poll for the import result + const result = await this.pollForResult(atomName); + return new TextResponse(result); + } finally { + // clean up the temporary file + this.cleanupTempFile(tempFilePath); + } + } + + /** + * Builds the ClojureScript code that fetches the file from the static directory, + * creates a blob URL, and triggers the import via the web worker. + * + * @param atomName - unique name for the result atom + * @param servePath - the URL path to fetch the file from (same-origin) + * @returns the ClojureScript code string + */ + private buildImportCode(atomName: string, servePath: string): string { + // escape for embedding in a CLJS string + const escapedPath = servePath.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const escapedAtom = atomName.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + + return ` + (do + (require '[app.main.store :as st]) + (require '[app.main.worker :as mw]) + (require '[app.common.uuid :as uuid]) + (require '[beicon.v2.core :as rx]) + + (def ${escapedAtom} (atom {:status :pending})) + + (let [project-id (->> @st/state :projects vals (filter :is-default) first :id) + file-ids-before (set (keys (:files @st/state)))] + (-> (js/fetch "${escapedPath}") + (.then (fn [resp] + (when-not (.-ok resp) + (reset! ${escapedAtom} {:status :error :error (str "Fetch failed: " (.-status resp))}) + (throw (js/Error. (str "Fetch failed: " (.-status resp))))) + (.blob resp))) + (.then (fn [blob] + (let [uri (js/URL.createObjectURL blob) + file-id (uuid/next) + entries [{:file-id file-id + :name "import" + :type :binfile-v3 + :uri uri}]] + (->> (mw/ask-many! + {:cmd :import-files + :project-id project-id + :files entries + :features (get @st/state :features)}) + (rx/subs! + (fn [msg] + (when (= :finish (:status msg)) + (reset! ${escapedAtom} + {:status :success + :file-ids-before file-ids-before}))) + (fn [err] + (reset! ${escapedAtom} {:status :error :error (str err)})) + (fn [] + (when (= :pending (:status @${escapedAtom})) + (reset! ${escapedAtom} {:status :error :error "Stream completed without success message"})))))))) + (.catch (fn [err] + (when (= :pending (:status @${escapedAtom})) + (reset! ${escapedAtom} {:status :error :error (str err)})))))) + + :initiated) + `; + } + + /** + * Builds the ClojureScript code that resolves the imported file details. + * + * Refreshes the dashboard, diffs the file list against the pre-import snapshot, + * and for each new file fetches the first page-id via the backend API. + * + * @param atomName - the atom holding the import result (including :file-ids-before) + * @param resultAtomName - the atom to store the final file details in + * @returns the ClojureScript code string + */ + private buildResolveCode(atomName: string, resultAtomName: string): string { + return ` + (do + (require '[app.main.store :as st]) + (require '[app.main.repo :as rp]) + (require '[app.main.data.dashboard :as dd]) + (require '[beicon.v2.core :as rx]) + + (def ${resultAtomName} (atom {:status :pending})) + + (let [file-ids-before (:file-ids-before @${atomName}) + team-id (:current-team-id @st/state)] + ;; refresh dashboard files + (st/emit! (dd/fetch-recent-files)) + ;; wait a moment for the state to update, then resolve + (js/setTimeout + (fn [] + (let [all-files (vals (:files @st/state)) + new-files (remove #(contains? file-ids-before (:id %)) all-files) + file-count (count new-files)] + (if (zero? file-count) + (reset! ${resultAtomName} {:status :success :files []}) + ;; fetch page-ids for each new file + (let [remaining (atom file-count) + results (atom [])] + (doseq [f new-files] + (->> (rp/cmd! :get-file {:id (:id f) :features (get @st/state :features)}) + (rx/subs! + (fn [file-data] + (swap! results conj + {:file-id (str (:id f)) + :name (:name f) + :team-id (str team-id) + :page-id (str (first (get-in file-data [:data :pages])))}) + (when (zero? (swap! remaining dec)) + (reset! ${resultAtomName} {:status :success :files @results}))) + (fn [err] + (swap! results conj + {:file-id (str (:id f)) + :name (:name f) + :team-id (str team-id) + :error (str err)}) + (when (zero? (swap! remaining dec)) + (reset! ${resultAtomName} {:status :success :files @results})))))))))) + 500)) + + :initiated) + `; + } + + /** + * Polls the CLJS atom for the import result until it succeeds, fails, or times out. + * On success, resolves the imported file details (server-side IDs, names, page-ids). + * + * @param atomName - the name of the atom to poll + * @returns a JSON string with the imported file details + */ + private async pollForResult(atomName: string): Promise { + const startTime = Date.now(); + + // phase 1: wait for the import to complete + while (Date.now() - startTime < ImportPenpotFileTool.IMPORT_TIMEOUT_MS) { + await this.sleep(ImportPenpotFileTool.POLL_INTERVAL_MS); + + const pollResult = await this.nreplClient.evalCljs(`(pr-str @${atomName})`); + const resultStr = pollResult.values.join(""); + this.log.debug(`Poll result: ${resultStr}`); + + if (resultStr.includes(":success")) { + this.log.info("Import succeeded, resolving file details..."); + return await this.resolveImportedFiles(atomName); + } else if (resultStr.includes(":error")) { + this.log.error(`Import failed: ${resultStr}`); + throw new Error(`Import failed: ${resultStr}`); + } + } + + throw new Error(`Import timed out after ${ImportPenpotFileTool.IMPORT_TIMEOUT_MS / 1000} seconds`); + } + + /** + * After a successful import, resolves the actual server-side file details + * by diffing the dashboard file list and fetching page IDs. + * + * @param atomName - the atom holding the import result with :file-ids-before + * @returns a JSON string with the imported file details + */ + private async resolveImportedFiles(atomName: string): Promise { + const resultAtomName = `import-details-${crypto.randomUUID().slice(0, 8)}`; + const resolveCode = this.buildResolveCode(atomName, resultAtomName); + + await this.nreplClient.evalCljs(resolveCode); + + // poll the result atom + const startTime = Date.now(); + const resolveTimeoutMs = 15_000; + + while (Date.now() - startTime < resolveTimeoutMs) { + await this.sleep(ImportPenpotFileTool.POLL_INTERVAL_MS); + + const pollResult = await this.nreplClient.evalCljs(`(pr-str @${resultAtomName})`); + const resultStr = pollResult.values.join(""); + + if (resultStr.includes(":success")) { + this.log.info("File details resolved"); + return resultStr + "\n\n" + ImportPenpotFileTool.NAVIGATION_HINT; + } + } + + this.log.warn("Timed out resolving file details, returning basic success"); + return "Import succeeded but could not resolve file details."; + } + + /** + * Downloads a file from a URL to a local path. + * + * @param url - the URL to download from + * @param destPath - the local file path to write to + */ + private downloadFile(url: string, destPath: string): Promise { + return new Promise((resolve, reject) => { + const client = url.startsWith("https") ? https : http; + const file = fs.createWriteStream(destPath); + + const request = client.get(url, (response) => { + // handle redirects + if ( + response.statusCode && + response.statusCode >= 300 && + response.statusCode < 400 && + response.headers.location + ) { + file.close(); + fs.unlinkSync(destPath); + this.downloadFile(response.headers.location, destPath).then(resolve, reject); + return; + } + + if (response.statusCode && response.statusCode !== 200) { + file.close(); + fs.unlinkSync(destPath); + reject(new Error(`Download failed with status ${response.statusCode}`)); + return; + } + + response.pipe(file); + file.on("finish", () => { + file.close(); + resolve(); + }); + }); + + request.on("error", (err) => { + file.close(); + if (fs.existsSync(destPath)) { + fs.unlinkSync(destPath); + } + reject(new Error(`Download error: ${err.message}`)); + }); + + file.on("error", (err) => { + file.close(); + if (fs.existsSync(destPath)) { + fs.unlinkSync(destPath); + } + reject(new Error(`File write error: ${err.message}`)); + }); + }); + } + + /** + * Removes the temporary file, logging but not throwing on failure. + */ + private cleanupTempFile(filePath: string): void { + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + this.log.info("Cleaned up temporary file: %s", filePath); + } + } catch (err) { + this.log.warn("Failed to clean up temporary file %s: %s", filePath, err); + } + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/mcp/packages/server/src/tools/ReadTaigaIssueTool.ts b/mcp/packages/server/src/tools/ReadTaigaIssueTool.ts new file mode 100644 index 0000000000..29e8bea460 --- /dev/null +++ b/mcp/packages/server/src/tools/ReadTaigaIssueTool.ts @@ -0,0 +1,163 @@ +import { z } from "zod"; +import { Tool } from "../Tool"; +import "reflect-metadata"; +import type { ToolResponse } from "../ToolResponse"; +import { TextResponse } from "../ToolResponse"; +import { PenpotMcpServer } from "../PenpotMcpServer"; + +/** + * Arguments for the {@link ReadTaigaIssueTool}. + */ +export class ReadTaigaIssueArgs { + static schema = { + issueNumber: z + .number() + .int() + .positive() + .describe( + "The Penpot issue number as it appears in Taiga URLs, " + + "e.g. 14177 for https://tree.taiga.io/project/penpot/issue/14177" + ), + }; + + /** + * The Penpot issue number as it appears in Taiga issue URLs + * (e.g. 14177 for https://tree.taiga.io/project/penpot/issue/14177). + */ + issueNumber!: number; +} + +/** + * Represents a file attachment on a Taiga issue. + */ +interface TaigaAttachment { + filename: string; + size: number; + url: string; +} + +/** + * Represents a comment on a Taiga issue. + */ +interface TaigaComment { + username: string; + comment: string; +} + +/** + * The resolved issue data returned by the tool. + */ +interface TaigaIssueData { + subject: string; + description: string; + status: string; + attachments: TaigaAttachment[]; + comments: TaigaComment[]; +} + +/** + * Tool for reading Penpot issues from the Taiga project tracker. + * + * Resolves a Penpot issue number to its internal Taiga ID and retrieves the issue's + * subject, description, status, attachments, and comments via the Taiga REST API. + */ +export class ReadTaigaIssueTool extends Tool { + private static readonly TAIGA_API_BASE = "https://api.taiga.io/api/v1"; + + constructor(mcpServer: PenpotMcpServer) { + super(mcpServer, ReadTaigaIssueArgs.schema); + } + + public getToolName(): string { + return "read_taiga_issue"; + } + + public getToolDescription(): string { + return "Reads a Penpot issue from the Taiga project tracker, returning its subject, description, status, attachments, and comments."; + } + + protected async executeCore(args: ReadTaigaIssueArgs): Promise { + const { projectId, issueId } = await this.resolveIssue(args.issueNumber); + const issueData = await this.fetchIssueData(projectId, issueId); + return new TextResponse(JSON.stringify(issueData, null, 2)); + } + + /** + * Resolves a Penpot issue number to the internal Taiga project and issue IDs. + */ + private async resolveIssue(issueNumber: number): Promise<{ projectId: number; issueId: number }> { + const url = `${ReadTaigaIssueTool.TAIGA_API_BASE}/resolver?project=penpot&issue=${issueNumber}`; + const data = await this.fetchJson(url); + return { projectId: data.project, issueId: data.issue }; + } + + /** + * Fetches the full issue data including details, attachments, and comments. + */ + private async fetchIssueData(projectId: number, issueId: number): Promise { + // fetch issue details, attachments, and history in parallel + const [details, attachments, comments] = await Promise.all([ + this.fetchIssueDetails(issueId), + this.fetchAttachments(projectId, issueId), + this.fetchComments(issueId), + ]); + + return { + subject: details.subject, + description: details.description ?? "", + status: details.status_extra_info?.name ?? "Unknown", + attachments, + comments, + }; + } + + /** + * Fetches the core issue details from the Taiga API. + */ + private async fetchIssueDetails(issueId: number): Promise { + const url = `${ReadTaigaIssueTool.TAIGA_API_BASE}/issues/${issueId}`; + return this.fetchJson(url); + } + + /** + * Fetches the attachments for an issue. + */ + private async fetchAttachments(projectId: number, issueId: number): Promise { + const url = `${ReadTaigaIssueTool.TAIGA_API_BASE}/issues/attachments?project=${projectId}&object_id=${issueId}`; + const data: any[] = await this.fetchJson(url); + return data.map((a) => ({ + filename: a.name, + size: a.size, + url: a.url, + })); + } + + /** + * Fetches comments from the issue history. + * + * History entries that have a non-empty `comment` field are treated as comments. + */ + private async fetchComments(issueId: number): Promise { + const url = `${ReadTaigaIssueTool.TAIGA_API_BASE}/history/issue/${issueId}`; + const history: any[] = await this.fetchJson(url); + return history + .filter((entry) => entry.comment && entry.comment.trim().length > 0) + .map((entry) => ({ + username: entry.user?.username ?? "unknown", + comment: entry.comment, + })); + } + + /** + * Performs a GET request and returns the parsed JSON response. + * + * @throws Error if the HTTP response status is not OK + */ + private async fetchJson(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Taiga API request failed: ${response.status} ${response.statusText} (${url})`); + } + return response.json(); + } +} diff --git a/mcp/packages/server/src/types/nrepl-client.d.ts b/mcp/packages/server/src/types/nrepl-client.d.ts new file mode 100644 index 0000000000..21ed53fb72 --- /dev/null +++ b/mcp/packages/server/src/types/nrepl-client.d.ts @@ -0,0 +1,53 @@ +declare module "nrepl-client" { + import type { Socket } from "net"; + + interface NreplConnection extends Socket { + /** + * Evaluates the given Clojure expression on the nREPL server. + * + * @param code - the Clojure expression to evaluate + * @param callback - called with an error or array of response messages + */ + eval(code: string, callback: (err: Error | null, result: NreplMessage[]) => void): void; + + /** + * Sends a raw nREPL message to the server. + */ + send(message: Record, callback: (err: Error | null, result: NreplMessage[]) => void): void; + + /** + * Clones the current session, creating a new session that inherits the current state. + */ + clone(callback: (err: Error | null, result: NreplMessage[]) => void): void; + + /** + * Closes the current session. + */ + close(callback: (err: Error | null, result: NreplMessage[]) => void): void; + } + + interface NreplMessage { + id?: string; + session?: string; + "new-session"?: string; + ns?: string; + value?: string; + out?: string; + err?: string; + ex?: string; + status?: string[]; + } + + interface ConnectOptions { + port: number; + host?: string; + } + + /** + * Creates a connection to an nREPL server. + */ + function connect(options: ConnectOptions): NreplConnection; + + export default { connect }; + export type { NreplConnection, NreplMessage, ConnectOptions }; +} diff --git a/mcp/packages/server/src/utils/Semaphore.ts b/mcp/packages/server/src/utils/Semaphore.ts new file mode 100644 index 0000000000..e88a63db36 --- /dev/null +++ b/mcp/packages/server/src/utils/Semaphore.ts @@ -0,0 +1,69 @@ +import { createLogger } from "../logger"; + +/** + * Counting semaphore for bounding the parallelism of asynchronous operations. + * + * Calls in excess of the configured maximum are queued in FIFO order and + * proceed once an earlier holder has released its permit. + */ +export class Semaphore { + private static readonly logger = createLogger("Semaphore"); + + private available: number; + private readonly waiters: Array<() => void> = []; + + /** + * @param name - identifier used in log messages so the source of contention is recognisable + * @param maxConcurrent - the maximum number of permits that may be held simultaneously + */ + constructor( + private readonly name: string, + maxConcurrent: number + ) { + if (maxConcurrent < 1) { + throw new Error(`maxConcurrent must be at least 1; got ${maxConcurrent}`); + } + this.available = maxConcurrent; + } + + /** + * Acquires a permit, runs the given function, and releases the permit when it + * settles - including on rejection. Queues if no permit is currently available. + * + * @param fn - the function to run while holding the permit + * @returns the value returned by fn + */ + public async withPermit(fn: () => Promise): Promise { + await this.acquire(); + try { + return await fn(); + } finally { + this.release(); + } + } + + private acquire(): Promise { + if (this.available > 0) { + this.available--; + return Promise.resolve(); + } + return new Promise((resolve) => { + this.waiters.push(resolve); + Semaphore.logger.info( + "Semaphore '%s' saturated; request queued (%d waiting)", + this.name, + this.waiters.length + ); + }); + } + + private release(): void { + const next = this.waiters.shift(); + if (next !== undefined) { + // pass the permit directly to the next waiter without touching `available` + next(); + } else { + this.available++; + } + } +} diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index c90b714cc5..99fa1143d6 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -38,10 +38,10 @@ importers: version: 5.9.3 vite: specifier: ^7.0.8 - version: 7.3.1(@types/node@20.19.30) + version: 7.3.1(@types/node@20.19.30)(tsx@4.22.3) vite-live-preview: specifier: ^0.3.2 - version: 0.3.2(vite@7.3.1(@types/node@20.19.30)) + version: 0.3.2(vite@7.3.1(@types/node@20.19.30)(tsx@4.22.3)) packages/server: dependencies: @@ -60,12 +60,18 @@ importers: js-yaml: specifier: ^4.1.1 version: 4.1.1 + nrepl-client: + specifier: ^0.3.0 + version: 0.3.0 penpot-mcp: specifier: file:.. version: packages@file:packages pino: specifier: ^9.10.0 version: 9.14.0 + pino-loki: + specifier: ^2.6.0 + version: 2.6.0 pino-pretty: specifier: ^13.1.1 version: 13.1.3 @@ -106,6 +112,9 @@ importers: ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@20.19.30)(typescript@5.9.3) + tsx: + specifier: ^4.22.3 + version: 4.22.3 typescript: specifier: ^5.0.0 version: 5.9.3 @@ -136,6 +145,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -148,6 +163,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -160,6 +181,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -172,6 +199,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -184,6 +217,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -196,6 +235,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -208,6 +253,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -220,6 +271,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -232,6 +289,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -244,6 +307,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -256,6 +325,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -268,6 +343,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -280,6 +361,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -292,6 +379,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -304,6 +397,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -316,6 +415,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -328,6 +433,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} @@ -340,6 +451,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -352,6 +469,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} @@ -364,6 +487,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -376,6 +505,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} @@ -388,6 +523,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -400,6 +541,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -412,6 +559,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -424,6 +577,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -436,6 +595,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@hono/node-server@1.19.9': resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} engines: {node: '>=18.14.1'} @@ -878,6 +1043,9 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} + bencode@2.0.3: + resolution: {integrity: sha512-D/vrAD4dLVX23NalHwb8dSvsUsxeRPO8Y7ToKA015JQYq69MLDOMkC0uGZYA/MPpltLO8rt8eqFC2j8DxjTZ/w==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -1026,6 +1194,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1218,6 +1391,9 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + nrepl-client@0.3.0: + resolution: {integrity: sha512-EcROXUrzlGHKOdu/E/5WB0OESCI0iGHhdXeYk9cULYtd72eFJrM/Q1umvjTBfKWlT62y76cnyLG/3CmSCqT12w==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1268,6 +1444,10 @@ packages: pino-abstract-transport@3.0.0: resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + pino-loki@2.6.0: + resolution: {integrity: sha512-Qy+NeIdb0YmZe/M5mgnO5aGaAyVaeqgwn45T6VajhRXZlZVfGe1YNYhFa9UZyCeNFAPGaUkD2e9yPGjx+2BBYA==} + hasBin: true + pino-pretty@13.1.3: resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} hasBin: true @@ -1469,6 +1649,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.22.3: + resolution: {integrity: sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==} + engines: {node: '>=18.0.0'} + hasBin: true + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -1611,156 +1796,234 @@ snapshots: '@esbuild/aix-ppc64@0.27.2': optional: true + '@esbuild/aix-ppc64@0.28.0': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true '@esbuild/android-arm64@0.27.2': optional: true + '@esbuild/android-arm64@0.28.0': + optional: true + '@esbuild/android-arm@0.25.12': optional: true '@esbuild/android-arm@0.27.2': optional: true + '@esbuild/android-arm@0.28.0': + optional: true + '@esbuild/android-x64@0.25.12': optional: true '@esbuild/android-x64@0.27.2': optional: true + '@esbuild/android-x64@0.28.0': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true '@esbuild/darwin-arm64@0.27.2': optional: true + '@esbuild/darwin-arm64@0.28.0': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true '@esbuild/darwin-x64@0.27.2': optional: true + '@esbuild/darwin-x64@0.28.0': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true '@esbuild/freebsd-arm64@0.27.2': optional: true + '@esbuild/freebsd-arm64@0.28.0': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true '@esbuild/freebsd-x64@0.27.2': optional: true + '@esbuild/freebsd-x64@0.28.0': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true '@esbuild/linux-arm64@0.27.2': optional: true + '@esbuild/linux-arm64@0.28.0': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true '@esbuild/linux-arm@0.27.2': optional: true + '@esbuild/linux-arm@0.28.0': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true '@esbuild/linux-ia32@0.27.2': optional: true + '@esbuild/linux-ia32@0.28.0': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true '@esbuild/linux-loong64@0.27.2': optional: true + '@esbuild/linux-loong64@0.28.0': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true '@esbuild/linux-mips64el@0.27.2': optional: true + '@esbuild/linux-mips64el@0.28.0': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true '@esbuild/linux-ppc64@0.27.2': optional: true + '@esbuild/linux-ppc64@0.28.0': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true '@esbuild/linux-riscv64@0.27.2': optional: true + '@esbuild/linux-riscv64@0.28.0': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true '@esbuild/linux-s390x@0.27.2': optional: true + '@esbuild/linux-s390x@0.28.0': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true '@esbuild/linux-x64@0.27.2': optional: true + '@esbuild/linux-x64@0.28.0': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true '@esbuild/netbsd-arm64@0.27.2': optional: true + '@esbuild/netbsd-arm64@0.28.0': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true '@esbuild/netbsd-x64@0.27.2': optional: true + '@esbuild/netbsd-x64@0.28.0': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true '@esbuild/openbsd-arm64@0.27.2': optional: true + '@esbuild/openbsd-arm64@0.28.0': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true '@esbuild/openbsd-x64@0.27.2': optional: true + '@esbuild/openbsd-x64@0.28.0': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true '@esbuild/openharmony-arm64@0.27.2': optional: true + '@esbuild/openharmony-arm64@0.28.0': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true '@esbuild/sunos-x64@0.27.2': optional: true + '@esbuild/sunos-x64@0.28.0': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true '@esbuild/win32-arm64@0.27.2': optional: true + '@esbuild/win32-arm64@0.28.0': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true '@esbuild/win32-ia32@0.27.2': optional: true + '@esbuild/win32-ia32@0.28.0': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true '@esbuild/win32-x64@0.27.2': optional: true + '@esbuild/win32-x64@0.28.0': + optional: true + '@hono/node-server@1.19.9(hono@4.11.7)': dependencies: hono: 4.11.7 @@ -2085,6 +2348,8 @@ snapshots: atomic-sleep@1.0.0: {} + bencode@2.0.3: {} + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -2270,6 +2535,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + escalade@3.2.0: {} escape-goat@4.0.0: {} @@ -2445,6 +2739,11 @@ snapshots: negotiator@1.0.0: {} + nrepl-client@0.3.0: + dependencies: + bencode: 2.0.3 + tree-kill: 1.2.2 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -2481,6 +2780,11 @@ snapshots: dependencies: split2: 4.2.0 + pino-loki@2.6.0: + dependencies: + pino-abstract-transport: 2.0.0 + pump: 3.0.3 + pino-pretty@13.1.3: dependencies: colorette: 2.0.20 @@ -2768,6 +3072,12 @@ snapshots: tslib@2.8.1: {} + tsx@4.22.3: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -2786,7 +3096,7 @@ snapshots: vary@1.1.2: {} - vite-live-preview@0.3.2(vite@7.3.1(@types/node@20.19.30)): + vite-live-preview@0.3.2(vite@7.3.1(@types/node@20.19.30)(tsx@4.22.3)): dependencies: '@commander-js/extra-typings': 12.1.0(commander@12.1.0) '@types/ansi-html': 0.0.0 @@ -2798,14 +3108,14 @@ snapshots: debug: 4.4.3 escape-goat: 4.0.0 p-defer: 4.0.1 - vite: 7.3.1(@types/node@20.19.30) + vite: 7.3.1(@types/node@20.19.30)(tsx@4.22.3) ws: 8.19.0 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - vite@7.3.1(@types/node@20.19.30): + vite@7.3.1(@types/node@20.19.30)(tsx@4.22.3): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -2816,6 +3126,7 @@ snapshots: optionalDependencies: '@types/node': 20.19.30 fsevents: 2.3.3 + tsx: 4.22.3 which@2.0.2: dependencies: diff --git a/mcp/scripts/start-mcp-devenv b/mcp/scripts/start-mcp-devenv new file mode 100755 index 0000000000..62e957a9eb --- /dev/null +++ b/mcp/scripts/start-mcp-devenv @@ -0,0 +1,6 @@ +#!/bin/sh + +# This starts the MCP server in a configuration for Penpot development +# (assuming devenv) + +PENPOT_MCP_SERVER_HOST=0.0.0.0 PENPOT_MCP_REMOTE_MODE=true PENPOT_MCP_DEVENV=true pnpm run bootstrap diff --git a/opencode.json b/opencode.json deleted file mode 100644 index 6376bc70e5..0000000000 --- a/opencode.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "instructions": ["AGENTS.md"] -} diff --git a/package.json b/package.json index fbb4c5d92f..543592511c 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,10 @@ "fmt": "./scripts/fmt" }, "devDependencies": { - "@github/copilot": "^1.0.21", - "@types/node": "^25.5.2", + "@github/copilot": "^1.0.44", + "@types/node": "^25.6.2", "esbuild": "^0.28.0", - "opencode-ai": "^1.14.19" + "nrepl-client": "^0.3.0", + "opencode-ai": "^1.15.4" } } diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 4b58244e86..614a77407b 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -1,8 +1,10 @@ ## 1.5.0 (Unreleased) - **plugins-runtime**: Added `version` field that returns the current version +- **plugins-runtime**: Added optional parameter `throwOnError` to `penpot.ui.sendMessage` (default false, backwards-compatible) - **plugin-types**: Added a flags subcontexts with the flag `naturalChildrenOrdering` - **plugin-types**: Fix penpot.openPage() to navigate in same tab by default +- **plugin-types:** Change `LibraryComponent.isVariant()` return type to type guard `this is LibraryVariantComponent` - **plugin-types**: Added `createVariantFromComponents` - **plugin-types**: Change return type of `combineAsVariants` - **plugin-types**: Added `textBounds` property for text shapes diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index dd42f365fe..bad2ce5e9f 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -54,13 +54,14 @@ export interface Penpot extends Omit< * Sends a message to the plugin UI. * * @param message content usually is an object + * @param throwOnError if true, throws an error when the message cannot be cloned instead of logging to console (default: false) * * @example * ```js * this.sendMessage({ type: 'example-type', content: 'data we want to share' }); * ``` */ - sendMessage: (message: unknown) => void; + sendMessage: (message: unknown, throwOnError?: boolean) => void; /** * This is usually used in the `plugin.ts` file in order to handle the data sent by our plugin * @@ -2672,9 +2673,11 @@ export interface LibraryComponent extends LibraryElement { mainInstance(): Shape; /** - * @return true when this component is a VariantComponent + * Checks whether this component is a variant component. + * If true, the component can be used as a `LibraryVariantComponent`, + * which provides additional attributes (e.g. `variants`) and methods. */ - isVariant(): boolean; + isVariant(): this is LibraryVariantComponent; /** * Creates a new Variant from this standard Component. It creates a VariantContainer, transform this Component into a VariantComponent, duplicates it, and creates a diff --git a/plugins/libs/plugins-runtime/src/lib/api/index.ts b/plugins/libs/plugins-runtime/src/lib/api/index.ts index 00d64e58ed..52bd078ca5 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/index.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/index.ts @@ -67,17 +67,24 @@ export function createApi( return plugin.resizeModal(width, height); }, - sendMessage(message: unknown) { + sendMessage(message: unknown, throwOnError = false) { let cloneableMessage: unknown; try { cloneableMessage = structuredClone(message); } catch (err) { - console.error( + const msg = 'plugin sendMessage: the message could not be cloned. ' + - 'Ensure the message does not contain functions, DOM nodes, or other non-serializable values.', - err, - ); + 'Ensure the message does not contain functions, DOM nodes, or other non-serializable values.'; + if (throwOnError) { + throw new Error( + msg + + ' Original error: ' + + (err instanceof Error ? err.message : String(err)), + ); + } else { + console.error(msg, err); + } return; } diff --git a/plugins/libs/plugins-runtime/src/lib/api/openUI.api.ts b/plugins/libs/plugins-runtime/src/lib/api/openUI.api.ts index dbbeba184c..3e56ea15bb 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/openUI.api.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/openUI.api.ts @@ -10,7 +10,27 @@ export const openUIApi = z z.enum(['dark', 'light']), openUISchema.optional(), z.boolean().optional(), + z.boolean().optional(), + z.boolean().optional(), ) - .implement((title, url, theme, options, allowDownloads) => { - return createModal(title, url, theme, options, allowDownloads); - }); + .implement( + ( + title, + url, + theme, + options, + allowDownloads, + allowClipboardRead, + allowClipboardWrite, + ) => { + return createModal( + title, + url, + theme, + options, + allowDownloads, + allowClipboardRead, + allowClipboardWrite, + ); + }, + ); diff --git a/plugins/libs/plugins-runtime/src/lib/create-modal.spec.ts b/plugins/libs/plugins-runtime/src/lib/create-modal.spec.ts index 80d28fe56c..4cefab63c2 100644 --- a/plugins/libs/plugins-runtime/src/lib/create-modal.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/create-modal.spec.ts @@ -104,4 +104,73 @@ describe('createModal', () => { expect(modal.wrapper.style.width).toEqual('200px'); expect(modal.wrapper.style.height).toEqual('200px'); }); + + it('should set allow-clipboard-read attribute when allowClipboardRead is true', () => { + const theme: Theme = 'light'; + + createModal( + 'Test Modal', + 'https://example.com', + theme, + undefined, + false, + true, + false, + ); + + expect(modalMock.setAttribute).toHaveBeenCalledWith( + 'allow-clipboard-read', + 'true', + ); + expect(modalMock.setAttribute).not.toHaveBeenCalledWith( + 'allow-clipboard-write', + 'true', + ); + }); + + it('should set allow-clipboard-write attribute when allowClipboardWrite is true', () => { + const theme: Theme = 'light'; + + createModal( + 'Test Modal', + 'https://example.com', + theme, + undefined, + false, + false, + true, + ); + + expect(modalMock.setAttribute).toHaveBeenCalledWith( + 'allow-clipboard-write', + 'true', + ); + expect(modalMock.setAttribute).not.toHaveBeenCalledWith( + 'allow-clipboard-read', + 'true', + ); + }); + + it('should set both clipboard attributes when both are true', () => { + const theme: Theme = 'light'; + + createModal( + 'Test Modal', + 'https://example.com', + theme, + undefined, + false, + true, + true, + ); + + expect(modalMock.setAttribute).toHaveBeenCalledWith( + 'allow-clipboard-read', + 'true', + ); + expect(modalMock.setAttribute).toHaveBeenCalledWith( + 'allow-clipboard-write', + 'true', + ); + }); }); diff --git a/plugins/libs/plugins-runtime/src/lib/create-modal.ts b/plugins/libs/plugins-runtime/src/lib/create-modal.ts index 01422c76b6..d6cd2d1623 100644 --- a/plugins/libs/plugins-runtime/src/lib/create-modal.ts +++ b/plugins/libs/plugins-runtime/src/lib/create-modal.ts @@ -10,6 +10,8 @@ export function createModal( theme: Theme, options?: OpenUIOptions, allowDownloads?: boolean, + allowClipboardRead?: boolean, + allowClipboardWrite?: boolean, ) { const modal = document.createElement('plugin-modal') as PluginModalElement; @@ -44,6 +46,14 @@ export function createModal( modal.setAttribute('allow-downloads', 'true'); } + if (allowClipboardRead) { + modal.setAttribute('allow-clipboard-read', 'true'); + } + + if (allowClipboardWrite) { + modal.setAttribute('allow-clipboard-write', 'true'); + } + document.body.appendChild(modal); return modal; diff --git a/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.spec.ts b/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.spec.ts index d7b774c2c3..fb19f291ab 100644 --- a/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.spec.ts @@ -99,6 +99,35 @@ describe('PluginModalElement', () => { modal.remove(); }); + it('should set iframe allow attribute for clipboard permissions', () => { + const modal = document.createElement('plugin-modal'); + modal.setAttribute('title', 'Test modal'); + modal.setAttribute('iframe-src', 'about:blank'); + modal.setAttribute('allow-clipboard-read', 'true'); + modal.setAttribute('allow-clipboard-write', 'true'); + document.body.appendChild(modal); + + const iframe = modal.shadowRoot?.querySelector('iframe'); + expect(iframe).toBeTruthy(); + expect(iframe?.allow).toContain('clipboard-read'); + expect(iframe?.allow).toContain('clipboard-write'); + + modal.remove(); + }); + + it('should not set clipboard allow attributes when permissions are absent', () => { + const modal = document.createElement('plugin-modal'); + modal.setAttribute('title', 'Test modal'); + modal.setAttribute('iframe-src', 'about:blank'); + document.body.appendChild(modal); + + const iframe = modal.shadowRoot?.querySelector('iframe'); + expect(iframe).toBeTruthy(); + expect(iframe?.allow).toBe(''); + + modal.remove(); + }); + it('should dispatch close event when close button is clicked', () => { const modal = document.createElement('plugin-modal'); modal.setAttribute('title', 'Test modal'); diff --git a/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts b/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts index c61ad7fce5..53ea472494 100644 --- a/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts +++ b/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts @@ -52,6 +52,10 @@ export class PluginModalElement extends HTMLElement { const title = this.getAttribute('title'); const iframeSrc = this.getAttribute('iframe-src'); const allowDownloads = this.getAttribute('allow-downloads') || false; + const allowClipboardRead = + this.getAttribute('allow-clipboard-read') || false; + const allowClipboardWrite = + this.getAttribute('allow-clipboard-write') || false; if (!title || !iframeSrc) { throw new Error('title and iframe-src attributes are required'); @@ -95,7 +99,12 @@ export class PluginModalElement extends HTMLElement { const iframe = document.createElement('iframe'); iframe.src = iframeSrc; - iframe.allow = ''; + + const allowList: string[] = []; + if (allowClipboardRead) allowList.push('clipboard-read'); + if (allowClipboardWrite) allowList.push('clipboard-write'); + iframe.allow = allowList.join('; '); + iframe.sandbox.add( 'allow-scripts', 'allow-forms', diff --git a/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts b/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts index bd5895c02b..16d4fd5e28 100644 --- a/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts +++ b/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts @@ -19,6 +19,8 @@ export const manifestSchema = z.object({ 'comment:write', 'allow:downloads', 'allow:localstorage', + 'clipboard:read', + 'clipboard:write', ]), ), }); 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 206f43ba34..d53f4f5296 100644 --- a/plugins/libs/plugins-runtime/src/lib/plugin-manager.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/plugin-manager.spec.ts @@ -123,6 +123,8 @@ describe('createPluginManager', () => { 'light', { width: 400, height: 300 }, true, + false, + false, ); expect(mockModal.setTheme).toHaveBeenCalledWith('light'); expect(mockModal.addEventListener).toHaveBeenCalledWith( diff --git a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts index 0b4035794a..8b811f55eb 100644 --- a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts +++ b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts @@ -27,6 +27,14 @@ export async function createPluginManager( (s) => s === 'allow:downloads', ); + const allowClipboardRead = !!manifest.permissions.find( + (s) => s === 'clipboard:read', + ); + + const allowClipboardWrite = !!manifest.permissions.find( + (s) => s === 'clipboard:write', + ); + const themeChangeId = context.addListener('themechange', (theme: Theme) => { modal?.setTheme(theme); }); @@ -91,7 +99,15 @@ export async function createPluginManager( return; } - modal = openUIApi(name, modalUrl, theme, options, allowDownloads); + modal = openUIApi( + name, + modalUrl, + theme, + options, + allowDownloads, + allowClipboardRead, + allowClipboardWrite, + ); modal.setTheme(theme); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32b4fa3382..aa7c8ea720 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,17 +9,20 @@ importers: .: devDependencies: '@github/copilot': - specifier: ^1.0.21 - version: 1.0.21 + specifier: ^1.0.44 + version: 1.0.44 '@types/node': - specifier: ^25.5.2 - version: 25.5.2 + specifier: ^25.6.2 + version: 25.6.2 esbuild: specifier: ^0.28.0 version: 0.28.0 + nrepl-client: + specifier: ^0.3.0 + version: 0.3.0 opencode-ai: - specifier: ^1.14.19 - version: 1.14.19 + specifier: ^1.15.4 + version: 1.15.4 packages: @@ -179,120 +182,132 @@ packages: cpu: [x64] os: [win32] - '@github/copilot-darwin-arm64@1.0.21': - resolution: {integrity: sha512-aB+s9ldTwcyCOYmzjcQ4SknV6g81z92T8aUJEJZBwOXOTBeWKAJtk16ooAKangZgdwuLgO3or1JUjx1FJAm5nQ==} + '@github/copilot-darwin-arm64@1.0.44': + resolution: {integrity: sha512-9NqA5sT2spmNsehxhs51GhXRZIZga5nq+WcMl4LG2QrUPJRDwvHf1bDKqETJUBbYvBY8jONGuTKMRofkMI68YQ==} cpu: [arm64] os: [darwin] hasBin: true - '@github/copilot-darwin-x64@1.0.21': - resolution: {integrity: sha512-aNad81DOGuGShmaiFNIxBUSZLwte0dXmDYkGfAF9WJIgY4qP4A8CPWFoNr8//gY+4CwaIf9V+f/OC6k2BdECbw==} + '@github/copilot-darwin-x64@1.0.44': + resolution: {integrity: sha512-QPD8KtXx07SIKILGBl4JDhPyL2Qo0FMmaTYVxR6nkyHkHnFPsUZD6VWGR+T/KMLkcUXFM85Xc1ba9Y27s4nRrQ==} cpu: [x64] os: [darwin] hasBin: true - '@github/copilot-linux-arm64@1.0.21': - resolution: {integrity: sha512-FL0NsCnHax4czHVv1S8iBqPLGZDhZ28N3+6nT29xWGhmjBWTkIofxLThKUPcyyMsfPTTxIlrdwWa8qQc5z2Q+g==} + '@github/copilot-linux-arm64@1.0.44': + resolution: {integrity: sha512-Z8ScIUP433xS18f68NP9jM9zW320Xzpi2wf7Nig/VyfrwupBy25UTezydQMT0KQHLWTEleHOPcYnASY3HgJXnQ==} cpu: [arm64] os: [linux] hasBin: true - '@github/copilot-linux-x64@1.0.21': - resolution: {integrity: sha512-S7pWVI16hesZtxYbIyfw+MHZpc5ESoGKUVr5Y+lZJNaM2340gJGPQzQwSpvKIRMLHRKI2hXLwciAnYeMFxE/Tg==} + '@github/copilot-linux-x64@1.0.44': + resolution: {integrity: sha512-KUl6lvJt0HNKaXSx0T0bIWJ3rvrGwgZYMlkDfqMbuMnZatEQJbjPwxmL/IDfp/c0DyKd7K+ajl17wHYcN/hJIQ==} cpu: [x64] os: [linux] hasBin: true - '@github/copilot-win32-arm64@1.0.21': - resolution: {integrity: sha512-a9qc2Ku+XbyBkXCclbIvBbIVnECACTIWnPctmXWsQeSdeapGxgfHGux7y8hAFV5j6+nhCm6cnyEMS3rkZjAhdA==} + '@github/copilot-win32-arm64@1.0.44': + resolution: {integrity: sha512-JVJxZJwAc95ZfapgOXjNFwSqrWlvC3heo128L+CDkdZ6lwpD1dTGMHT/6rMMEeo3xjZmMm8tiynfwsHLDgTtvQ==} cpu: [arm64] os: [win32] hasBin: true - '@github/copilot-win32-x64@1.0.21': - resolution: {integrity: sha512-9klu+7NQ6tEyb8sibb0rsbimBivDrnNltZho10Bgbf1wh3o+erTjffXDjW9Zkyaw8lZA9Fz8bqhVkKntZq58Lg==} + '@github/copilot-win32-x64@1.0.44': + resolution: {integrity: sha512-Yj3KQ/DqwS50PwRtyQITX2mWIVZeJeX+y0faVSMwUUzG1qxmMcme7wimhKOyc4LSV11DpgVB9MSiBw2xys7iww==} cpu: [x64] os: [win32] hasBin: true - '@github/copilot@1.0.21': - resolution: {integrity: sha512-P+nORjNKAtl92jYCG6Qr1Rsw2JoyScgeQSkIR6O2WB37WS5JVdA4ax1WVualMbfuc9V58CPHX6fwyNpkI89FkQ==} + '@github/copilot@1.0.44': + resolution: {integrity: sha512-wr/GmNOUaJK/giJK5abyB1oTpEowgFKLi+NJnlyAymKiK/GKCaRlJqiX23H2RetM8vD2hDYUFUFm9lTCooGy0g==} hasBin: true - '@types/node@25.5.2': - resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==} + '@types/node@25.6.2': + resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==} + + bencode@2.0.3: + resolution: {integrity: sha512-D/vrAD4dLVX23NalHwb8dSvsUsxeRPO8Y7ToKA015JQYq69MLDOMkC0uGZYA/MPpltLO8rt8eqFC2j8DxjTZ/w==} esbuild@0.28.0: resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} engines: {node: '>=18'} hasBin: true - opencode-ai@1.14.19: - resolution: {integrity: sha512-67h56qYcJivd2U9VK8LJvyMBCc3ZE3HcJ/qL4YtaidSnjEumy4SxO+HHlDISsLase7TUQ0w1nULOAibdZxGzbQ==} + nrepl-client@0.3.0: + resolution: {integrity: sha512-EcROXUrzlGHKOdu/E/5WB0OESCI0iGHhdXeYk9cULYtd72eFJrM/Q1umvjTBfKWlT62y76cnyLG/3CmSCqT12w==} + + opencode-ai@1.15.4: + resolution: {integrity: sha512-Z5PeJwFNUW4sFW+jYHQTnJm6858dECvWOATvnG0S66Nn46zwjaaZJEJMKQEPOW7Yog99j6k7xRKvJPPAjKDXfQ==} + cpu: [arm64, x64] + os: [darwin, linux, win32] hasBin: true - opencode-darwin-arm64@1.14.19: - resolution: {integrity: sha512-gjJ97dTiBbCas3Y7K6KQMQm5/KNIBOM9+10KZHqNr2bQfn0N09O977ZkXoX6IVBBE2632Ahaa71d4pzLKN9ULw==} + opencode-darwin-arm64@1.15.4: + resolution: {integrity: sha512-d+0sFAAhrDtjQbxRZvYSzy3g/xj/xUDRWUVBWGfkJAx0QEWc/v7cksmnYj3p3l88Gxm/rWeLCh6H32pw1/En8A==} cpu: [arm64] os: [darwin] - opencode-darwin-x64-baseline@1.14.19: - resolution: {integrity: sha512-UuwLpa511h7qQ+rTmOUmsHch/N4NBQT64dzg+iLWnR5yoR/81inENpbwxiS7hXpVdzCUGo/YnxI1u6SIBoMlTQ==} + opencode-darwin-x64-baseline@1.15.4: + resolution: {integrity: sha512-Lj9wsEPFyEOgLO6J3DsCXdSu/IAJnW/RjtD1oojAao6uHvhs5uXyj1mrsmK8GrtAfCT4JUh8W38o3YYXGjItSw==} cpu: [x64] os: [darwin] - opencode-darwin-x64@1.14.19: - resolution: {integrity: sha512-uksrjOtWI7Ob5JvjZBSsrKuy3JVF9d89oYZYfWS5m8ordNyv1Nob39MXJXizv85ozsXjSb0rqjpJurnJw8K+tQ==} + opencode-darwin-x64@1.15.4: + resolution: {integrity: sha512-H412BUw5O+bmXfzLo6UMCWVc3DOYEM0RCI5Kt+Ynqh+Q9878bXK6mwRR7VXgGVBkkH2U4GtT1uDgY0BzSK185Q==} cpu: [x64] os: [darwin] - opencode-linux-arm64-musl@1.14.19: - resolution: {integrity: sha512-R1BJuBGWHfBxfKvIA/Hb4nhYaJgCKl1B+mAGNydu+z0CLtGtwU8r+kQWF/G2N0y8Vx6Y6DRfJiv1X0eZEfD1BQ==} + opencode-linux-arm64-musl@1.15.4: + resolution: {integrity: sha512-TO2IVSoYolGKJahf73/hRsJBGxLKOdP/akYPzI0hQQvW4oVrmQkZ3s13jU1+LXIEn4Zbj/TB18QvLzvXrnrEhA==} cpu: [arm64] os: [linux] - opencode-linux-arm64@1.14.19: - resolution: {integrity: sha512-Bo+aZOppLF366mgGfK0CnIcAVy1EmsrBv93eot1CmPSN1oeud07GpGdn3Bjl5f6KuBx1io6JsvjQyHno+MH5AA==} + opencode-linux-arm64@1.15.4: + resolution: {integrity: sha512-V+x/u9JnPOLPEfqbePSCL0OQdin5gs1V35VsVxj19WaZDEwxlMVjOe6HjVKEY64/O6htkPxCCZohmnMU4dVBMQ==} cpu: [arm64] os: [linux] - opencode-linux-x64-baseline-musl@1.14.19: - resolution: {integrity: sha512-+K8MuGoHugtUec4P/nKcTwZFUipHfW7oPpwlIoPiAQou3bNFTzzP6rslbzzNwjXlQRsUw9GAtuIPDOCL6CkgDg==} + opencode-linux-x64-baseline-musl@1.15.4: + resolution: {integrity: sha512-xOJ3aHg2+2GrT9F/KmAF0JLB1D6K3SCY/626n+fLjs/AEFvLdmE3TYhoXPEyGH2I9F4kF+4p2xk0pg2b+LVlZQ==} cpu: [x64] os: [linux] - opencode-linux-x64-baseline@1.14.19: - resolution: {integrity: sha512-KuvITzg4iK0hdIjpNZepwu3bLZ/dUZDI6BwCoV4w//VEP1j3UfDyeS3vWghKcQLd2T1+yybuEMM/3RXcwm/lGQ==} + opencode-linux-x64-baseline@1.15.4: + resolution: {integrity: sha512-dTlV8tAVN8nFdPb7527GR6/BpyIVavAcXJmZ2VbS1daXu4C6k6bpmjiS/ZFKlphRZiKKiEzFrHlimao4BMchVQ==} cpu: [x64] os: [linux] - opencode-linux-x64-musl@1.14.19: - resolution: {integrity: sha512-0qe2+X76UJdrCdhdlJyfubMC4tveHAVxjSmPq7g9Zm95heBeJdcQDCLeyQk/lGgeXgsZzVPfLmyTWNtBvCZYFQ==} + opencode-linux-x64-musl@1.15.4: + resolution: {integrity: sha512-IbMaM6zrakdtDD55GUhlT/WeXomXmKsVqo3XQuOaGXprBg3W5alsxXh60SZpV3ftbdcMD/eiB/PYtN/ZN8Fa5w==} cpu: [x64] os: [linux] - opencode-linux-x64@1.14.19: - resolution: {integrity: sha512-2GljfL7BeG4xALBJVRwaAGCM/dzYF5aQf6bfLTKsQIl6QpLUguYSF+fkStBHLeehyqbDP5MtiEEuXjC0+mecjA==} + opencode-linux-x64@1.15.4: + resolution: {integrity: sha512-2c20aldKLfNkg6N6nABvvK1fuaCwYLo/HNeL8ikellkFMeGalCGDhkL/VQ8R8KPV3ohVZJtZwG0nkFiA8MeHCg==} cpu: [x64] os: [linux] - opencode-windows-arm64@1.14.19: - resolution: {integrity: sha512-8/vRHe5tHexikfPceLmpjsQiEhuDTOSCSlEmP4s0Yq3UAkVaDAxpiWq7Bx4g8hjr1gzfXD9vbjV+WHq/BtMC/w==} + opencode-windows-arm64@1.15.4: + resolution: {integrity: sha512-kr3nIWmYH7NC0Vgrhgjp9EmCuy5MuxjIRrSjzlfRLMaML6U/a0Hsr3AahBwI1KjT+HEhz5u6xpodZeeEDY3nPQ==} cpu: [arm64] os: [win32] - opencode-windows-x64-baseline@1.14.19: - resolution: {integrity: sha512-Z8imEJK/srE/r1fr7oNLvpLTeRJQyuL7vsbXvCt3T7j2Ew9BOZ7RuYa8EE0R6bNqQ+MLhBGPiAG7NWc++MgK8Q==} + opencode-windows-x64-baseline@1.15.4: + resolution: {integrity: sha512-2/elQ163r4Q97bYJRrY09IG+bpqh0AKpfutDGCaokFdLWIWQN/cFvjzb4C+BKzLFsU9LRfoyvPhe4nXMm1+S4A==} cpu: [x64] os: [win32] - opencode-windows-x64@1.14.19: - resolution: {integrity: sha512-/TqGN91WiUzx7IPMPwmpMIzRixi5TMjaBcC9FH1TgD7DCqKnP6pokvu+ak0C9xwA4wKorE9PoZzeRt/+c3rDCQ==} + opencode-windows-x64@1.15.4: + resolution: {integrity: sha512-f6p40u3yLEbiq4pzBOXAwtW/NP/dL8uTurHfraPcfezA4ua5DEm4vSoSePUY0CHtubUPuDe0wRUA1s53sysjPQ==} cpu: [x64] os: [win32] - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} snapshots: @@ -374,36 +389,38 @@ snapshots: '@esbuild/win32-x64@0.28.0': optional: true - '@github/copilot-darwin-arm64@1.0.21': + '@github/copilot-darwin-arm64@1.0.44': optional: true - '@github/copilot-darwin-x64@1.0.21': + '@github/copilot-darwin-x64@1.0.44': optional: true - '@github/copilot-linux-arm64@1.0.21': + '@github/copilot-linux-arm64@1.0.44': optional: true - '@github/copilot-linux-x64@1.0.21': + '@github/copilot-linux-x64@1.0.44': optional: true - '@github/copilot-win32-arm64@1.0.21': + '@github/copilot-win32-arm64@1.0.44': optional: true - '@github/copilot-win32-x64@1.0.21': + '@github/copilot-win32-x64@1.0.44': optional: true - '@github/copilot@1.0.21': + '@github/copilot@1.0.44': optionalDependencies: - '@github/copilot-darwin-arm64': 1.0.21 - '@github/copilot-darwin-x64': 1.0.21 - '@github/copilot-linux-arm64': 1.0.21 - '@github/copilot-linux-x64': 1.0.21 - '@github/copilot-win32-arm64': 1.0.21 - '@github/copilot-win32-x64': 1.0.21 + '@github/copilot-darwin-arm64': 1.0.44 + '@github/copilot-darwin-x64': 1.0.44 + '@github/copilot-linux-arm64': 1.0.44 + '@github/copilot-linux-x64': 1.0.44 + '@github/copilot-win32-arm64': 1.0.44 + '@github/copilot-win32-x64': 1.0.44 - '@types/node@25.5.2': + '@types/node@25.6.2': dependencies: - undici-types: 7.18.2 + undici-types: 7.19.2 + + bencode@2.0.3: {} esbuild@0.28.0: optionalDependencies: @@ -434,55 +451,62 @@ snapshots: '@esbuild/win32-ia32': 0.28.0 '@esbuild/win32-x64': 0.28.0 - opencode-ai@1.14.19: + nrepl-client@0.3.0: + dependencies: + bencode: 2.0.3 + tree-kill: 1.2.2 + + opencode-ai@1.15.4: optionalDependencies: - opencode-darwin-arm64: 1.14.19 - opencode-darwin-x64: 1.14.19 - opencode-darwin-x64-baseline: 1.14.19 - opencode-linux-arm64: 1.14.19 - opencode-linux-arm64-musl: 1.14.19 - opencode-linux-x64: 1.14.19 - opencode-linux-x64-baseline: 1.14.19 - opencode-linux-x64-baseline-musl: 1.14.19 - opencode-linux-x64-musl: 1.14.19 - opencode-windows-arm64: 1.14.19 - opencode-windows-x64: 1.14.19 - opencode-windows-x64-baseline: 1.14.19 + opencode-darwin-arm64: 1.15.4 + opencode-darwin-x64: 1.15.4 + opencode-darwin-x64-baseline: 1.15.4 + opencode-linux-arm64: 1.15.4 + opencode-linux-arm64-musl: 1.15.4 + opencode-linux-x64: 1.15.4 + opencode-linux-x64-baseline: 1.15.4 + opencode-linux-x64-baseline-musl: 1.15.4 + opencode-linux-x64-musl: 1.15.4 + opencode-windows-arm64: 1.15.4 + opencode-windows-x64: 1.15.4 + opencode-windows-x64-baseline: 1.15.4 - opencode-darwin-arm64@1.14.19: + opencode-darwin-arm64@1.15.4: optional: true - opencode-darwin-x64-baseline@1.14.19: + opencode-darwin-x64-baseline@1.15.4: optional: true - opencode-darwin-x64@1.14.19: + opencode-darwin-x64@1.15.4: optional: true - opencode-linux-arm64-musl@1.14.19: + opencode-linux-arm64-musl@1.15.4: optional: true - opencode-linux-arm64@1.14.19: + opencode-linux-arm64@1.15.4: optional: true - opencode-linux-x64-baseline-musl@1.14.19: + opencode-linux-x64-baseline-musl@1.15.4: optional: true - opencode-linux-x64-baseline@1.14.19: + opencode-linux-x64-baseline@1.15.4: optional: true - opencode-linux-x64-musl@1.14.19: + opencode-linux-x64-musl@1.15.4: optional: true - opencode-linux-x64@1.14.19: + opencode-linux-x64@1.15.4: optional: true - opencode-windows-arm64@1.14.19: + opencode-windows-arm64@1.15.4: optional: true - opencode-windows-x64-baseline@1.14.19: + opencode-windows-x64-baseline@1.15.4: optional: true - opencode-windows-x64@1.14.19: + opencode-windows-x64@1.15.4: optional: true - undici-types@7.18.2: {} + tree-kill@1.2.2: {} + + undici-types@7.19.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000000..ca93fb1e8d --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + opencode-ai: true diff --git a/render-wasm/AGENTS.md b/render-wasm/AGENTS.md deleted file mode 100644 index 511b7da0c9..0000000000 --- a/render-wasm/AGENTS.md +++ /dev/null @@ -1,62 +0,0 @@ -# render-wasm – Agent Instructions - -This component compiles Rust to WebAssembly using Emscripten + -Skia. It is consumed by the frontend as a canvas renderer. - -## Commands - -```bash -./build # Compile Rust → WASM (requires Emscripten environment) -./watch # Incremental rebuild on file change -./test # Run Rust unit tests (cargo test) -./lint # clippy -D warnings -cargo fmt --check -``` - -Run a single test: -```bash -cargo test my_test_name # by test function name -cargo test shapes:: # by module prefix -``` - -Build output lands in `../frontend/resources/public/js/` (consumed directly by the frontend dev server). - -## Build Environment - -The `_build_env` script sets required env vars (Emscripten paths, -`EMCC_CFLAGS`). `./build` sources it automatically. The WASM heap is -configured to 256 MB initial with geometric growth. - -## Architecture - -**Global state** — a single `unsafe static mut State` accessed -exclusively through `with_state!` / `with_state_mut!` macros. Never -access it directly. - -**Tile-based rendering** — only 512×512 tiles within the viewport -(plus a pre-render buffer) are drawn each frame. Tiles outside the -range are skipped. - -**Two-phase updates** — shape data is written via exported setter -functions (called from ClojureScript), then a single `render_frame()` -triggers the actual Skia draw calls. - -**Shape hierarchy** — shapes live in a flat pool indexed by UUID; -parent/child relationships are tracked separately. - -## Key Source Modules - -| Path | Role | -|------|------| -| `src/lib.rs` | WASM exports — all functions callable from JS | -| `src/state.rs` | Global `State` struct definition | -| `src/render/` | Tile rendering pipeline, Skia surface management | -| `src/shapes/` | Shape types and Skia draw logic per shape | -| `src/wasm/` | JS interop helpers (memory, string encoding) | - -## Frontend Integration - -The WASM module is loaded by `app.render-wasm.*` namespaces in the -frontend. ClojureScript calls exported Rust functions to push shape -data, then calls `render_frame`. Do not change export function -signatures without updating the corresponding ClojureScript bridge. diff --git a/render-wasm/Cargo.toml b/render-wasm/Cargo.toml index a26e798e13..77c4c10715 100644 --- a/render-wasm/Cargo.toml +++ b/render-wasm/Cargo.toml @@ -10,6 +10,7 @@ build = "build.rs" [features] default = [] +stats = [] profile = ["profile-macros", "profile-raf"] profile-macros = [] profile-raf = [] @@ -36,4 +37,7 @@ thiserror = "2.0.18" uuid = { version = "1.11.0", features = ["v4", "js"] } [profile.release] -opt-level = "s" +opt-level = 3 +lto = "fat" +strip = true +codegen-units = 1 diff --git a/render-wasm/_build_env b/render-wasm/_build_env index 1082af1aac..b54af40d34 100644 --- a/render-wasm/_build_env +++ b/render-wasm/_build_env @@ -31,8 +31,8 @@ export EM_MALLOC="dlmalloc" export EMCC_CFLAGS="--no-entry \ --js-library src/js/wapi.js \ - -sASSERTIONS=1 \ - -sALLOW_TABLE_GROWTH=1 \ + -sMALLOC=$EM_MALLOC \ + -sALLOW_TABLE_GROWTH=0 \ -sALLOW_MEMORY_GROWTH=1 \ -sINITIAL_HEAP=$EM_INITIAL_HEAP \ -sMEMORY_GROWTH_GEOMETRIC_STEP=$EM_MEMORY_GROWTH_GEOMETRIC_STEP \ @@ -42,6 +42,8 @@ export EMCC_CFLAGS="--no-entry \ -sEXPORTED_RUNTIME_METHODS=GL,UTF8ToString,stringToUTF8,HEAPU8,HEAP32,HEAPU32,HEAPF32 \ -sENVIRONMENT=web \ -sMODULARIZE=1 \ + -sDISABLE_EXCEPTION_CATCHING=1 \ + -sFILESYSTEM=0 \ -sEXPORT_ES6=1"; export EM_CACHE="/tmp/emsdk_cache"; @@ -50,13 +52,13 @@ export CARGO_PARAMS="${@:2}"; if [ "$BUILD_MODE" = "release" ]; then export CARGO_PARAMS="--release $CARGO_PARAMS" - export EMCC_CFLAGS="-Os $EMCC_CFLAGS" + export EMCC_CFLAGS="-O3 -sASSERTIONS=0 $EMCC_CFLAGS" else # TODO: Extra parameters that could be good to look into: # -gseparate-dwarf # -gsplit-dwarf # -gsource-map - export EMCC_CFLAGS="-g $EMCC_CFLAGS -sVERBOSE=1 -sMALLOC=$EM_MALLOC" + export EMCC_CFLAGS="-g -sASSERTIONS=1 -sVERBOSE=1 $EMCC_CFLAGS" fi function clean { @@ -80,6 +82,9 @@ function copy_artifacts { cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.js $DEST/$BUILD_NAME.js; cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm $DEST/$BUILD_NAME.wasm; + if [ -f target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm.map ]; then + cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm.map $DEST/$BUILD_NAME.wasm.map; + fi sed -i "s/render_wasm.wasm/$BUILD_NAME.wasm?version=$VERSION_TAG/g" $DEST/$BUILD_NAME.js; diff --git a/render-wasm/src/globals.rs b/render-wasm/src/globals.rs new file mode 100644 index 0000000000..d2ed2196f6 --- /dev/null +++ b/render-wasm/src/globals.rs @@ -0,0 +1,138 @@ +use macros::wasm_error; + +use crate::mem; +use crate::render::{gpu_state::GpuState, RenderState}; +use crate::state::{State, TextEditorState}; + +static mut DESIGN_STATE: *mut State = std::ptr::null_mut(); + +/// Design State. +pub(crate) fn get_design_state() -> &'static mut State { + unsafe { + debug_assert!(!DESIGN_STATE.is_null(), "Design State is null"); + &mut *DESIGN_STATE + } +} + +/// GPU State. +static mut GPU_STATE: *mut GpuState = std::ptr::null_mut(); + +#[inline(always)] +pub(crate) fn get_gpu_state() -> &'static mut GpuState { + unsafe { + debug_assert!(!GPU_STATE.is_null(), "GPU State is null"); + &mut *GPU_STATE + } +} + +/// Render State. +static mut RENDER_STATE: *mut RenderState = std::ptr::null_mut(); + +#[inline(always)] +pub(crate) fn get_render_state() -> &'static mut RenderState { + unsafe { + debug_assert!(!RENDER_STATE.is_null(), "Render State is null"); + &mut *RENDER_STATE + } +} + +/// Text Editor State +static mut TEXT_EDITOR_STATE: *mut TextEditorState = std::ptr::null_mut(); + +#[inline(always)] +pub(crate) fn get_text_editor_state() -> &'static mut TextEditorState { + unsafe { + debug_assert!(!TEXT_EDITOR_STATE.is_null(), "Text Editor state is null"); + &mut *TEXT_EDITOR_STATE + } +} + +// FIXME: These with_state* macros should be using our CriticalError instead of expect. +// But to do that, we need to not use them at domain-level (i.e. in business logic), just +// in the context of the wasm call. +#[macro_export] +macro_rules! with_state { + ($state:ident, $block:block) => {{ + use $crate::globals::get_design_state; + let $state = get_design_state(); + $block + }}; +} + +#[macro_export] +macro_rules! with_current_shape_mut { + ($state:ident, |$shape:ident: &mut Shape| $block:block) => { + use $crate::globals::get_design_state; + let $state = get_design_state(); + $state.touch_current(); + if let Some($shape) = $state.current_shape_mut() { + $block + } + }; +} + +#[macro_export] +macro_rules! with_current_shape { + ($state:ident, |$shape:ident: &Shape| $block:block) => { + use $crate::globals::get_design_state; + let $state = get_design_state(); + if let Some($shape) = $state.current_shape() { + $block + } + }; +} + +/// Initializes GPUState. +fn gpu_init() { + unsafe { + let gpu_state = GpuState::try_new().expect("Cannot initialize GPU State"); + GPU_STATE = Box::into_raw(Box::new(gpu_state)); + } +} + +/// Initializes RenderState. +fn render_init(width: i32, height: i32) { + unsafe { + let render_state = + RenderState::try_new(width, height).expect("Cannot intialize RenderState"); + RENDER_STATE = Box::into_raw(Box::new(render_state)); + } +} + +/// Initializes DesignState. +fn design_init() { + unsafe { + let design_state = State::new(); + DESIGN_STATE = Box::into_raw(Box::new(design_state)); + } +} + +fn text_editor_init() { + unsafe { + let text_editor_state = TextEditorState::new(); + TEXT_EDITOR_STATE = Box::into_raw(Box::new(text_editor_state)); + } +} + +#[no_mangle] +#[wasm_error] +pub extern "C" fn init(width: i32, height: i32) -> Result<()> { + gpu_init(); + render_init(width, height); + text_editor_init(); + design_init(); + Ok(()) +} + +#[no_mangle] +#[wasm_error] +pub extern "C" fn clean_up() -> Result<()> { + // Cancel the current animation frame if it exists so + // it won't try to render without context + let render_state = get_render_state(); + render_state.cancel_animation_frame(); + render_state.prepare_context_loss_cleanup(); + unsafe { DESIGN_STATE = std::ptr::null_mut() } + mem::free_bytes()?; + Ok(()) +} diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 9f298c3900..c1ebc94839 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -1,6 +1,7 @@ #[cfg(target_arch = "wasm32")] mod emscripten; mod error; +mod globals; mod math; mod mem; mod performance; @@ -18,130 +19,32 @@ use std::collections::HashMap; #[allow(unused_imports)] use crate::error::{Error, Result}; + +use globals::{get_design_state, get_gpu_state, get_render_state}; + use macros::wasm_error; use math::{Bounds, Matrix}; use mem::SerializableResult; use shapes::{StructureEntry, StructureEntryType, TransformEntry}; use skia_safe as skia; -use state::State; use utils::uuid_from_u32_quartet; use uuid::Uuid; -pub(crate) static mut STATE: Option> = None; - -// FIXME: These with_state* macros should be using our CriticalError instead of expect. -// But to do that, we need to not use them at domain-level (i.e. in business logic), just -// in the context of the wasm call. -#[macro_export] -macro_rules! with_state_mut { - ($state:ident, $block:block) => {{ - let $state = unsafe { - #[allow(static_mut_refs)] - STATE.as_mut() - } - .expect("Got an invalid state pointer"); - $block - }}; -} - -#[macro_export] -macro_rules! with_state { - ($state:ident, $block:block) => {{ - let $state = unsafe { - #[allow(static_mut_refs)] - STATE.as_ref() - } - .expect("Got an invalid state pointer"); - $block - }}; -} - -#[macro_export] -macro_rules! with_current_shape_mut { - ($state:ident, |$shape:ident: &mut Shape| $block:block) => { - let $state = unsafe { - #[allow(static_mut_refs)] - STATE.as_mut() - } - .expect("Got an invalid state pointer"); - - $state.touch_current(); - - if let Some($shape) = $state.current_shape_mut() { - $block - } - }; -} - -#[macro_export] -macro_rules! with_current_shape { - ($state:ident, |$shape:ident: &Shape| $block:block) => { - let $state = unsafe { - #[allow(static_mut_refs)] - STATE.as_ref() - } - .expect("Got an invalid state pointer"); - if let Some($shape) = $state.current_shape() { - $block - } - }; -} - -#[macro_export] -macro_rules! with_state_mut_current_shape { - ($state:ident, |$shape:ident: &Shape| $block:block) => { - let $state = unsafe { - #[allow(static_mut_refs)] - STATE.as_mut() - } - .expect("Got an invalid state pointer"); - if let Some($shape) = $state.current_shape() { - $block - } - }; -} - -#[no_mangle] -#[wasm_error] -pub extern "C" fn init(width: i32, height: i32) -> Result<()> { - let state_box = Box::new(State::try_new(width, height)?); - unsafe { - STATE = Some(state_box); - } - Ok(()) -} - #[no_mangle] #[wasm_error] pub extern "C" fn set_browser(browser: u8) -> Result<()> { - with_state_mut!(state, { + with_state!(state, { state.set_browser(browser); }); Ok(()) } -#[no_mangle] -#[wasm_error] -pub extern "C" fn clean_up() -> Result<()> { - with_state_mut!(state, { - // Cancel the current animation frame if it exists so - // it won't try to render without context - let render_state = state.render_state_mut(); - render_state.cancel_animation_frame(); - }); - unsafe { STATE = None } - mem::free_bytes()?; - Ok(()) -} - #[no_mangle] #[wasm_error] pub extern "C" fn set_render_options(debug: u32, dpr: f32) -> Result<()> { - with_state_mut!(state, { - let render_state = state.render_state_mut(); - render_state.set_debug_flags(debug); - render_state.set_dpr(dpr)?; - }); + let render_state = get_render_state(); + render_state.set_debug_flags(debug); + render_state.set_dpr(dpr)?; Ok(()) } @@ -150,68 +53,55 @@ pub extern "C" fn set_render_options(debug: u32, dpr: f32) -> Result<()> { pub extern "C" fn set_viewport_interest_area_threshold( viewport_interest_area_threshold: i32, ) -> Result<()> { - with_state_mut!(state, { - let render_state = state.render_state_mut(); - render_state.set_viewport_interest_area_threshold(viewport_interest_area_threshold); - }); + let render_state = get_render_state(); + render_state.set_viewport_interest_area_threshold(viewport_interest_area_threshold); Ok(()) } #[no_mangle] #[wasm_error] pub extern "C" fn set_max_blocking_time_ms(max_blocking_time_ms: i32) -> Result<()> { - with_state_mut!(state, { - let render_state = state.render_state_mut(); - render_state.set_max_blocking_time_ms(max_blocking_time_ms); - }); + let render_state = get_render_state(); + render_state.set_max_blocking_time_ms(max_blocking_time_ms); Ok(()) } #[no_mangle] #[wasm_error] pub extern "C" fn set_node_batch_threshold(node_batch_threshold: i32) -> Result<()> { - with_state_mut!(state, { - let render_state = state.render_state_mut(); - render_state.set_node_batch_threshold(node_batch_threshold); - }); + let render_state = get_render_state(); + render_state.set_node_batch_threshold(node_batch_threshold); Ok(()) } #[no_mangle] #[wasm_error] pub extern "C" fn set_blur_downscale_threshold(blur_downscale_threshold: f32) -> Result<()> { - with_state_mut!(state, { - let render_state = state.render_state_mut(); - render_state.set_blur_downscale_threshold(blur_downscale_threshold); - }); + let render_state = get_render_state(); + render_state.set_blur_downscale_threshold(blur_downscale_threshold); Ok(()) } #[no_mangle] #[wasm_error] pub extern "C" fn set_antialias_threshold(threshold: f32) -> Result<()> { - with_state_mut!(state, { - state.render_state_mut().set_antialias_threshold(threshold); - }); + get_render_state().set_antialias_threshold(threshold); Ok(()) } #[no_mangle] #[wasm_error] pub extern "C" fn set_max_atlas_texture_size(max_px: i32) -> Result<()> { - with_state_mut!(state, { - state - .render_state_mut() - .surfaces - .set_max_atlas_texture_size(max_px); - }); + get_render_state() + .surfaces + .set_max_atlas_texture_size(max_px); Ok(()) } #[no_mangle] #[wasm_error] pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> { - with_state_mut!(state, { + with_state!(state, { let color = skia::Color::new(raw_color); state.set_background_color(color); state.rebuild_tiles_shallow(); @@ -222,11 +112,24 @@ pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> { #[no_mangle] #[wasm_error] -pub extern "C" fn render(_: i32) -> Result<()> { - with_state_mut!(state, { +pub extern "C" fn render(timestamp: i32) -> Result<()> { + with_state!(state, { state.rebuild_touched_tiles(); + // Drain the throttled modifier-tile invalidation accumulated + // since the previous rAF. set_modifiers skips this work during + // interactive_transform; we do it once here, with the current + // modifier set, so the cost is paid once per rAF rather than + // once per pointer move. + if get_render_state().options.is_interactive_transform() { + // Collect into an owned Vec to release the immutable borrow on + // `state.shapes` before the mutable `rebuild_modifier_tiles` call. + let ids = state.shapes.modifier_ids().to_vec(); + if !ids.is_empty() { + state.rebuild_modifier_tiles(&ids)?; + } + } state - .start_render_loop(performance::get_time()) + .start_render_loop(timestamp) .map_err(|_| Error::RecoverableError("Error rendering".to_string()))?; }); Ok(()) @@ -235,10 +138,10 @@ pub extern "C" fn render(_: i32) -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn render_sync() -> Result<()> { - with_state_mut!(state, { + with_state!(state, { state.rebuild_tiles(); state - .render_sync(performance::get_time()) + .render_sync(0) .map_err(|_| Error::RecoverableError("Error rendering".to_string()))?; }); Ok(()) @@ -247,7 +150,7 @@ pub extern "C" fn render_sync() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn render_sync_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> { - with_state_mut!(state, { + with_state!(state, { let id = uuid_from_u32_quartet(a, b, c, d); state.use_shape(id); @@ -264,7 +167,7 @@ pub extern "C" fn render_sync_shape(a: u32, b: u32, c: u32, d: u32) -> Result<() state.rebuild_tiles_from(Some(&id)); state - .render_sync_shape(&id, performance::get_time()) + .render_sync_shape(&id, 0) .map_err(|e| Error::RecoverableError(e.to_string()))?; }); Ok(()) @@ -273,7 +176,7 @@ pub extern "C" fn render_sync_shape(a: u32, b: u32, c: u32, d: u32) -> Result<() #[no_mangle] #[wasm_error] pub extern "C" fn render_from_cache(_: i32) -> Result<()> { - with_state_mut!(state, { + with_state!(state, { // Don't cancel the animation frame — let the async render // continue populating the tile HashMap in the background. // process_animation_frame skips flush_and_submit in fast @@ -289,16 +192,14 @@ pub extern "C" fn render_from_cache(_: i32) -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn set_preview_mode(enabled: bool) -> Result<()> { - with_state_mut!(state, { - state.render_state.set_preview_mode(enabled); - }); + get_render_state().set_preview_mode(enabled); Ok(()) } #[no_mangle] #[wasm_error] pub extern "C" fn render_preview() -> Result<()> { - with_state_mut!(state, { + with_state!(state, { state.render_preview(performance::get_time()); }); Ok(()) @@ -308,7 +209,7 @@ pub extern "C" fn render_preview() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn begin_loading() -> Result<()> { - with_state_mut!(state, { + with_state!(state, { state.loading = true; }); Ok(()) @@ -319,7 +220,7 @@ pub extern "C" fn begin_loading() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn end_loading() -> Result<()> { - with_state_mut!(state, { + with_state!(state, { state.loading = false; }); Ok(()) @@ -334,51 +235,41 @@ pub extern "C" fn end_loading() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn render_loading_overlay() -> Result<()> { - with_state_mut!(state, { - state.render_state.render_loading_overlay(); - }); + get_render_state().render_loading_overlay(); Ok(()) } #[no_mangle] #[wasm_error] pub extern "C" fn process_animation_frame(timestamp: i32) -> Result<()> { - let result = with_state_mut!(state, { state.process_animation_frame(timestamp) }); - + let result = with_state!(state, { state.process_animation_frame(timestamp) }); if let Err(err) = result { eprintln!("process_animation_frame error: {}", err); } - Ok(()) } #[no_mangle] #[wasm_error] pub extern "C" fn reset_canvas() -> Result<()> { - with_state_mut!(state, { - state.render_state_mut().reset_canvas(); - }); + get_render_state().reset_canvas(); Ok(()) } #[no_mangle] #[wasm_error] pub extern "C" fn resize_viewbox(width: i32, height: i32) -> Result<()> { - with_state_mut!(state, { - state.resize(width, height)?; - }); + get_render_state().resize(width, height)?; Ok(()) } #[no_mangle] #[wasm_error] pub extern "C" fn set_view(zoom: f32, x: f32, y: f32) -> Result<()> { - with_state_mut!(state, { - performance::begin_measure!("set_view"); - let render_state = state.render_state_mut(); - render_state.set_view(zoom, x, y); - performance::end_measure!("set_view"); - }); + performance::begin_measure!("set_view"); + let render_state = get_render_state(); + render_state.set_view(zoom, x, y); + performance::end_measure!("set_view"); Ok(()) } @@ -388,15 +279,13 @@ static mut VIEW_INTERACTION_START: i32 = 0; #[no_mangle] #[wasm_error] pub extern "C" fn set_view_start() -> Result<()> { - with_state_mut!(state, { - #[cfg(feature = "profile-macros")] - unsafe { - VIEW_INTERACTION_START = performance::get_time(); - } - performance::begin_measure!("set_view_start"); - state.render_state.options.set_fast_mode(true); - performance::end_measure!("set_view_start"); - }); + #[cfg(feature = "profile-macros")] + unsafe { + VIEW_INTERACTION_START = performance::get_time(); + } + performance::begin_measure!("set_view_start"); + get_render_state().options.set_fast_mode(true); + performance::end_measure!("set_view_start"); Ok(()) } @@ -408,36 +297,35 @@ pub extern "C" fn set_view_start() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn set_view_end() -> Result<()> { - with_state_mut!(state, { + with_state!(state, { performance::begin_measure!("set_view_end"); - state.render_state.options.set_fast_mode(false); - state.render_state.cancel_animation_frame(); + let render_state = get_render_state(); + render_state.options.set_fast_mode(false); + render_state.cancel_animation_frame(); - let scale = state.render_state.get_scale(); - state - .render_state + let scale = render_state.get_scale(); + render_state .tile_viewbox - .update(state.render_state.viewbox, scale); + .update(render_state.viewbox, scale); - if state.render_state.options.is_profile_rebuild_tiles() { + if render_state.options.is_profile_rebuild_tiles() { state.rebuild_tiles(); - } else if state.render_state.zoom_changed() { + } else if render_state.zoom_changed() { // Zoom changed: tile sizes differ so all cached tile // textures are invalid (wrong scale). Rebuild the tile // index and clear the tile texture cache, but *preserve* // the cache canvas so render_from_cache can show a scaled // preview of the old content while new tiles render. - state.render_state.rebuild_tile_index(&state.shapes); - state.render_state.surfaces.invalidate_tile_cache(); + render_state.rebuild_tile_index(&state.shapes); + render_state.surfaces.invalidate_tile_cache(); } else { // Pure pan at the same zoom level: tile contents have not // changed — only the viewport position moved. Update the // tile index (which tiles are in the interest area) but // keep cached tile textures so the render can blit them // instead of re-drawing every visible tile from scratch. - state.render_state.rebuild_tile_index(&state.shapes); + render_state.rebuild_tile_index(&state.shapes); } - performance::end_measure!("set_view_end"); }); Ok(()) @@ -451,13 +339,11 @@ pub extern "C" fn set_view_end() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn set_modifiers_start() -> Result<()> { - with_state_mut!(state, { - performance::begin_measure!("set_modifiers_start"); - let opts = &mut state.render_state.options; - opts.set_fast_mode(true); - opts.set_interactive_transform(true); - performance::end_measure!("set_modifiers_start"); - }); + performance::begin_measure!("set_modifiers_start"); + let render_state = get_render_state(); + render_state.options.set_fast_mode(true); + render_state.options.set_interactive_transform(true); + performance::end_measure!("set_modifiers_start"); Ok(()) } @@ -468,21 +354,19 @@ pub extern "C" fn set_modifiers_start() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn set_modifiers_end() -> Result<()> { - with_state_mut!(state, { - performance::begin_measure!("set_modifiers_end"); - let opts = &mut state.render_state.options; - opts.set_fast_mode(false); - opts.set_interactive_transform(false); - state.render_state.cancel_animation_frame(); - performance::end_measure!("set_modifiers_end"); - }); + performance::begin_measure!("set_modifiers_end"); + let render_state = get_render_state(); + render_state.options.set_fast_mode(false); + render_state.options.set_interactive_transform(false); + render_state.cancel_animation_frame(); + performance::end_measure!("set_modifiers_end"); Ok(()) } #[no_mangle] #[wasm_error] pub extern "C" fn clear_focus_mode() -> Result<()> { - with_state_mut!(state, { + with_state!(state, { state.clear_focus_mode(); }); Ok(()) @@ -498,7 +382,7 @@ pub extern "C" fn set_focus_mode() -> Result<()> { .map(|data| Uuid::try_from(data).map_err(|e| Error::RecoverableError(e.to_string()))) .collect::>>()?; - with_state_mut!(state, { + with_state!(state, { state.set_focus_mode(entries); }); Ok(()) @@ -507,7 +391,7 @@ pub extern "C" fn set_focus_mode() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn init_shapes_pool(capacity: usize) -> Result<()> { - with_state_mut!(state, { + with_state!(state, { state.init_shapes_pool(capacity); }); Ok(()) @@ -516,7 +400,7 @@ pub extern "C" fn init_shapes_pool(capacity: usize) -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn use_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> { - with_state_mut!(state, { + with_state!(state, { let id = uuid_from_u32_quartet(a, b, c, d); state.use_shape(id); }); @@ -526,7 +410,7 @@ pub extern "C" fn use_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn touch_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> { - with_state_mut!(state, { + with_state!(state, { let shape_id = uuid_from_u32_quartet(a, b, c, d); state.touch_shape(shape_id); }); @@ -536,7 +420,7 @@ pub extern "C" fn touch_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn set_parent(a: u32, b: u32, c: u32, d: u32) -> Result<()> { - with_state_mut!(state, { + with_state!(state, { let id = uuid_from_u32_quartet(a, b, c, d); state.set_parent_for_current_shape(id); }); @@ -622,7 +506,7 @@ fn set_children_set(entries: Vec) -> Result<()> { } }); - with_state_mut!(state, { + with_state!(state, { let Some(parent_id) = parent_id else { return Err(Error::RecoverableError( "set_children_set: Parent ID not found".to_string(), @@ -791,11 +675,9 @@ pub extern "C" fn is_image_cached( d: u32, is_thumbnail: bool, ) -> Result { - with_state_mut!(state, { - let id = uuid_from_u32_quartet(a, b, c, d); - let result = state.render_state().has_image(&id, is_thumbnail); - Ok(result) - }) + let id = uuid_from_u32_quartet(a, b, c, d); + let result = get_render_state().has_image(&id, is_thumbnail); + Ok(result) } #[no_mangle] @@ -857,7 +739,7 @@ pub extern "C" fn get_selection_rect() -> Result<*mut u8> { }) .collect(); - let result_bound = with_state_mut!(state, { + let result_bound = with_state!(state, { let bbs: Vec<_> = entries .iter() .flat_map(|id| state.shapes.get(id).map(|b| b.bounds())) @@ -904,7 +786,7 @@ pub extern "C" fn set_structure_modifiers() -> Result<()> { }) .collect::>>()?; - with_state_mut!(state, { + with_state!(state, { let mut structure = HashMap::new(); let mut scale_content = HashMap::new(); for entry in entries { @@ -943,12 +825,15 @@ pub extern "C" fn set_structure_modifiers() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn clean_modifiers() -> Result<()> { - with_state_mut!(state, { + with_state!(state, { + let render_state = get_render_state(); let prev_modifier_ids = state.shapes.clean_all(); - if !prev_modifier_ids.is_empty() { - state - .render_state - .update_tiles_shapes(&prev_modifier_ids, &mut state.shapes)?; + // Skip the tile-cache cleanup during interactive transform: the + // per-rAF `rebuild_modifier_tiles` in `render()` already evicts + // the same tiles for the active modifier set, so the eviction + // here is redundant and doubles the per-emission cost. + if !prev_modifier_ids.is_empty() && !render_state.options.is_interactive_transform() { + render_state.update_tiles_shapes(&prev_modifier_ids, &mut state.shapes)?; } }); Ok(()) @@ -971,9 +856,11 @@ pub extern "C" fn set_modifiers() -> Result<()> { ids.push(entry.id); } - with_state_mut!(state, { + with_state!(state, { state.set_modifiers(modifiers); - state.rebuild_modifier_tiles(ids)?; + if !get_render_state().options.is_interactive_transform() { + state.rebuild_modifier_tiles(&ids)?; + } }); Ok(()) } @@ -981,31 +868,36 @@ pub extern "C" fn set_modifiers() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn start_temp_objects() -> Result<()> { - unsafe { - #[allow(static_mut_refs)] - let mut state = STATE.take().ok_or(Error::CriticalError( - "Got an invalid state pointer".to_string(), - ))?; - state = Box::new(state.start_temp_objects()?); - STATE = Some(state); - } + get_design_state().start_temp_objects()?; Ok(()) } #[no_mangle] #[wasm_error] pub extern "C" fn end_temp_objects() -> Result<()> { - unsafe { - #[allow(static_mut_refs)] - let mut state = STATE.take().ok_or(Error::CriticalError( - "Got an invalid state pointer".to_string(), - ))?; - state = Box::new(state.end_temp_objects()?); - STATE = Some(state); - } + get_design_state().end_temp_objects()?; Ok(()) } +#[no_mangle] +#[wasm_error] +pub extern "C" fn get_shape_extrect(a: u32, b: u32, c: u32, d: u32) -> Result<*mut u8> { + let id = uuid_from_u32_quartet(a, b, c, d); + + with_state!(state, { + let Some(shape) = state.shapes.get(&id) else { + return Err(Error::CriticalError("Shape not found".to_string())); + }; + let extrect = get_render_state().get_cached_extrect(shape, &state.shapes, 1.0); + let mut buf = Vec::with_capacity(16); + buf.extend_from_slice(&extrect.x().to_le_bytes()); + buf.extend_from_slice(&extrect.y().to_le_bytes()); + buf.extend_from_slice(&extrect.width().to_le_bytes()); + buf.extend_from_slice(&extrect.height().to_le_bytes()); + Ok(mem::write_bytes(buf)) + }) +} + #[no_mangle] #[wasm_error] pub extern "C" fn render_shape_pixels( @@ -1021,7 +913,7 @@ pub extern "C" fn render_shape_pixels( return Err(Error::CriticalError("Scale is not finite".to_string())); } - with_state_mut!(state, { + with_state!(state, { let (data, width, height) = state.render_shape_pixels(&id, scale, performance::get_time())?; @@ -1035,6 +927,16 @@ pub extern "C" fn render_shape_pixels( }) } +#[no_mangle] +pub extern "C" fn render_stats() { + get_render_state().print_stats(); +} + +#[no_mangle] +pub fn free_gpu_resources() { + get_render_state().free_gpu_resources(); +} + fn main() { #[cfg(target_arch = "wasm32")] init_gl!(); diff --git a/render-wasm/src/mem.rs b/render-wasm/src/mem.rs index d03cb4fdc1..1c8531e7e9 100644 --- a/render-wasm/src/mem.rs +++ b/render-wasm/src/mem.rs @@ -1,61 +1,73 @@ -use std::sync::Mutex; - -use crate::error::{Error, Result, CRITICAL_ERROR}; +use crate::{error::Result, performance}; pub const LAYOUT_ALIGN: usize = 4; -pub static BUFFERU8: Mutex>> = Mutex::new(None); -pub static BUFFER_ERROR: Mutex = Mutex::new(0x00); +// Please, read about the #[allow(static_mut_refs)] +// +// If we don't put this allow, the compiler shows a warning like this: +// +// shared references to mutable statics are dangerous; it's undefined behavior +// if the static is mutated or if a mutable reference is created for it while +// the shared reference lives +// +// https://doc.rust-lang.org/edition-guide/rust-2024/static-mut-references.html +// +// But this isn't a problem in a single-threaded environment like WebAssembly +// because access/modification is always sequential, not parallel. +pub static mut BUFFERU8: Option> = None; +pub static mut BUFFER_ERROR: u8 = 0x00; pub fn clear_error_code() { - let mut guard = BUFFER_ERROR.lock().unwrap(); - *guard = 0x00; + unsafe { + BUFFER_ERROR = 0x00; + } } /// Sets the error buffer from a byte. Used by #[wasm_error] when E: Into. pub fn set_error_code(code: u8) { - let mut guard = BUFFER_ERROR.lock().unwrap(); - *guard = code; + unsafe { + BUFFER_ERROR = code; + } } #[no_mangle] pub extern "C" fn read_error_code() -> u8 { - if let Ok(guard) = BUFFER_ERROR.lock() { - *guard - } else { - CRITICAL_ERROR - } + unsafe { BUFFER_ERROR } } pub fn write_bytes(mut bytes: Vec) -> *mut u8 { - let mut guard = BUFFERU8.lock().unwrap(); + unsafe { + performance::begin_measure!("write_bytes"); + #[allow(static_mut_refs)] + if BUFFERU8.is_some() { + panic!("Bytes already allocated"); + } - if guard.is_some() { - panic!("Bytes already allocated"); + let ptr = bytes.as_mut_ptr(); + BUFFERU8 = Some(bytes); + performance::end_measure!("write_bytes"); + ptr } - - let ptr = bytes.as_mut_ptr(); - - *guard = Some(bytes); - ptr } pub fn bytes() -> Vec { - let mut guard = BUFFERU8.lock().unwrap(); - guard.take().expect("Buffer is not initialized") + unsafe { + #[allow(static_mut_refs)] + BUFFERU8.take().expect("Buffer is not initialized") + } } pub fn bytes_or_empty() -> Vec { - let mut guard = BUFFERU8.lock().unwrap(); - guard.take().unwrap_or_default() + unsafe { + #[allow(static_mut_refs)] + BUFFERU8.take().unwrap_or_default() + } } pub fn free_bytes() -> Result<()> { - let mut guard = BUFFERU8 - .lock() - .map_err(|_| Error::CriticalError("Failed to lock buffer".to_string()))?; - *guard = None; - std::mem::drop(guard); + unsafe { + BUFFERU8 = None; + } Ok(()) } diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 6272d8d9a3..72cc2078f2 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -2,7 +2,7 @@ mod debug; mod fills; pub mod filters; mod fonts; -mod gpu_state; +pub mod gpu_state; pub mod grid_layout; mod images; mod options; @@ -15,24 +15,23 @@ mod ui; use skia_safe::{self as skia, Matrix, RRect, Rect}; use std::borrow::Cow; -use std::collections::HashSet; - -use gpu_state::GpuState; +use std::collections::{HashMap, HashSet}; use options::RenderOptions; pub use surfaces::{SurfaceId, Surfaces}; use crate::error::{Error, Result}; -use crate::performance; +use crate::math; use crate::shapes::{ all_with_ancestors, radius_to_sigma, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor, - Stroke, StrokeKind, Type, + Stroke, StrokeKind, TextContent, Type, }; use crate::state::{ShapesPoolMutRef, ShapesPoolRef}; use crate::tiles::{self, PendingTiles, TileRect}; use crate::uuid::Uuid; use crate::view::Viewbox; use crate::wapi; +use crate::{get_gpu_state, performance}; pub use fonts::*; pub use images::*; @@ -289,9 +288,46 @@ fn sort_z_index(tree: ShapesPoolRef, element: &Shape, children_ids: Vec) - } } +struct RenderStats { + pub counts: HashMap, +} + +#[allow(dead_code)] +impl RenderStats { + pub fn new() -> Self { + Self { + counts: HashMap::new(), + } + } + + fn count(&mut self, id: Uuid) -> i32 { + let counter = self.counts.entry(id).or_insert(0); + *counter += 1; + *counter + } + + fn clear(&mut self) { + self.counts.clear(); + } + + #[allow(dead_code)] + fn get(&self, id: &Uuid) -> Option<&i32> { + self.counts.get(id) + } + + fn print(&self) { + let mut sum: i32 = 0; + for (&id, &count) in self.counts.iter() { + println!("{}: {}", id, count); + sum += count; + } + println!("{}: {}", self.counts.len(), sum); + } +} + pub(crate) struct RenderState { - gpu_state: GpuState, pub options: RenderOptions, + stats: RenderStats, pub surfaces: Surfaces, pub fonts: FontStore, pub viewbox: Viewbox, @@ -334,6 +370,75 @@ pub(crate) struct RenderState { /// Cleared at the beginning of a render pass; set to true after we clear Cache the first /// time we are about to blit a tile into Cache for this pass. pub cache_cleared_this_render: bool, + /// True iff the current tile had shapes assigned to it when we + /// started rendering it. Lets us distinguish a genuinely empty + /// tile (skip composite, just clear) from a tile whose walker + /// finished its work in a previous PAF and is now being resumed + /// (must composite to present the work). Reset when current_tile + /// changes. + pub current_tile_had_shapes: bool, + /// During interactive transforms we keep `Target` between rAFs. Seed the + /// interactive backdrop exactly once per gesture (first rAF) so we don't + /// repeatedly overwrite tiles that have already been updated. + pub interactive_target_seeded: bool, + /// GPU crops from `Backbuffer` or tile atlas keyed by shape id. Filled on full-frame completion; during + /// drag, entries for the moved top-level selection are ensured here + pub backbuffer_crop_cache: HashMap, +} + +pub struct InteractiveDragCrop { + pub src_doc_bounds: Rect, + pub src_selrect: Rect, + /// Viewbox origin (doc-space) at capture time. + pub capture_vb_left: f32, + pub capture_vb_top: f32, + /// Backbuffer pixel origin used for `snapshot_rect` (so we can do 1:1 blits). + pub capture_src_left: i32, + pub capture_src_top: i32, + pub image: skia::Image, +} + +/// Chooses a window inside the full workspace-pixel crop `[0, out_w) × [0, out_h)` with each side +/// at most `max_side_px` (**without scaling**): centered on the projection of +/// `viewport_doc ∩ src_doc_bounds`, or on the full crop if that intersection is empty. +/// `max_side_px` should match [`Surfaces::max_texture_dimension_px`] (same budget as the atlas). +#[allow(clippy::too_many_arguments)] +fn drag_crop_snapshot_window_px( + max_side_px: i32, + out_w: i32, + out_h: i32, + viewport_doc: Rect, + vb_left: f32, + vb_top: f32, + scale: f32, + src_left_px: i32, + src_top_px: i32, + src_doc_bounds: Rect, +) -> (i32, i32, i32, i32) { + let cap = max_side_px.max(1); + if out_w <= cap && out_h <= cap { + return (0, 0, out_w, out_h); + } + let win_w = out_w.min(cap); + let win_h = out_h.min(cap); + + let mut vis = viewport_doc; + let has_vis = vis.intersect(src_doc_bounds); + let (cx, cy) = if !has_vis || vis.is_empty() { + (out_w as f32 * 0.5, out_h as f32 * 0.5) + } else { + let lx0 = (vis.left - vb_left) * scale - src_left_px as f32; + let ly0 = (vis.top - vb_top) * scale - src_top_px as f32; + let lx1 = (vis.right - vb_left) * scale - src_left_px as f32; + let ly1 = (vis.bottom - vb_top) * scale - src_top_px as f32; + ((lx0 + lx1) * 0.5, (ly0 + ly1) * 0.5) + }; + + let mut ox = (cx - win_w as f32 * 0.5).round() as i32; + let mut oy = (cy - win_h as f32 * 0.5).round() as i32; + ox = ox.clamp(0, out_w - win_w); + oy = oy.clamp(0, out_h - win_h); + (ox, oy, win_w, win_h) } pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISize { @@ -353,15 +458,81 @@ pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISiz } impl RenderState { + /// Decide whether a top-level node can be served from `backbuffer_crop_cache` during an + /// interactive transform (drag/resize/rotate). + /// + /// We only reuse cached pixels when it is safe and visually correct: + /// - **Top-level only**: cache entries are built for direct children of the root. + /// - **Moved node**: only allow cache reuse for *pure translations* (no scale/rotate/skew), + /// because other transforms would require resampling and can diverge from the live render. + /// - **Other cached nodes**: if the moving bounds overlap this cached crop, invalidate it so + /// we don't show stale content while something moves over/inside it. + fn should_use_cached_top_level_during_interactive( + &mut self, + node_id: Uuid, + tree: ShapesPoolRef, + moved_ids: &[Uuid], + moved_bounds: Option, + ) -> bool { + if !self.backbuffer_crop_cache.contains_key(&node_id) { + return false; + } + let Some(raw) = tree.get_raw(&node_id) else { + return false; + }; + if raw.parent_id != Some(Uuid::nil()) { + return false; + } + + // If this top-level shape itself is being moved, always allow using its cached pixels. + // BUT only for pure translations. For non-translation transforms (scale/rotate/skew), + // cached pixels won't match the live result (and may require resampling), so render live. + if moved_ids.contains(&node_id) { + let Some(m) = tree.get_modifier(&node_id) else { + return false; + }; + // Only allow using the cached pixels for pure translations. + // For non-translation transforms (scale/rotate/skew), cached pixels won't match. + // If the transform is the identity means a reflow, we need to redraw as well. + if math::identitish(m) || !math::is_move_only_matrix(m) { + return false; + } + + if !self.backbuffer_crop_cache.contains_key(&node_id) { + return false; + } + + // Additionally require this node to be safe to serve from a rectangular backbuffer + // crop while moving; otherwise it must be rendered live (e.g. text, overflow frames). + return tree + .get(&node_id) + .is_some_and(|s| s.is_safe_for_drag_crop_cache(tree)); + } + + // If the moving content overlaps this cached crop, do not use the cached pixels + // for this frame. We intentionally keep the cache entry: overlap is typically + // transient during drag, and once the moving content leaves the area the crop + // becomes valid again (stationary shape unchanged). + if let Some(moved) = moved_bounds { + let intersects = self + .backbuffer_crop_cache + .get(&node_id) + .is_some_and(|crop| moved.intersects(crop.src_doc_bounds)); + + if intersects { + return false; + } + } + true + } + pub fn try_new(width: i32, height: i32) -> Result { // This needs to be done once per WebGL context. - let mut gpu_state = GpuState::try_new()?; let sampling_options = skia::SamplingOptions::new(skia::FilterMode::Linear, skia::MipmapMode::Nearest); let fonts = FontStore::try_new()?; let surfaces = Surfaces::try_new( - &mut gpu_state, (width, height), sampling_options, tiles::get_tile_dimensions(), @@ -374,14 +545,14 @@ impl RenderState { let tiles = tiles::TileHashMap::new(); let options = RenderOptions::default(); - Ok(RenderState { - gpu_state: gpu_state.clone(), + Ok(Self { options, + stats: RenderStats::new(), surfaces, fonts, viewbox, cached_viewbox: Viewbox::new(0., 0.), - images: ImageStore::new(gpu_state.context.clone()), + images: ImageStore::new(), background_color: skia::Color::TRANSPARENT, render_request_id: None, render_in_progress: false, @@ -393,10 +564,10 @@ impl RenderState { tiles, tile_viewbox: tiles::TileViewbox::new_with_interest( viewbox, - options.viewport_interest_area_threshold, + options.dpr_viewport_interest_area_threshold, 1.0, ), - pending_tiles: PendingTiles::new_empty(), + pending_tiles: PendingTiles::new(), nested_fills: vec![], nested_blurs: vec![], nested_shadows: vec![], @@ -407,6 +578,9 @@ impl RenderState { preview_mode: false, export_context: None, cache_cleared_this_render: false, + current_tile_had_shapes: false, + interactive_target_seeded: false, + backbuffer_crop_cache: HashMap::default(), }) } @@ -607,8 +781,11 @@ impl RenderState { } pub fn set_dpr(&mut self, dpr: f32) -> Result<()> { - if Some(dpr) != self.options.dpr { - self.options.dpr = Some(dpr); + // Only when this function returns true (it means the value + // was properly changed) the rest of the functions is called. + if self.options.set_dpr(dpr) { + self.tile_viewbox + .set_interest(self.options.dpr_viewport_interest_area_threshold); self.resize( self.viewbox.width.floor() as i32, self.viewbox.height.floor() as i32, @@ -623,7 +800,15 @@ impl RenderState { } pub fn set_viewport_interest_area_threshold(&mut self, value: i32) { - self.options.set_viewport_interest_area_threshold(value); + // Only when this function returns true (it means the value + // was changed properly) the tile_viewbox.set_interest is called. + if self.options.set_viewport_interest_area_threshold(value) { + // The TileViewbox stores its own copy of `interest` (set at + // construction). Without propagating, options change wouldn't + // affect pending_tiles generation. + self.tile_viewbox + .set_interest(self.options.dpr_viewport_interest_area_threshold); + } } pub fn set_node_batch_threshold(&mut self, value: i32) { @@ -647,19 +832,34 @@ impl RenderState { } pub fn resize(&mut self, width: i32, height: i32) -> Result<()> { - let dpr_width = (width as f32 * self.options.dpr()).floor() as i32; - let dpr_height = (height as f32 * self.options.dpr()).floor() as i32; - self.surfaces - .resize(&mut self.gpu_state, dpr_width, dpr_height)?; + let dpr_width = (width as f32 * self.options.dpr).floor() as i32; + let dpr_height = (height as f32 * self.options.dpr).floor() as i32; + self.surfaces.resize(dpr_width, dpr_height)?; self.viewbox.set_wh(width as f32, height as f32); self.tile_viewbox.update(self.viewbox, self.get_scale()); Ok(()) } + pub fn flush(&mut self) { + self.surfaces.flush(SurfaceId::Backbuffer); + } + pub fn flush_and_submit(&mut self) { - self.surfaces - .flush_and_submit(&mut self.gpu_state, SurfaceId::Target); + self.surfaces.flush_and_submit(SurfaceId::Target); + } + + /// Copy the clean (no UI overlay) Backbuffer to Target, draw UI/debug overlays + /// on top of Target, then present. Backbuffer is left clean so it can be reused + /// as-is across interactive-transform frames without stale overlay pixels. + pub fn present_frame(&mut self, tree: ShapesPoolRef) { + self.surfaces.copy_backbuffer_to_target(); + if self.options.is_debug_visible() { + debug::render(self); + } + ui::render(self, tree); + debug::render_wasm_label(self); + self.surfaces.flush_and_submit(SurfaceId::Target); } pub fn reset_canvas(&mut self) { @@ -670,7 +870,7 @@ impl RenderState { /// This is currently not being used, but it's set there for testing purposes on /// upcoming tasks pub fn render_loading_overlay(&mut self) { - let canvas = self.surfaces.canvas(SurfaceId::Target); + let canvas = self.surfaces.canvas(SurfaceId::Backbuffer); let skia::ISize { width, height } = canvas.base_layer_size(); canvas.save(); @@ -712,6 +912,19 @@ impl RenderState { } pub fn apply_render_to_final_canvas(&mut self, rect: skia::Rect) -> Result<()> { + // During interactive transforms we render tiles directly into Target; updating the cache + // (snapshot -> atlas blit -> tiles.add) can force GPU stalls. Defer cache rebuild until + // the interaction ends. + if self.options.is_interactive_transform() { + let tile_rect = self.get_current_aligned_tile_bounds()?; + self.surfaces.draw_current_tile_direct( + &tile_rect, + self.background_color, + surfaces::DrawOnCache::No, + ); + return Ok(()); + } + let fast_mode = self.options.is_fast_mode(); // Decide *now* (at the first real cache blit) whether we need to clear Cache. // This avoids clearing Cache on renders that don't actually paint tiles (e.g. hover/UI), @@ -720,26 +933,25 @@ impl RenderState { self.surfaces.clear_cache(self.background_color); self.cache_cleared_this_render = true; } - let tile_rect = self.get_current_aligned_tile_bounds()?; // In fast mode the viewport is moving (pan/zoom) so Cache surface // positions would be wrong — only save to the tile HashMap. + let tile_rect = self.get_current_aligned_tile_bounds()?; + + let current_tile = *self + .current_tile + .as_ref() + .ok_or(Error::CriticalError("Current tile not found".to_string()))?; + self.surfaces.cache_current_tile_texture( - &mut self.gpu_state, &self.tile_viewbox, - &self - .current_tile - .ok_or(Error::CriticalError("Current tile not found".to_string()))?, + ¤t_tile, &tile_rect, fast_mode, self.render_area, ); - self.surfaces.draw_cached_tile_surface( - self.current_tile - .ok_or(Error::CriticalError("Current tile not found".to_string()))?, - rect, - self.background_color, - ); + self.surfaces + .draw_cached_tile_surface(current_tile, rect, self.background_color); Ok(()) } @@ -851,6 +1063,9 @@ impl RenderState { outset: Option, target_surface: SurfaceId, ) -> Result<()> { + #[cfg(feature = "stats")] + self.stats.count(shape.id); + let surface_ids = fills_surface_id as u32 | strokes_surface_id as u32 | innershadows_surface_id as u32 @@ -866,10 +1081,14 @@ impl RenderState { s.canvas().save(); }); } - - let antialias = - shape.should_use_antialias(self.get_scale(), self.options.antialias_threshold); let fast_mode = self.options.is_fast_mode(); + // Skip anti-aliasing entirely during fast_mode (interactive + // gestures + pan/zoom). AA edge sampling is per-pixel and adds + // up across many shapes; reverts to full quality on commit. + let antialias = !fast_mode + && shape.should_use_antialias(self.get_scale(), self.options.antialias_threshold); + let skip_effects = fast_mode; + let has_nested_fills = self .nested_fills .last() @@ -912,7 +1131,6 @@ impl RenderState { }); fills::render(self, shape, &shape.fills, antialias, target_surface, None)?; - // Pass strokes in natural order; stroke merging handles top-most ordering internally. let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect(); strokes::render( @@ -997,7 +1215,7 @@ impl RenderState { // Remove background blur from the shape so it doesn't get processed // as a layer blur. The actual rendering is done before the save_layer // in render_background_blur() so it's independent of shape opacity. - if !fast_mode + if !skip_effects && apply_to_current_surface && fills_surface_id == SurfaceId::Fills && !matches!(shape.shape_type, Type::Text(_)) @@ -1023,14 +1241,14 @@ impl RenderState { } else if shape_has_blur { shape.to_mut().set_blur(None); } - if fast_mode { + if skip_effects { shape.to_mut().set_blur(None); } // For non-text, non-SVG shapes in the normal rendering path, apply blur // via a single save_layer on each render surface // Clip correctness is preserved - let blur_sigma_for_layers: Option = if !fast_mode + let blur_sigma_for_layers: Option = if !skip_effects && apply_to_current_surface && fills_surface_id == SurfaceId::Fills && !matches!(shape.shape_type, Type::Text(_)) @@ -1083,12 +1301,23 @@ impl RenderState { } } - Type::Text(text_content) => { + Type::Text(stored_text_content) => { self.surfaces.apply_mut(surface_ids, |s| { s.canvas().concat(&matrix); }); - let text_content = text_content.new_bounds(shape.selrect()); + // Skip the paragraph-cloning `new_bounds` when shape size is unchanged. + let selrect = shape.selrect(); + let stored_bounds = stored_text_content.bounds(); + let bounds_match = (stored_bounds.width() - selrect.width()).abs() < 0.01 + && (stored_bounds.height() - selrect.height()).abs() < 0.01; + let rebound_text_content = if bounds_match { + None + } else { + Some(stored_text_content.new_bounds(selrect)) + }; + let text_content: &TextContent = + rebound_text_content.as_ref().unwrap_or(stored_text_content); let count_inner_strokes = shape.count_visible_inner_strokes(); // Erode the main text fill by 1px when there are inner strokes, to avoid a visible seam at the glyph edge. let text_fill_inset = (count_inner_strokes > 0).then(|| 1.0 / self.get_scale()); @@ -1102,14 +1331,14 @@ impl RenderState { .rev() .map(|stroke| { text::stroke_paragraph_builder_group_from_text( - &text_content, + text_content, stroke, &shape.selrect(), None, ) }) .unzip(); - if fast_mode { + if skip_effects { // Fast path: render fills and strokes only (skip shadows/blur). text::render( Some(self), @@ -1178,7 +1407,7 @@ impl RenderState { .rev() .map(|stroke| { text::stroke_paragraph_builder_group_from_text( - &text_content, + text_content, stroke, &shape.selrect(), Some(true), @@ -1211,7 +1440,7 @@ impl RenderState { &parent_shadows, &blur_filter, &stroke_kinds, - &text_content, + text_content, )?; } } else { @@ -1255,7 +1484,7 @@ impl RenderState { &drop_shadows, &blur_filter, &stroke_kinds, - &text_content, + text_content, )?; // 4. Stroke fills @@ -1307,7 +1536,7 @@ impl RenderState { &inner_shadows, &blur_filter, &stroke_kinds, - &text_content, + text_content, )?; // 6. Fill Inner shadows @@ -1398,7 +1627,7 @@ impl RenderState { antialias, outset, )?; - if !fast_mode { + if !skip_effects { for stroke in &visible_strokes { shadows::render_stroke_inner_shadows( self, @@ -1411,7 +1640,7 @@ impl RenderState { } } - if !fast_mode { + if !skip_effects { shadows::render_fill_inner_shadows( self, shape, @@ -1470,6 +1699,205 @@ impl RenderState { } } + fn rebuild_backbuffer_crop_cache(&mut self, tree: ShapesPoolRef) { + self.backbuffer_crop_cache.clear(); + + // Collect candidate shapes that are "recortable" and visible in the current viewport. + + // This is intentionally conservative; we only cache shapes that do not overlap with + // ANY other candidate to guarantee the pixels under their bounds belong exclusively + // to that shape in Backbuffer. + let viewport = self.viewbox.area; + let scale = self.get_scale(); + let mut candidates: Vec<(Uuid, Rect, Rect)> = Vec::new(); // (id, doc_bounds, selrect) + + let root_ids: Vec = match tree.get(&Uuid::nil()) { + Some(root) => root.children_ids(false), + None => Vec::new(), + }; + + for shape_id in root_ids { + let Some(shape) = tree.get(&shape_id) else { + continue; + }; + if shape.hidden { + continue; + } + + let doc_bounds = self.get_cached_extrect(shape, tree, 1.0); + if !doc_bounds.intersects(viewport) { + continue; + } + + // Also require selrect to be visible; used for drag delta placement. + let selrect = shape.selrect(); + if !selrect.intersects(viewport) { + continue; + } + + candidates.push((shape.id, doc_bounds, selrect)); + } + + // Filter out any candidate that overlaps with any other candidate. + // Sort by left edge so the inner loop can break early once no further + // x-overlap is possible, reducing comparisons from O(N²) to O(N log N) + // in typical layouts where shapes are spread out. + candidates.sort_unstable_by(|a, b| { + a.1.left + .partial_cmp(&b.1.left) + .unwrap_or(std::cmp::Ordering::Equal) + }); + let n = candidates.len(); + let mut is_overlapping = vec![false; n]; + for i in 0..n { + for j in (i + 1)..n { + if candidates[j].1.left >= candidates[i].1.right { + break; // sorted: no further x-overlap possible for i + } + if is_overlapping[i] && is_overlapping[j] { + continue; // both already excluded, skip check + } + if candidates[i].1.intersects(candidates[j].1) { + is_overlapping[i] = true; + is_overlapping[j] = true; + } + } + } + let non_overlapping: Vec<(Uuid, Rect, Rect)> = candidates + .iter() + .zip(is_overlapping.iter()) + .filter_map(|((id, bounds, selrect), ov)| { + if !ov { + Some((*id, *bounds, *selrect)) + } else { + None + } + }) + .collect(); + + let vb_left = self.viewbox.area.left; + let vb_top = self.viewbox.area.top; + let (bb_w, bb_h) = self.surfaces.surface_size(SurfaceId::Backbuffer); + let max_snap_px = self.surfaces.max_texture_dimension_px(); + + // Snapshot the atlas once for the whole pass so that all shapes sharing + // the tile/atlas fallback path reuse the same GPU image rather than each + // triggering a separate `image_snapshot` flush. + let atlas_snap = self.surfaces.atlas_snapshot_for_drag_crop(); + + // Scratch surface reused across all shapes that need the tile/atlas + // fallback — avoids one WebGL texture allocation per shape. + // Created lazily on first use and grown if a later shape needs more space. + let mut scratch_surface: Option = None; + + for (id, doc_bounds, selrect) in non_overlapping { + let left = ((doc_bounds.left - vb_left) * scale).floor() as i32; + let top = ((doc_bounds.top - vb_top) * scale).floor() as i32; + let right = ((doc_bounds.right - vb_left) * scale).ceil() as i32; + let bottom = ((doc_bounds.bottom - vb_top) * scale).ceil() as i32; + if right <= left || bottom <= top { + continue; + } + let src_irect = skia::IRect::new(left, top, right, bottom); + + let src_doc_bounds = Rect::new( + src_irect.left as f32 / scale + vb_left, + src_irect.top as f32 / scale + vb_top, + src_irect.right as f32 / scale + vb_left, + src_irect.bottom as f32 / scale + vb_top, + ); + + let full_w = src_irect.width(); + let full_h = src_irect.height(); + let (win_ox, win_oy, win_w, win_h) = drag_crop_snapshot_window_px( + max_snap_px, + full_w, + full_h, + viewport, + vb_left, + vb_top, + scale, + src_irect.left, + src_irect.top, + src_doc_bounds, + ); + let window_irect = skia::IRect::new( + src_irect.left + win_ox, + src_irect.top + win_oy, + src_irect.left + win_ox + win_w, + src_irect.top + win_oy + win_h, + ); + + let src_doc_window = Rect::new( + window_irect.left as f32 / scale + vb_left, + window_irect.top as f32 / scale + vb_top, + window_irect.right as f32 / scale + vb_left, + window_irect.bottom as f32 / scale + vb_top, + ); + + let in_backbuffer = window_irect.left >= 0 + && window_irect.top >= 0 + && window_irect.right <= bb_w + && window_irect.bottom <= bb_h; + + let backbuffer_snap = if in_backbuffer { + self.surfaces + .snapshot_rect(SurfaceId::Backbuffer, window_irect) + } else { + None + }; + + let image = if let Some(img) = backbuffer_snap { + img + } else { + // Ensure the scratch surface is large enough for this window. + // Grow (reallocate) only when necessary so that the common case + // of similarly-sized shapes pays zero extra allocation cost. + let needs_alloc = scratch_surface + .as_ref() + .is_none_or(|s| s.width() < win_w || s.height() < win_h); + if needs_alloc { + scratch_surface = get_gpu_state() + .create_surface_with_isize( + "drag_crop_scratch".to_string(), + skia::ISize::new(win_w, win_h), + ) + .ok(); + } + let Some(scratch) = scratch_surface.as_mut() else { + continue; + }; + let Some(img) = self.surfaces.try_snapshot_doc_rect_from_tiles_and_atlas( + scratch, + atlas_snap.as_ref(), + src_doc_window, + window_irect, + win_w, + win_h, + vb_left, + vb_top, + scale, + ) else { + continue; + }; + img + }; + + self.backbuffer_crop_cache.insert( + id, + InteractiveDragCrop { + src_doc_bounds: src_doc_window, + src_selrect: selrect, + capture_vb_left: vb_left, + capture_vb_top: vb_top, + capture_src_left: window_irect.left, + capture_src_top: window_irect.top, + image, + }, + ); + } + } + pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) { let _start = performance::begin_timed_log!("render_from_cache"); performance::begin_measure!("render_from_cache"); @@ -1482,16 +1910,9 @@ impl RenderState { // and drawing from it avoids mixing a partially-updated Cache surface with missing tiles. if self.options.is_fast_mode() && self.render_in_progress && self.surfaces.has_atlas() { self.surfaces - .draw_atlas_to_target(self.viewbox, self.options.dpr(), bg_color); + .draw_atlas_to_backbuffer(self.viewbox, self.options.dpr, bg_color); - if self.options.is_debug_visible() { - debug::render(self); - } - - ui::render(self, shapes); - debug::render_wasm_label(self); - - self.flush_and_submit(); + self.present_frame(shapes); performance::end_measure!("render_from_cache"); performance::end_timed_log!("render_from_cache", _start); return; @@ -1502,15 +1923,15 @@ impl RenderState { // Scale and translate the target according to the cached data let navigate_zoom = self.viewbox.zoom / self.cached_viewbox.zoom; - let interest = self.options.viewport_interest_area_threshold; + let interest = self.options.dpr_viewport_interest_area_threshold; let TileRect(start_tile_x, start_tile_y, _, _) = tiles::get_tiles_for_viewbox_with_interest( self.cached_viewbox, interest, cached_scale, ); - let offset_x = self.viewbox.area.left * self.cached_viewbox.zoom * self.options.dpr(); - let offset_y = self.viewbox.area.top * self.cached_viewbox.zoom * self.options.dpr(); + let offset_x = self.viewbox.area.left * self.cached_viewbox.zoom * self.options.dpr; + let offset_y = self.viewbox.area.top * self.cached_viewbox.zoom * self.options.dpr; let translate_x = (start_tile_x as f32 * tiles::TILE_SIZE) - offset_x; let translate_y = (start_tile_y as f32 * tiles::TILE_SIZE) - offset_y; @@ -1523,8 +1944,8 @@ impl RenderState { let cache_h = cache_dim.height as f32; // Viewport in target pixels. - let vw = (self.viewbox.width * self.options.dpr()).max(1.0); - let vh = (self.viewbox.height * self.options.dpr()).max(1.0); + let vw = (self.viewbox.width * self.options.dpr).max(1.0); + let vh = (self.viewbox.height * self.options.dpr).max(1.0); // Inverse-map viewport corners into cache coordinates. // target = (cache * navigate_zoom) translated by (translate_x, translate_y) (in cache coords). @@ -1550,19 +1971,13 @@ impl RenderState { if !cache_covers { // Early return only if atlas exists; otherwise keep cache path. if self.surfaces.has_atlas() { - self.surfaces.draw_atlas_to_target( + self.surfaces.draw_atlas_to_backbuffer( self.viewbox, - self.options.dpr(), + self.options.dpr, bg_color, ); - if self.options.is_debug_visible() { - debug::render(self); - } - - ui::render(self, shapes); - debug::render_wasm_label(self); - self.flush_and_submit(); + self.present_frame(shapes); performance::end_measure!("render_from_cache"); performance::end_timed_log!("render_from_cache", _start); return; @@ -1572,7 +1987,7 @@ impl RenderState { // Setup canvas transform { - let canvas = self.surfaces.canvas(SurfaceId::Target); + let canvas = self.surfaces.canvas(SurfaceId::Backbuffer); canvas.save(); canvas.scale((navigate_zoom, navigate_zoom)); canvas.translate((translate_x, translate_y)); @@ -1580,10 +1995,10 @@ impl RenderState { } // Draw directly from cache surface, avoiding snapshot overhead - self.surfaces.draw_cache_to_target(); + self.surfaces.draw_cache_to_backbuffer(); // Restore canvas state - self.surfaces.canvas(SurfaceId::Target).restore(); + self.surfaces.canvas(SurfaceId::Backbuffer).restore(); // During pure pan (same zoom), draw tiles from the HashMap // on top of the scaled Cache surface. Cached tile textures @@ -1615,14 +2030,7 @@ impl RenderState { } } - if self.options.is_debug_visible() { - debug::render(self); - } - - ui::render(self, shapes); - debug::render_wasm_label(self); - - self.flush_and_submit(); + self.present_frame(shapes); } performance::end_measure!("render_from_cache"); @@ -1662,6 +2070,9 @@ impl RenderState { timestamp: i32, sync_render: bool, ) -> Result<()> { + #[cfg(feature = "stats")] + self.stats.clear(); + let _start = performance::begin_timed_log!("start_render_loop"); let scale = self.get_scale(); @@ -1671,24 +2082,27 @@ impl RenderState { performance::begin_measure!("render"); performance::begin_measure!("start_render_loop"); - self.cache_cleared_this_render = false; - self.reset_canvas(); + // Compute and set document-space bounds (1 unit == 1 doc px @ 100% zoom) + // to clamp atlas updates. This prevents zoom-out tiles from forcing atlas + // growth far beyond real content. + let doc_bounds = self.compute_document_bounds(base_object, tree); + self.surfaces.set_atlas_doc_bounds(doc_bounds); - // During an interactive shape transform (drag/resize/rotate) the - // Target is repainted tile-by-tile. If only a subset of the - // invalidated tiles finishes in this rAF the remaining area - // would either show stale content from the previous frame or, - // on buffer swaps, show blank pixels — either way the user - // perceives tiles appearing sequentially. Paint the persistent - // 1:1 atlas as a stable backdrop so every flush presents a - // coherent picture: unchanged tiles come from the atlas and - // invalidated tiles are overwritten on top as they finish. - if self.options.is_interactive_transform() && self.surfaces.has_atlas() { - self.surfaces.draw_atlas_to_target( - self.viewbox, - self.options.dpr(), - self.background_color, - ); + self.cache_cleared_this_render = false; + if self.options.is_interactive_transform() { + // Keep `Target` as the previous frame and overwrite only the tiles + // that changed. This avoids clearing + redrawing an atlas backdrop + // every rAF during drag (a common source of GPU work/stalls). + self.surfaces + .reset_interactive_transform(self.background_color); + if !self.interactive_target_seeded { + // Seed from the last presented frame; this is stable even when + // fast_mode skips cache updates and regardless of atlas coverage. + self.interactive_target_seeded = true; + } + } else { + self.reset_canvas(); + self.interactive_target_seeded = false; } let surface_ids = SurfaceId::Strokes as u32 @@ -1702,12 +2116,12 @@ impl RenderState { let viewbox_cache_size = get_cache_size( self.viewbox, scale, - self.options.viewport_interest_area_threshold, + self.options.dpr_viewport_interest_area_threshold, ); let cached_viewbox_cache_size = get_cache_size( self.cached_viewbox, scale, - self.options.viewport_interest_area_threshold, + self.options.dpr_viewport_interest_area_threshold, ); // Only resize cache if the new size is larger than the cached size // This avoids unnecessary surface recreations when the cache size decreases @@ -1716,7 +2130,7 @@ impl RenderState { { self.surfaces.resize_cache( viewbox_cache_size, - self.options.viewport_interest_area_threshold, + self.options.dpr_viewport_interest_area_threshold, )?; } @@ -1725,8 +2139,9 @@ impl RenderState { let _tile_start = performance::begin_timed_log!("tile_cache_update"); performance::begin_measure!("tile_cache"); + let only_visible = self.options.is_interactive_transform(); self.pending_tiles - .update(&self.tile_viewbox, &self.surfaces); + .update(&self.tile_viewbox, &self.surfaces, only_visible); performance::end_measure!("tile_cache"); performance::end_timed_log!("tile_cache_update", _tile_start); @@ -1742,6 +2157,7 @@ impl RenderState { self.nested_shadows.clear(); // reorder by distance to the center. self.current_tile = None; + self.render_in_progress = true; self.apply_drawing_to_render_canvas(None, SurfaceId::Current); @@ -1767,6 +2183,37 @@ impl RenderState { Ok(()) } + fn compute_document_bounds( + &mut self, + base_object: Option<&Uuid>, + tree: ShapesPoolRef, + ) -> Option { + let ids: Vec = if let Some(id) = base_object { + vec![*id] + } else { + let root = tree.get(&Uuid::nil())?; + root.children_ids(false) + }; + + let mut acc: Option = None; + for id in ids.iter() { + let Some(shape) = tree.get(id) else { + continue; + }; + let r = self.get_cached_extrect(shape, tree, 1.0); + if r.is_empty() { + continue; + } + acc = Some(if let Some(mut a) = acc { + a.join(r); + a + } else { + r + }); + } + acc + } + pub fn process_animation_frame( &mut self, base_object: Option<&Uuid>, @@ -1774,32 +2221,28 @@ impl RenderState { timestamp: i32, ) -> Result<()> { performance::begin_measure!("process_animation_frame"); + self.render_shape_tree_partial(base_object, tree, timestamp, true)?; + if self.render_in_progress { - if tree.len() != 0 { - self.render_shape_tree_partial(base_object, tree, timestamp, true)?; - } - - // In a pure viewport interaction (pan/zoom), render_from_cache - // owns the Target surface — skip flush so we don't present - // stale tile positions. The rAF still populates the Cache - // surface and tile HashMap so render_from_cache progressively - // shows more complete content. - // - // During interactive shape transforms (drag/resize/rotate) we - // still need to flush every rAF so the user sees the updated - // shape position — render_from_cache is not in the loop here. - if !self.options.is_viewport_interaction() { - self.flush_and_submit(); - } - - if self.render_in_progress { - self.cancel_animation_frame(); - self.render_request_id = Some(wapi::request_animation_frame!()); - } else { - wapi::notify_tiles_render_complete!(); - performance::end_measure!("render"); + // Partial frame: just flush GPU work. The display shows the last + // fully submitted frame; no need to copy or draw UI overlays here. + self.flush(); + self.cancel_animation_frame(); + self.render_request_id = Some(wapi::request_animation_frame!()); + } else { + // A full-quality frame is now complete. Rebuild the per-shape crop + // cache from the clean Backbuffer (no UI overlay yet) so that + // interactive drag backgrounds don't include the grid overlay. + if !self.options.is_fast_mode() && !self.options.is_interactive_transform() { + self.rebuild_backbuffer_crop_cache(tree); } + // present_frame: copy clean Backbuffer → Target, draw UI/debug + // overlays on Target only, then flush. Backbuffer stays overlay-free. + self.present_frame(tree); + wapi::notify_tiles_render_complete!(); + performance::end_measure!("render"); } + performance::end_measure!("process_animation_frame"); Ok(()) } @@ -1810,10 +2253,8 @@ impl RenderState { tree: ShapesPoolRef, timestamp: i32, ) -> Result<()> { - if tree.len() != 0 { - self.render_shape_tree_partial(base_object, tree, timestamp, false)?; - } - self.flush_and_submit(); + self.render_shape_tree_partial(base_object, tree, timestamp, false)?; + self.present_frame(tree); Ok(()) } @@ -1884,13 +2325,12 @@ impl RenderState { // Clear export context so get_scale() returns to workspace zoom. self.export_context = None; - self.surfaces - .flush_and_submit(&mut self.gpu_state, target_surface); + self.surfaces.flush_and_submit(target_surface); let image = self.surfaces.snapshot(target_surface); let data = image .encode( - &mut self.gpu_state.context, + Some(&mut get_gpu_state().context), skia::EncodedImageFormat::PNG, 100, ) @@ -1991,7 +2431,7 @@ impl RenderState { paint.set_blend_mode(element.blend_mode().into()); paint.set_alpha_f(element.opacity()); - // Skip frame-level blur in fast mode (pan/zoom) + // Skip frame-level blur in fast mode (pan/zoom). if !self.options.is_fast_mode() { if let Some(frame_blur) = Self::frame_clip_layer_blur(element) { let scale = self.get_scale(); @@ -2510,7 +2950,8 @@ impl RenderState { } if let Some(clips) = clip_bounds.as_ref() { - let antialias = element.should_use_antialias(scale, self.options.antialias_threshold); + let antialias = !self.options.is_fast_mode() + && element.should_use_antialias(scale, self.options.antialias_threshold); self.surfaces.canvas(target_surface).save(); for (bounds, corners, transform) in clips.iter() { if target_surface == SurfaceId::Export { @@ -2595,6 +3036,32 @@ impl RenderState { target_surface = SurfaceId::Export; } + // During interactive transforms we compute the union of the current bounds of all + // modified shapes (doc-space @ 100% zoom, scale=1.0). This is used as a cheap overlap + // guard to decide when cached top-level crops are unsafe to reuse (something is moving + // over/inside them), without doing expensive ancestor walks per node. + // + // `modifier_ids` is pre-computed once here and reused throughout the loop to avoid + // repeated allocations (formerly O(N_shapes) HashMap builds) per node. + let modifier_ids = tree.modifier_ids(); + let moved_bounds = if self.options.is_interactive_transform() && !modifier_ids.is_empty() { + let mut acc: Option = None; + for id in modifier_ids.iter() { + let Some(s) = tree.get(id) else { continue }; + let r = self.get_cached_extrect(s, tree, 1.0); + acc = Some(match acc { + None => r, + Some(mut prev) => { + prev.join(r); + prev + } + }); + } + acc + } else { + None + }; + while let Some(node_render_state) = self.pending_nodes.pop() { let node_id = node_render_state.id; let visited_children = node_render_state.visited_children; @@ -2665,6 +3132,71 @@ impl RenderState { } } + // Interactive drag cache: if this node is cacheable during interactive transform, + // draw it directly from Backbuffer crop on the current tile surface and skip + // traversing/rendering the subtree. + if self.options.is_interactive_transform() { + let use_cached = self.should_use_cached_top_level_during_interactive( + node_id, + tree, + modifier_ids, + moved_bounds, + ); + + if use_cached { + if let Some(crop) = self.backbuffer_crop_cache.get(&node_id) { + let crop_image = &crop.image; + let crop_src_selrect = crop.src_selrect; + + let cur_selrect = tree.get(&node_id).map(|s| s.selrect()); + let (dx, dy) = match cur_selrect { + Some(cur) => ( + cur.left - crop_src_selrect.left, + cur.top - crop_src_selrect.top, + ), + None => (0.0, 0.0), + }; + let scale = self.get_scale(); + let translation = self + .surfaces + .get_render_context_translation(self.render_area, scale); + + let canvas = self.surfaces.canvas(target_surface); + canvas.save(); + canvas.reset_matrix(); + // If the crop includes shadows/blur (extrect pixels outside the fill/stroke + // silhouette), do NOT apply the silhouette clip or we'd cut those pixels. + let should_clip_crop = element.shadows.is_empty() && element.blur.is_none(); + if should_clip_crop { + if let Some(clip_path) = element.drag_crop_clip_path() { + let mut doc_to_tile = Matrix::new_identity(); + // Map document-space coordinates into tile pixels. + // Rendering surfaces apply: scale(scale) then translate(translation) in doc units. + // Equivalent point mapping: (doc + translation) * scale. + doc_to_tile.post_translate((translation.0, translation.1)); + doc_to_tile.post_scale((scale, scale), None); + let clip_path = clip_path.make_transform(&doc_to_tile); + canvas.clip_path(&clip_path, skia::ClipOp::Intersect, true); + } + } + let doc_left = + crop.capture_vb_left + (crop.capture_src_left as f32 / scale) + dx; + let doc_top = + crop.capture_vb_top + (crop.capture_src_top as f32 / scale) + dy; + + let x = (doc_left + translation.0) * scale; + let y = (doc_top + translation.1) * scale; + let bw = crop_image.width() as f32; + let bh = crop_image.height() as f32; + let dst = skia::Rect::from_xywh(x, y, bw, bh); + canvas.draw_image_rect(crop_image, None, dst, &skia::Paint::default()); + + canvas.restore(); + } + continue; + } + } + let can_flatten = element.can_flatten() && !self.focus_mode.should_focus(&element.id); // Skip render_shape_enter/exit for flattened containers @@ -2714,7 +3246,7 @@ impl RenderState { .surfaces .get_render_context_translation(self.render_area, scale); - // Skip expensive drop shadow rendering in fast mode (during pan/zoom) + // Skip expensive drop shadow rendering in fast mode (during pan/zoom). let skip_shadows = self.options.is_fast_mode(); // Skip shadow block when already rendered before the layer (frame_clip_layer_blur) @@ -2854,12 +3386,17 @@ impl RenderState { if let Some(current_tile) = self.current_tile { if self.surfaces.has_cached_tile_surface(current_tile) { performance::begin_measure!("render_shape_tree::cached"); + // During interactive transforms, `Target` is preserved and seeded once + // from Backbuffer. Cached tiles are therefore already visible and + // re-blitting them costs extra GPU work. let tile_rect = self.get_current_tile_bounds()?; - self.surfaces.draw_cached_tile_surface( - current_tile, - tile_rect, - self.background_color, - ); + if !self.options.is_interactive_transform() { + self.surfaces.draw_cached_tile_surface( + current_tile, + tile_rect, + self.background_color, + ); + } // Also draw the cached tile to the Cache surface so // render_from_cache (used during pan) has the full scene. @@ -2896,9 +3433,24 @@ impl RenderState { return Ok(()); } performance::end_measure!("render_shape_tree::uncached"); + let tile_rect = self.get_current_tile_bounds()?; - if !is_empty { - self.apply_render_to_final_canvas(tile_rect)?; + // Composite if the walker did work in this PAF (`!is_empty`) OR + // the tile has unfinished work from a previous PAF + // (`current_tile_had_shapes` was set when we populated pending_nodes + // for this tile). + if !is_empty || self.current_tile_had_shapes { + if self.options.is_interactive_transform() { + // During drag, avoid snapshot-based caching. Draw Current directly + // into Target (and Cache) to reduce stalls. + self.surfaces.draw_current_tile_direct( + &tile_rect, + self.background_color, + surfaces::DrawOnCache::Yes, + ); + } else { + self.apply_render_to_final_canvas(tile_rect)?; + } if self.options.is_debug_visible() { debug::render_workspace_current_tile( @@ -2909,29 +3461,8 @@ impl RenderState { ); } } else { - self.surfaces.apply_mut(SurfaceId::Target as u32, |s| { - let mut paint = skia::Paint::default(); - paint.set_color(self.background_color); - s.canvas().draw_rect(tile_rect, &paint); - }); - // Keep Cache surface coherent for render_from_cache. - if !self.options.is_fast_mode() { - if !self.cache_cleared_this_render { - self.surfaces.clear_cache(self.background_color); - self.cache_cleared_this_render = true; - } - let aligned_rect = self.get_aligned_tile_bounds(current_tile); - self.surfaces.apply_mut(SurfaceId::Cache as u32, |s| { - let mut paint = skia::Paint::default(); - paint.set_color(self.background_color); - s.canvas().draw_rect(aligned_rect, &paint); - }); - } - - // Clear atlas region to transparent so background shows through. - let _ = self - .surfaces - .clear_doc_rect_in_atlas(&mut self.gpu_state, self.render_area); + // Tile is uncached and has no shapes to render + self.apply_render_to_final_canvas(tile_rect)?; } } } @@ -2944,6 +3475,11 @@ impl RenderState { // let's check if there are more pending nodes if let Some(next_tile) = self.pending_tiles.pop() { self.update_render_context(next_tile); + // Reset for the new tile. We'll flip it to true if the + // tile has shapes, so a later "is_empty=true" reflects + // a resumed-from-yield case rather than a genuinely + // empty tile. + self.current_tile_had_shapes = false; if !self.surfaces.has_cached_tile_surface(next_tile) { if let Some(ids) = self.tiles.get_shapes_at(next_tile) { @@ -2959,14 +3495,28 @@ impl RenderState { }) }); - // We only need first level shapes, in the same order as the parent node + // We only need first level shapes, in the same order as the parent node. + // + // During interactive transforms we may invalidate only the modified shapes + // (to avoid massive ancestor eviction). However, we still composite full + // tiles (we clear the tile rect before drawing Current), so we must render + // all root shapes that can contribute to this tile; otherwise, unchanged + // siblings inside the same tile would disappear. let mut valid_ids = Vec::with_capacity(ids.len()); - for root_id in root_ids.iter() { - if tile_has_bg_blur || ids.contains(root_id) { - valid_ids.push(*root_id); + if self.options.is_interactive_transform() || tile_has_bg_blur { + valid_ids.extend(root_ids.iter().copied()); + } else { + for root_id in root_ids.iter() { + if ids.contains(root_id) { + valid_ids.push(*root_id); + } } } + if !valid_ids.is_empty() { + self.current_tile_had_shapes = true; + } + self.pending_nodes.extend(valid_ids.into_iter().map(|id| { NodeRenderState { id, @@ -2985,7 +3535,6 @@ impl RenderState { } self.render_in_progress = false; - self.surfaces.gc(); // Mark cache as valid for render_from_cache. @@ -3000,13 +3549,6 @@ impl RenderState { self.cached_viewbox = self.viewbox; } - if self.options.is_debug_visible() { - debug::render(self); - } - - ui::render(self, tree); - debug::render_wasm_label(self); - Ok(()) } @@ -3070,6 +3612,25 @@ impl RenderState { let mut result = HashSet::::with_capacity(old_tiles.len()); + // When the shape has an active modifier (i.e. is being moved/resized), + // clear its OLD doc-space extent from the atlas using the raw + // (pre-modifier) shape. The per-tile clearing done later via + // `clear_tile_in_atlas` only covers tiles tracked in `atlas_tile_doc_rects` + // at the current zoom level. However, the atlas may also contain stale + // pixels from previous zoom levels (tiles are larger / smaller in doc + // space at different zoom scales) that were never re-tracked after a zoom + // change. Clearing the full raw extrect here removes all such residual + // content without growing the atlas. + // + // We intentionally skip this when there is NO modifier so that plain + // zoom / pan tile-index rebuilds do NOT invalidate valid atlas content. + if tree.get_modifier(&shape.id).is_some() { + if let Some(raw_shape) = tree.get_raw(&shape.id) { + let old_extrect = raw_shape.extrect(tree, 1.0); + self.surfaces.clear_doc_rect_in_atlas_clipped(old_extrect); + } + } + // First, remove the shape from all tiles where it was previously located for tile in old_tiles { self.tiles.remove_shape_at(tile, shape.id); @@ -3299,10 +3860,19 @@ impl RenderState { pub fn rebuild_modifier_tiles( &mut self, tree: ShapesPoolMutRef<'_>, - ids: Vec, + ids: &[Uuid], ) -> Result<()> { - let ancestors = all_with_ancestors(&ids, tree, false); - self.update_tiles_shapes(&ancestors, tree)?; + // During interactive transform, skip ancestor invalidation: walking up to the + // parent frame evicts every tile the frame covers, including dense tiles with + // many siblings. Ancestor extrect caches are already invalidated by + // `ShapesPool::set_modifiers`; the tile index is reconciled post-gesture by + // the committing code path (rebuild_touched_tiles). + if self.options.is_interactive_transform() { + self.update_tiles_shapes(ids, tree)?; + } else { + let ancestors = all_with_ancestors(ids, tree, false); + self.update_tiles_shapes(&ancestors, tree)?; + } Ok(()) } @@ -3311,11 +3881,11 @@ impl RenderState { if let Some((_, export_scale)) = self.export_context { return export_scale; } - self.viewbox.zoom() * self.options.dpr() + self.viewbox.zoom() * self.options.dpr } pub fn get_cached_scale(&self) -> f32 { - self.cached_viewbox.zoom() * self.options.dpr() + self.cached_viewbox.zoom() * self.options.dpr } pub fn zoom_changed(&self) -> bool { @@ -3338,10 +3908,21 @@ impl RenderState { pub fn set_view(&mut self, zoom: f32, x: f32, y: f32) { self.viewbox.set_all(zoom, x, y); } -} -impl Drop for RenderState { - fn drop(&mut self) { - self.gpu_state.context.free_gpu_resources(); + pub fn print_stats(&self) { + self.stats.print(); + } + + pub fn prepare_context_loss_cleanup(&mut self) { + // Drop cached GPU-backed snapshots before dropping the render state. + self.backbuffer_crop_cache.clear(); + self.surfaces.invalidate_tile_cache(); + // Mark context as abandoned so resource destructors avoid issuing + // GL commands when the browser has already lost/restored the context. + get_gpu_state().context.abandon(); + } + + pub fn free_gpu_resources(&mut self) { + get_gpu_state().context.free_gpu_resources(); } } diff --git a/render-wasm/src/render/debug.rs b/render-wasm/src/render/debug.rs index f374e32af3..cddf371851 100644 --- a/render-wasm/src/render/debug.rs +++ b/render-wasm/src/render/debug.rs @@ -1,7 +1,11 @@ use super::{tiles, RenderState, SurfaceId}; -use crate::with_state_mut; -use crate::STATE; + +#[cfg(target_arch = "wasm32")] use macros::wasm_error; + +#[cfg(target_arch = "wasm32")] +use crate::get_render_state; + use skia_safe::{self as skia, Rect}; #[cfg(target_arch = "wasm32")] @@ -183,8 +187,52 @@ pub fn render_debug_shape( } } -#[cfg(target_arch = "wasm32")] #[allow(dead_code)] +#[cfg(target_arch = "wasm32")] +pub fn trap() { + run_script!("debugger"); +} + +#[allow(dead_code)] +#[cfg(target_arch = "wasm32")] +#[derive(Debug, PartialEq)] +pub enum SurfaceBackendKind { + BackendTexture, // GPU Framebuffer (Texture) + BackendRenderTarget, // GPU Framebuffer (Renderbuffer) + Raster, // CPU + Unknown, +} + +#[allow(dead_code)] +#[cfg(target_arch = "wasm32")] +pub fn classify_surface_backend(surface: &mut skia::Surface) -> SurfaceBackendKind { + if skia::gpu::surfaces::get_backend_texture( + surface, + skia_safe::surface::BackendHandleAccess::FlushRead, + ) + .is_some() + { + return SurfaceBackendKind::BackendTexture; + } + + if skia::gpu::surfaces::get_backend_render_target( + surface, + skia_safe::surface::BackendHandleAccess::FlushRead, + ) + .is_some() + { + return SurfaceBackendKind::BackendRenderTarget; + } + + if surface.peek_pixels().is_some() { + return SurfaceBackendKind::Raster; + } + + SurfaceBackendKind::Unknown +} + +#[allow(dead_code)] +#[cfg(target_arch = "wasm32")] pub fn console_debug_surface(render_state: &mut RenderState, id: SurfaceId) { let base64_image = render_state .surfaces @@ -194,6 +242,8 @@ pub fn console_debug_surface(render_state: &mut RenderState, id: SurfaceId) { run_script!(format!("console.log('%c ', 'font-size: 1px; background: url(data:image/png;base64,{base64_image}) no-repeat; padding: 100px; background-size: contain;')")); } +#[allow(dead_code)] +#[cfg(target_arch = "wasm32")] pub fn console_debug_surface_base64(render_state: &mut RenderState, id: SurfaceId) { let base64_image = render_state .surfaces @@ -227,9 +277,7 @@ pub fn console_debug_surface_rect(render_state: &mut RenderState, id: SurfaceId, #[wasm_error] #[cfg(target_arch = "wasm32")] pub extern "C" fn debug_cache_console() -> Result<()> { - with_state_mut!(state, { - console_debug_surface(state.render_state_mut(), SurfaceId::Cache); - }); + console_debug_surface(get_render_state(), SurfaceId::Cache); Ok(()) } @@ -237,9 +285,7 @@ pub extern "C" fn debug_cache_console() -> Result<()> { #[wasm_error] #[cfg(target_arch = "wasm32")] pub extern "C" fn debug_cache_base64() -> Result<()> { - with_state_mut!(state, { - console_debug_surface_base64(state.render_state_mut(), SurfaceId::Cache); - }); + console_debug_surface_base64(get_render_state(), SurfaceId::Cache); Ok(()) } @@ -247,9 +293,7 @@ pub extern "C" fn debug_cache_base64() -> Result<()> { #[wasm_error] #[cfg(target_arch = "wasm32")] pub extern "C" fn debug_atlas_console() -> Result<()> { - with_state_mut!(state, { - console_debug_surface(state.render_state_mut(), SurfaceId::Atlas); - }); + console_debug_surface(get_render_state(), SurfaceId::Atlas); Ok(()) } @@ -257,8 +301,14 @@ pub extern "C" fn debug_atlas_console() -> Result<()> { #[wasm_error] #[cfg(target_arch = "wasm32")] pub extern "C" fn debug_atlas_base64() -> Result<()> { - with_state_mut!(state, { - console_debug_surface_base64(state.render_state_mut(), SurfaceId::Atlas); - }); + console_debug_surface_base64(get_render_state(), SurfaceId::Atlas); + Ok(()) +} + +#[no_mangle] +#[wasm_error] +#[cfg(target_arch = "wasm32")] +pub extern "C" fn debug_surface_console(id: SurfaceId) -> Result<()> { + console_debug_surface(get_render_state(), id); Ok(()) } diff --git a/render-wasm/src/render/fills.rs b/render-wasm/src/render/fills.rs index 615d8b9532..45f8fc3048 100644 --- a/render-wasm/src/render/fills.rs +++ b/render-wasm/src/render/fills.rs @@ -62,7 +62,9 @@ fn draw_image_fill( if let Some(path) = shape_type.path() { if let Some(path_transform) = path_transform { canvas.clip_path( - &path.to_skia_path().make_transform(&path_transform), + &path + .to_skia_path(shape.svg_attrs.as_ref()) + .make_transform(&path_transform), skia::ClipOp::Intersect, antialias, ); diff --git a/render-wasm/src/render/gpu_state.rs b/render-wasm/src/render/gpu_state.rs index aa62b83817..07ce266eff 100644 --- a/render-wasm/src/render/gpu_state.rs +++ b/render-wasm/src/render/gpu_state.rs @@ -1,5 +1,8 @@ use crate::error::{Error, Result}; -use skia_safe::gpu::{self, gl::FramebufferInfo, gl::TextureInfo, DirectContext}; +use skia_safe::gpu::{ + self, ganesh::context_options::Enable, gl::FramebufferInfo, gl::TextureInfo, ContextOptions, + DirectContext, +}; use skia_safe::{self as skia, ISize}; #[derive(Debug, Clone)] @@ -13,7 +16,16 @@ impl GpuState { let interface = gpu::gl::Interface::new_native().ok_or(Error::CriticalError( "Failed to create GL interface".to_string(), ))?; - let context = gpu::direct_contexts::make_gl(interface, None).ok_or( + + // We tweak some options to enhance performance. + let mut context_options = ContextOptions::default(); + // context_options.reduce_ops_task_splitting = Enable::Yes; + context_options.skip_gl_error_checks = Enable::Yes; + // context_options.runtime_program_cache_size = 1024; + // context_options.allow_multiple_glyph_cache_textures = Enable::Yes; + // context_options.allow_path_mask_caching = false; + + let context = gpu::direct_contexts::make_gl(interface, Some(&context_options)).ok_or( Error::CriticalError("Failed to create GL context".to_string()), )?; let framebuffer_info = { @@ -29,13 +41,20 @@ impl GpuState { } }; - Ok(GpuState { + Ok(Self { context, framebuffer_info, }) } - fn create_webgl_texture(&mut self, width: i32, height: i32) -> gl::types::GLuint { + fn delete_gl_texture(&mut self, texture_id: gl::types::GLuint) -> bool { + unsafe { + gl::DeleteTextures(1, &texture_id); + gl::GetError() == 0 + } + } + + fn create_gl_texture(&mut self, width: i32, height: i32) -> gl::types::GLuint { let mut texture_id: gl::types::GLuint = 0; unsafe { @@ -63,6 +82,19 @@ impl GpuState { texture_id } + pub fn delete_surface(&mut self, surface: &mut skia::Surface) -> bool { + let Some(texture) = skia::gpu::surfaces::get_backend_texture( + surface, + skia_safe::surface::BackendHandleAccess::FlushRead, + ) else { + return false; + }; + let Some(texture_info) = gpu::backend_textures::get_gl_texture_info(&texture) else { + return false; + }; + self.delete_gl_texture(texture_info.id) + } + pub fn create_surface_with_isize( &mut self, label: String, @@ -78,7 +110,7 @@ impl GpuState { height: i32, ) -> Result { let backend_texture = unsafe { - let texture_id = self.create_webgl_texture(width, height); + let texture_id = self.create_gl_texture(width, height); let texture_info = TextureInfo { target: gl::TEXTURE_2D, id: texture_id, diff --git a/render-wasm/src/render/images.rs b/render-wasm/src/render/images.rs index e1c66b2a51..b7a25388dc 100644 --- a/render-wasm/src/render/images.rs +++ b/render-wasm/src/render/images.rs @@ -3,6 +3,7 @@ use crate::shapes::ImageFill; use crate::uuid::Uuid; use crate::error::Result; +use crate::get_gpu_state; use skia_safe::gpu::{surfaces, Budgeted, DirectContext}; use skia_safe::{self as skia, Codec, ISize}; use std::collections::HashMap; @@ -143,10 +144,12 @@ fn decode_image(context: &mut Box, raw_data: &[u8]) -> Option Self { + pub fn new() -> Self { + let gpu_state = get_gpu_state(); + let context = &gpu_state.context; Self { images: HashMap::with_capacity(2048), - context: Box::new(context), + context: Box::new(context.clone()), } } diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index 40a3125ccd..8142f1f52c 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -7,7 +7,7 @@ const SHOW_WASM_INFO: u32 = 0x08; // Render performance options // This is the extra area used for tile rendering (tiles beyond viewport). // Higher values pre-render more tiles, reducing empty squares during pan but using more memory. -const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3; +const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 1; const MAX_BLOCKING_TIME_MS: i32 = 32; const NODE_BATCH_THRESHOLD: i32 = 3; const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0; @@ -15,7 +15,7 @@ const ANTIALIAS_THRESHOLD: f32 = 7.0; #[derive(Debug, Copy, Clone, PartialEq)] pub struct RenderOptions { pub flags: u32, - pub dpr: Option, + pub dpr: f32, fast_mode: bool, /// Active while the user is interacting with a shape (drag, resize, /// rotate). Implies `fast_mode` semantics for expensive effects but @@ -25,6 +25,7 @@ pub struct RenderOptions { /// Minimum on-screen size (CSS px at 1:1 zoom) above which vector antialiasing is enabled. pub antialias_threshold: f32, pub viewport_interest_area_threshold: i32, + pub dpr_viewport_interest_area_threshold: i32, pub max_blocking_time_ms: i32, pub node_batch_threshold: i32, pub blur_downscale_threshold: f32, @@ -34,11 +35,12 @@ impl Default for RenderOptions { fn default() -> Self { Self { flags: 0, - dpr: None, + dpr: 1.0, fast_mode: false, interactive_transform: false, antialias_threshold: ANTIALIAS_THRESHOLD, viewport_interest_area_threshold: VIEWPORT_INTEREST_AREA_THRESHOLD, + dpr_viewport_interest_area_threshold: VIEWPORT_INTEREST_AREA_THRESHOLD, max_blocking_time_ms: MAX_BLOCKING_TIME_MS, node_batch_threshold: NODE_BATCH_THRESHOLD, blur_downscale_threshold: BLUR_DOWNSCALE_THRESHOLD, @@ -64,6 +66,24 @@ impl RenderOptions { self.fast_mode = enabled; } + /// Updates the dpr viewport interest area threshold. + /// This function is updated when the dpr or the + /// viewport_interest_area_threshold is changed + fn update_dpr_viewport_interest_area_threshold(&mut self) { + self.dpr_viewport_interest_area_threshold = + (self.dpr * self.viewport_interest_area_threshold as f32).ceil() as i32; + } + + /// Sets the devicePixelRatio. + pub fn set_dpr(&mut self, value: f32) -> bool { + if value > 0.0 && self.dpr != value { + self.dpr = value; + self.update_dpr_viewport_interest_area_threshold(); + return true; + } + false + } + /// Interactive transform is ON while the user is dragging, resizing /// or rotating a shape. Callers use it to keep per-frame flushing /// enabled and to render visible tiles in a single frame so tiles @@ -76,18 +96,6 @@ impl RenderOptions { self.interactive_transform = enabled; } - /// True only when the viewport is the one being moved (pan/zoom) - /// and the dedicated `render_from_cache` path owns Target - /// presentation. In this mode `process_animation_frame` must not - /// flush to avoid presenting stale tile positions. - pub fn is_viewport_interaction(&self) -> bool { - self.fast_mode && !self.interactive_transform - } - - pub fn dpr(&self) -> f32 { - self.dpr.unwrap_or(1.0) - } - pub fn is_text_editor_v3(&self) -> bool { self.flags & TEXT_EDITOR_V3 == TEXT_EDITOR_V3 } @@ -96,33 +104,44 @@ impl RenderOptions { self.flags & SHOW_WASM_INFO == SHOW_WASM_INFO } - pub fn set_antialias_threshold(&mut self, value: f32) { + pub fn set_antialias_threshold(&mut self, value: f32) -> bool { if value.is_finite() && value > 0.0 { self.antialias_threshold = value; + return true; } + false } - pub fn set_blur_downscale_threshold(&mut self, value: f32) { + pub fn set_blur_downscale_threshold(&mut self, value: f32) -> bool { if value.is_finite() && value > 0.0 { self.blur_downscale_threshold = value; + return true; } + false } - pub fn set_viewport_interest_area_threshold(&mut self, value: i32) { - if value >= 0 { + pub fn set_viewport_interest_area_threshold(&mut self, value: i32) -> bool { + if value >= 0 && self.viewport_interest_area_threshold != value { self.viewport_interest_area_threshold = value; + self.update_dpr_viewport_interest_area_threshold(); + return true; } + false } - pub fn set_node_batch_threshold(&mut self, value: i32) { + pub fn set_node_batch_threshold(&mut self, value: i32) -> bool { if value > 0 { self.node_batch_threshold = value; + return true; } + false } - pub fn set_max_blocking_time_ms(&mut self, value: i32) { + pub fn set_max_blocking_time_ms(&mut self, value: i32) -> bool { if value > 0 { self.max_blocking_time_ms = value; + return true; } + false } } diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs index c5a3a26bf1..6f31573288 100644 --- a/render-wasm/src/render/strokes.rs +++ b/render-wasm/src/render/strokes.rs @@ -1,7 +1,8 @@ use crate::math::{Matrix, Point, Rect}; use crate::shapes::{ - merge_fills, Corners, Fill, ImageFill, Path, Shape, Stroke, StrokeCap, StrokeKind, Type, + merge_fills, Corners, Fill, ImageFill, Path, Shape, Stroke, StrokeCap, StrokeKind, SvgAttrs, + Type, }; use skia_safe::{self as skia, ImageFilter, RRect}; @@ -210,6 +211,7 @@ fn draw_stroke_on_path( path_transform: Option<&Matrix>, shadow: Option<&ImageFilter>, blur: Option<&ImageFilter>, + svg_attrs: Option<&SvgAttrs>, antialias: bool, ) { let is_open = path.is_open(); @@ -229,8 +231,8 @@ fn draw_stroke_on_path( if let Some(pt) = path_transform { canvas.concat(pt); } - let skia_path = path.to_skia_path(); + let skia_path = path.to_skia_path(svg_attrs); match stroke.render_kind(is_open) { StrokeKind::Inner => { draw_inner_stroke_path(canvas, &skia_path, &draw_paint, blur, antialias); @@ -300,6 +302,12 @@ fn handle_stroke_caps( return; } + // When both ends share the same simple line cap, Skia already drew it + // natively via `PaintCap` on the stroke paint, so skip the manual overlay. + if stroke.to_skia_linecap().is_some() { + return; + } + // Curves can have duplicated points, so let's remove consecutive duplicated points let mut points = path.points().to_vec(); points.dedup(); @@ -510,7 +518,7 @@ fn draw_image_stroke_in_container( if let Some(p) = shape_type.path() { canvas.save(); - let path = p.to_skia_path().make_transform( + let path = p.to_skia_path(svg_attrs).make_transform( &path_transform.ok_or(Error::CriticalError("No path transform".to_string()))?, ); let stroke_kind = stroke.render_kind(p.is_open()); @@ -574,7 +582,7 @@ fn draw_image_stroke_in_container( // Clear outer stroke for paths if necessary. When adding an outer stroke we need to empty the stroke added too in the inner area. if let Type::Path(p) = &shape.shape_type { if stroke.render_kind(p.is_open()) == StrokeKind::Outer { - let path = p.to_skia_path().make_transform( + let path = p.to_skia_path(svg_attrs).make_transform( &path_transform.ok_or(Error::CriticalError("No path transform".to_string()))?, ); let mut clear_paint = skia::Paint::default(); @@ -846,6 +854,7 @@ fn render_merged( path_transform.as_ref(), None, blur_filter.as_ref(), + svg_attrs, antialias, ); } @@ -1016,6 +1025,7 @@ fn render_single_internal( path_transform.as_ref(), shadow, blur.as_ref(), + svg_attrs, antialias, ); } diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index ca7f2a3ef2..6e8736bffc 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -1,11 +1,14 @@ use crate::error::{Error, Result}; -use crate::performance; use crate::shapes::Shape; use crate::view::Viewbox; +use crate::{get_gpu_state, performance}; use skia_safe::{self as skia, IRect, Paint, RRect}; -use super::{gpu_state::GpuState, tiles::Tile, tiles::TileViewbox, tiles::TILE_SIZE}; +use super::{ + gpu_state::GpuState, + tiles::{self, Tile, TileViewbox, TILE_SIZE}, +}; use base64::{engine::general_purpose, Engine as _}; use std::collections::{HashMap, HashSet}; @@ -26,6 +29,12 @@ const TILE_SIZE_MULTIPLIER: i32 = 2; const MAX_ATLAS_TEXTURE_SIZE: i32 = 4096; const DEFAULT_MAX_ATLAS_TEXTURE_SIZE: i32 = 1024; +#[derive(Debug, PartialEq)] +pub enum DrawOnCache { + Yes, + No, +} + #[repr(u32)] #[derive(Debug, PartialEq, Clone, Copy)] pub enum SurfaceId { @@ -42,6 +51,7 @@ pub enum SurfaceId { UI = 0b100_0000_0000, Debug = 0b100_0000_0001, Atlas = 0b100_0000_0010, + Backbuffer = 0b100_0000_0100, } pub struct Surfaces { @@ -67,6 +77,8 @@ pub struct Surfaces { debug: skia::Surface, // for drawing tiles. export: skia::Surface, + // Persistent viewport-sized surface used to keep the last presented frame. + backbuffer: skia::Surface, tiles: TileTextureCache, // Persistent 1:1 document-space atlas that gets incrementally updated as tiles render. @@ -78,10 +90,16 @@ pub struct Surfaces { /// When the atlas would exceed `max_atlas_texture_size`, this value is /// reduced so the atlas stays within the fixed texture cap. atlas_scale: f32, + /// Optional document-space bounds (1 unit == 1 doc px @ 100% zoom) used to + /// clamp atlas writes/clears so the atlas doesn't grow due to outlier tile rects. + atlas_doc_bounds: Option, /// Max width/height in pixels for the atlas surface (typically browser /// `MAX_TEXTURE_SIZE`). Set from ClojureScript after WebGL context creation. max_atlas_texture_size: i32, sampling_options: skia::SamplingOptions, + /// Tracks the last document-space rect written to the atlas per tile. + /// Used to clear old content without clearing the whole (potentially huge) tile rect. + atlas_tile_doc_rects: HashMap, pub margins: skia::ISize, // Tracks which surfaces have content (dirty flag bitmask) dirty_surfaces: u32, @@ -92,11 +110,12 @@ pub struct Surfaces { #[allow(dead_code)] impl Surfaces { pub fn try_new( - gpu_state: &mut GpuState, (width, height): (i32, i32), sampling_options: skia::SamplingOptions, tile_dims: skia::ISize, ) -> Result { + let gpu_state = get_gpu_state(); + let extra_tile_dims = skia::ISize::new( tile_dims.width * TILE_SIZE_MULTIPLIER, tile_dims.height * TILE_SIZE_MULTIPLIER, @@ -106,6 +125,8 @@ impl Surfaces { let target = gpu_state.create_target_surface(width, height)?; let filter = gpu_state.create_surface_with_isize("filter".to_string(), extra_tile_dims)?; let cache = gpu_state.create_surface_with_dimensions("cache".to_string(), width, height)?; + let backbuffer = + gpu_state.create_surface_with_dimensions("backbuffer".to_string(), width, height)?; let current = gpu_state.create_surface_with_isize("current".to_string(), extra_tile_dims)?; @@ -129,7 +150,7 @@ impl Surfaces { atlas.canvas().clear(skia::Color::TRANSPARENT); let tiles = TileTextureCache::new(); - Ok(Surfaces { + Ok(Self { target, filter, cache, @@ -142,13 +163,16 @@ impl Surfaces { ui, debug, export, + backbuffer, tiles, atlas, atlas_origin: skia::Point::new(0.0, 0.0), atlas_size: skia::ISize::new(0, 0), atlas_scale: 1.0, + atlas_doc_bounds: None, max_atlas_texture_size: DEFAULT_MAX_ATLAS_TEXTURE_SIZE, sampling_options, + atlas_tile_doc_rects: HashMap::default(), margins, dirty_surfaces: 0, extra_tile_dims, @@ -162,6 +186,33 @@ impl Surfaces { self.max_atlas_texture_size = max_px.clamp(TILE_SIZE as i32, MAX_ATLAS_TEXTURE_SIZE); } + #[inline] + pub fn max_texture_dimension_px(&self) -> i32 { + self.max_atlas_texture_size + } + + /// Sets the document-space bounds used to clamp atlas updates. + /// Pass `None` to disable clamping. + pub fn set_atlas_doc_bounds(&mut self, bounds: Option) { + self.atlas_doc_bounds = bounds.filter(|b| !b.is_empty()); + } + + fn clamp_doc_rect_to_bounds(&self, doc_rect: skia::Rect) -> skia::Rect { + if doc_rect.is_empty() { + return doc_rect; + } + if let Some(bounds) = self.atlas_doc_bounds { + let mut r = doc_rect; + if r.intersect(bounds) { + r + } else { + skia::Rect::new_empty() + } + } else { + doc_rect + } + } + fn ensure_atlas_contains( &mut self, gpu_state: &mut GpuState, @@ -263,6 +314,7 @@ impl Surfaces { self.atlas_origin = skia::Point::new(new_left, new_top); self.atlas_size = skia::ISize::new(new_w, new_h); self.atlas_scale = new_scale; + gpu_state.delete_surface(&mut self.atlas); self.atlas = new_atlas; Ok(()) } @@ -271,21 +323,51 @@ impl Surfaces { &mut self, gpu_state: &mut GpuState, tile_image: &skia::Image, - doc_rect: skia::Rect, + tile_doc_rect: skia::Rect, ) -> Result<()> { - self.ensure_atlas_contains(gpu_state, doc_rect)?; + if tile_doc_rect.is_empty() { + return Ok(()); + } + + // Clamp to document bounds (if any) and compute a matching source-rect in tile pixels. + let mut clipped_doc_rect = tile_doc_rect; + if let Some(bounds) = self.atlas_doc_bounds { + if !clipped_doc_rect.intersect(bounds) { + return Ok(()); + } + } + if clipped_doc_rect.is_empty() { + return Ok(()); + } + + self.ensure_atlas_contains(gpu_state, clipped_doc_rect)?; // Destination is document-space rect mapped into atlas pixel coords. let dst = skia::Rect::from_xywh( - (doc_rect.left - self.atlas_origin.x) * self.atlas_scale, - (doc_rect.top - self.atlas_origin.y) * self.atlas_scale, - doc_rect.width() * self.atlas_scale, - doc_rect.height() * self.atlas_scale, + (clipped_doc_rect.left - self.atlas_origin.x) * self.atlas_scale, + (clipped_doc_rect.top - self.atlas_origin.y) * self.atlas_scale, + clipped_doc_rect.width() * self.atlas_scale, + clipped_doc_rect.height() * self.atlas_scale, ); - self.atlas - .canvas() - .draw_image_rect(tile_image, None, dst, &skia::Paint::default()); + // Compute source rect in tile_image pixel coordinates. + let img_w = tile_image.width() as f32; + let img_h = tile_image.height() as f32; + let tw = tile_doc_rect.width().max(1.0); + let th = tile_doc_rect.height().max(1.0); + + let sx = ((clipped_doc_rect.left - tile_doc_rect.left) / tw) * img_w; + let sy = ((clipped_doc_rect.top - tile_doc_rect.top) / th) * img_h; + let sw = (clipped_doc_rect.width() / tw) * img_w; + let sh = (clipped_doc_rect.height() / th) * img_h; + let src = skia::Rect::from_xywh(sx, sy, sw, sh); + + self.atlas.canvas().draw_image_rect( + tile_image, + Some((&src, skia::canvas::SrcRectConstraint::Fast)), + dst, + &skia::Paint::default(), + ); Ok(()) } @@ -294,6 +376,7 @@ impl Surfaces { gpu_state: &mut GpuState, doc_rect: skia::Rect, ) -> Result<()> { + let doc_rect = self.clamp_doc_rect_to_bounds(doc_rect); if doc_rect.is_empty() { return Ok(()); } @@ -316,6 +399,70 @@ impl Surfaces { Ok(()) } + /// Clears the last atlas region written by `tile` (if any). + /// + /// This avoids clearing the entire logical tile rect which, at very low + /// zoom levels, can be enormous in document space and would unnecessarily + /// grow / rescale the atlas. + pub fn clear_tile_in_atlas(&mut self, gpu_state: &mut GpuState, tile: Tile) -> Result<()> { + if let Some(doc_rect) = self.atlas_tile_doc_rects.remove(&tile) { + self.clear_doc_rect_in_atlas(gpu_state, doc_rect)?; + } + Ok(()) + } + + /// Clears a doc-space rect from the atlas **without** growing it. + /// + /// Unlike [`clear_doc_rect_in_atlas`], this method clips `doc_rect` to the + /// current atlas bounds and skips silently if there is no overlap. Use this + /// when evicting stale shape content (e.g. before a drag re-render) where + /// growing the atlas to accommodate an out-of-range rect would be wasteful. + pub fn clear_doc_rect_in_atlas_clipped(&mut self, doc_rect: skia::Rect) { + if !self.has_atlas() || doc_rect.is_empty() { + return; + } + + let atlas_scale = self.atlas_scale.max(0.01); + let atlas_doc_right = self.atlas_origin.x + (self.atlas_size.width as f32) / atlas_scale; + let atlas_doc_bottom = self.atlas_origin.y + (self.atlas_size.height as f32) / atlas_scale; + + // Intersect with current atlas bounds in doc space. + let mut clipped = doc_rect; + let atlas_bounds = skia::Rect::from_ltrb( + self.atlas_origin.x, + self.atlas_origin.y, + atlas_doc_right, + atlas_doc_bottom, + ); + if !clipped.intersect(atlas_bounds) { + return; + } + + // Apply atlas_doc_bounds clamping. + if let Some(bounds) = self.atlas_doc_bounds { + if !clipped.intersect(bounds) { + return; + } + } + + if clipped.is_empty() { + return; + } + + let dst = skia::Rect::from_xywh( + (clipped.left - self.atlas_origin.x) * atlas_scale, + (clipped.top - self.atlas_origin.y) * atlas_scale, + clipped.width() * atlas_scale, + clipped.height() * atlas_scale, + ); + + let canvas = self.atlas.canvas(); + canvas.save(); + canvas.clip_rect(dst, None, true); + canvas.clear(skia::Color::TRANSPARENT); + canvas.restore(); + } + pub fn clear_tiles(&mut self) { self.tiles.clear(); } @@ -324,14 +471,21 @@ impl Surfaces { self.atlas_size.width > 0 && self.atlas_size.height > 0 } - /// Draw the persistent atlas onto the target using the current viewbox transform. + /// Draw the persistent atlas onto the backbuffer using the current viewbox transform. /// Intended for fast pan/zoom-out previews (avoids per-tile composition). - pub fn draw_atlas_to_target(&mut self, viewbox: Viewbox, dpr: f32, background: skia::Color) { + /// Clears Backbuffer to `background` first so atlas-uncovered regions don't + /// show stale content when the atlas only partially covers the viewport. + pub fn draw_atlas_to_backbuffer( + &mut self, + viewbox: Viewbox, + dpr: f32, + background: skia::Color, + ) { if !self.has_atlas() { return; - }; + } - let canvas = self.target.canvas(); + let canvas = self.backbuffer.canvas(); canvas.save(); canvas.reset_matrix(); let size = canvas.base_layer_size(); @@ -340,11 +494,10 @@ impl Surfaces { None, true, ); + canvas.clear(background); let s = viewbox.zoom * dpr; let atlas_scale = self.atlas_scale.max(0.01); - - canvas.clear(background); canvas.translate(( (self.atlas_origin.x + viewbox.pan_x) * s, (self.atlas_origin.y + viewbox.pan_y) * s, @@ -365,12 +518,9 @@ impl Surfaces { self.margins } - pub fn resize( - &mut self, - gpu_state: &mut GpuState, - new_width: i32, - new_height: i32, - ) -> Result<()> { + pub fn resize(&mut self, new_width: i32, new_height: i32) -> Result<()> { + let gpu_state = get_gpu_state(); + self.reset_from_target(gpu_state.create_target_surface(new_width, new_height)?)?; Ok(()) } @@ -413,6 +563,11 @@ impl Surfaces { } } + pub fn snapshot_rect(&mut self, id: SurfaceId, irect: skia::IRect) -> Option { + let surface = self.get_mut(id); + surface.image_snapshot_with_bounds(irect) + } + /// Returns a mutable reference to the canvas and automatically marks /// render surfaces as dirty when accessed. This tracks which surfaces /// have content for optimization purposes. @@ -462,7 +617,14 @@ impl Surfaces { self.dirty_surfaces = 0; } - pub fn flush_and_submit(&mut self, gpu_state: &mut GpuState, id: SurfaceId) { + pub fn flush(&mut self, id: SurfaceId) { + let gpu_state = get_gpu_state(); + let surface = self.get_mut(id); + gpu_state.context.flush_surface(surface); + } + + pub fn flush_and_submit(&mut self, id: SurfaceId) { + let gpu_state = get_gpu_state(); let surface = self.get_mut(id); gpu_state.context.flush_and_submit_surface(surface, None); } @@ -478,12 +640,12 @@ impl Surfaces { ); } - /// Draws the cache surface directly to the target canvas. + /// Draws the cache surface directly to the backbuffer canvas. /// This avoids creating an intermediate snapshot, reducing GPU stalls. - pub fn draw_cache_to_target(&mut self) { + pub fn draw_cache_to_backbuffer(&mut self) { let sampling_options = self.sampling_options; - self.cache.clone().draw( - self.target.canvas(), + self.cache.draw( + self.backbuffer.canvas(), (0.0, 0.0), sampling_options, Some(&skia::Paint::default()), @@ -508,6 +670,9 @@ impl Surfaces { if ids & SurfaceId::Cache as u32 != 0 { f(self.get_mut(SurfaceId::Cache)); } + if ids & SurfaceId::Backbuffer as u32 != 0 { + f(self.get_mut(SurfaceId::Backbuffer)); + } if ids & SurfaceId::Fills as u32 != 0 { f(self.get_mut(SurfaceId::Fills)); } @@ -576,12 +741,13 @@ impl Surfaces { }); } - #[inline] + #[inline(always)] pub fn get_mut(&mut self, id: SurfaceId) -> &mut skia::Surface { match id { SurfaceId::Target => &mut self.target, SurfaceId::Filter => &mut self.filter, SurfaceId::Cache => &mut self.cache, + SurfaceId::Backbuffer => &mut self.backbuffer, SurfaceId::Current => &mut self.current, SurfaceId::DropShadows => &mut self.drop_shadows, SurfaceId::InnerShadows => &mut self.inner_shadows, @@ -595,11 +761,13 @@ impl Surfaces { } } + #[inline(always)] fn get(&self, id: SurfaceId) -> &skia::Surface { match id { SurfaceId::Target => &self.target, SurfaceId::Filter => &self.filter, SurfaceId::Cache => &self.cache, + SurfaceId::Backbuffer => &self.backbuffer, SurfaceId::Current => &self.current, SurfaceId::DropShadows => &self.drop_shadows, SurfaceId::InnerShadows => &self.inner_shadows, @@ -613,6 +781,34 @@ impl Surfaces { } } + pub fn surface_size(&self, id: SurfaceId) -> (i32, i32) { + let s = self.get(id); + (s.width(), s.height()) + } + + /// Copy the current `Backbuffer` contents into `Target`. + /// This is a GPU→GPU copy via Skia (no ReadPixels). + pub fn copy_backbuffer_to_target(&mut self) { + let sampling_options = self.sampling_options; + self.backbuffer.draw( + self.target.canvas(), + (0.0, 0.0), + sampling_options, + Some(&skia::Paint::default()), + ); + } + + /// Seed `Backbuffer` from `Target` (last presented frame). + pub fn seed_backbuffer_from_target(&mut self) { + let sampling_options = self.sampling_options; + self.target.draw( + self.backbuffer.canvas(), + (0.0, 0.0), + sampling_options, + Some(&skia::Paint::default()), + ); + } + fn reset_from_target(&mut self, target: skia::Surface) -> Result<()> { let dim = (target.width(), target.height()); self.target = target; @@ -620,6 +816,10 @@ impl Surfaces { .target .new_surface_with_dimensions(dim) .ok_or(Error::CriticalError("Failed to create surface".to_string()))?; + self.backbuffer = self + .target + .new_surface_with_dimensions(dim) + .ok_or(Error::CriticalError("Failed to create surface".to_string()))?; self.debug = self .target .new_surface_with_dimensions(dim) @@ -628,7 +828,6 @@ impl Surfaces { .target .new_surface_with_dimensions(dim) .ok_or(Error::CriticalError("Failed to create surface".to_string()))?; - // The rest are tile size surfaces Ok(()) } @@ -776,6 +975,43 @@ impl Surfaces { self.clear_all_dirty(); } + /// Reset render surfaces for interactive transforms without clearing `Target`. + /// Keeping `Target` avoids having to redraw an atlas backdrop each frame; we + /// then overwrite only the tiles that changed. + pub fn reset_interactive_transform(&mut self, color: skia::Color) { + self.canvas(SurfaceId::Fills).restore_to_count(1); + self.canvas(SurfaceId::InnerShadows).restore_to_count(1); + self.canvas(SurfaceId::TextDropShadows).restore_to_count(1); + self.canvas(SurfaceId::DropShadows).restore_to_count(1); + self.canvas(SurfaceId::Strokes).restore_to_count(1); + self.canvas(SurfaceId::Current).restore_to_count(1); + self.canvas(SurfaceId::Export).restore_to_count(1); + + // Clear tile-sized/intermediate surfaces; leave Target intact. + self.apply_mut( + SurfaceId::Fills as u32 + | SurfaceId::Strokes as u32 + | SurfaceId::Current as u32 + | SurfaceId::InnerShadows as u32 + | SurfaceId::TextDropShadows as u32 + | SurfaceId::DropShadows as u32 + | SurfaceId::Export as u32, + |s| { + s.canvas().clear(color).reset_matrix(); + }, + ); + + // UI/debug can be redrawn; clearing them is fine. + self.canvas(SurfaceId::Debug) + .clear(skia::Color::TRANSPARENT) + .reset_matrix(); + self.canvas(SurfaceId::UI) + .clear(skia::Color::TRANSPARENT) + .reset_matrix(); + + self.clear_all_dirty(); + } + /// Clears the whole cache surface without disturbing its configured transform. pub fn clear_cache(&mut self, color: skia::Color) { let canvas = self.cache.canvas(); @@ -787,13 +1023,13 @@ impl Surfaces { pub fn cache_current_tile_texture( &mut self, - gpu_state: &mut GpuState, tile_viewbox: &TileViewbox, tile: &Tile, tile_rect: &skia::Rect, skip_cache_surface: bool, tile_doc_rect: skia::Rect, ) { + let gpu_state = get_gpu_state(); let rect = IRect::from_xywh( self.margins.width, self.margins.height, @@ -817,6 +1053,7 @@ impl Surfaces { // Incrementally update persistent 1:1 atlas in document space. // `tile_doc_rect` is in world/document coordinates (1 unit == 1 px at 100%). let _ = self.blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect); + self.atlas_tile_doc_rects.insert(*tile, tile_doc_rect); self.tiles.add(tile_viewbox, tile, tile_image); } } @@ -825,11 +1062,127 @@ impl Surfaces { self.tiles.has(tile) } + /// Returns a snapshot of the atlas together with its scale and origin, so the + /// caller can take it **once** per `rebuild_backbuffer_crop_cache` and share it + /// across all shapes that need the tile/atlas fallback path — avoiding an + /// `image_snapshot` (and potential GPU flush) per shape. + pub fn atlas_snapshot_for_drag_crop(&mut self) -> Option<(skia::Image, f32, skia::Point)> { + if !self.has_atlas() { + return None; + } + Some(( + self.atlas.image_snapshot(), + self.atlas_scale.max(0.01), + self.atlas_origin, + )) + } + + /// Builds a 1:1 workspace-pixel snapshot for `src_doc_bounds` / `src_irect` into + /// `scratch`, then returns the sub-region `[0, out_w) × [0, out_h)` as an image. + /// + /// `scratch` must be at least `out_w × out_h` pixels — the caller is responsible + /// for allocating (and **reusing across shapes**) a surface large enough to hold + /// the largest window needed in one `rebuild_backbuffer_crop_cache` pass. + /// + /// `atlas_snap` is a pre-snapshotted view of the persistent atlas produced by + /// [`Surfaces::atlas_snapshot_for_drag_crop`]; pass `None` when no atlas exists. + /// + /// For each tile cell intersecting `src_doc_bounds`: draws from + /// [`TileTextureCache`] when present; otherwise samples the atlas. + #[allow(clippy::too_many_arguments)] + pub fn try_snapshot_doc_rect_from_tiles_and_atlas( + &mut self, + scratch: &mut skia::Surface, + atlas_snap: Option<&(skia::Image, f32, skia::Point)>, + src_doc_bounds: skia::Rect, + src_irect: IRect, + out_w: i32, + out_h: i32, + vb_left: f32, + vb_top: f32, + scale: f32, + ) -> Option { + if out_w <= 0 || out_h <= 0 || src_doc_bounds.is_empty() { + return None; + } + + let canvas = scratch.canvas(); + canvas.clear(skia::Color::TRANSPARENT); + + let tile_size = tiles::get_tile_size(scale); + let tr = tiles::get_tiles_for_rect(src_doc_bounds, tile_size); + let ix0 = src_irect.left as f32; + let iy0 = src_irect.top as f32; + let paint = skia::Paint::default(); + + for ty in tr.y1()..=tr.y2() { + for tx in tr.x1()..=tr.x2() { + let tile = Tile(tx, ty); + let tile_doc = tiles::get_tile_rect(tile, scale); + let mut clip_doc = tile_doc; + if !clip_doc.intersect(src_doc_bounds) || clip_doc.is_empty() { + continue; + } + + let dst = skia::Rect::from_ltrb( + (clip_doc.left - vb_left) * scale - ix0, + (clip_doc.top - vb_top) * scale - iy0, + (clip_doc.right - vb_left) * scale - ix0, + (clip_doc.bottom - vb_top) * scale - iy0, + ); + + if let Some(tile_image) = self.tiles.get(tile) { + let iw = tile_image.width() as f32; + let ih = tile_image.height() as f32; + let td_w = tile_doc.width().max(1e-6); + let td_h = tile_doc.height().max(1e-6); + + let src = skia::Rect::from_ltrb( + ((clip_doc.left - tile_doc.left) / td_w) * iw, + ((clip_doc.top - tile_doc.top) / td_h) * ih, + ((clip_doc.right - tile_doc.left) / td_w) * iw, + ((clip_doc.bottom - tile_doc.top) / td_h) * ih, + ); + + canvas.draw_image_rect( + tile_image, + Some((&src, skia::canvas::SrcRectConstraint::Fast)), + dst, + &paint, + ); + } else { + let snap = atlas_snap?; + let (atlas, a_scale, atlas_origin) = (&snap.0, snap.1, snap.2); + let sx = (clip_doc.left - atlas_origin.x) * a_scale; + let sy = (clip_doc.top - atlas_origin.y) * a_scale; + let sw = clip_doc.width() * a_scale; + let sh = clip_doc.height() * a_scale; + if sw <= 0.0 || sh <= 0.0 { + continue; + } + let src = skia::Rect::from_xywh(sx, sy, sw, sh); + canvas.draw_image_rect( + atlas, + Some((&src, skia::canvas::SrcRectConstraint::Fast)), + dst, + &paint, + ); + } + } + } + + scratch.image_snapshot_with_bounds(IRect::new(0, 0, out_w, out_h)) + } + pub fn remove_cached_tile_surface(&mut self, tile: Tile) { + let gpu_state = get_gpu_state(); // Mark tile as invalid // Old content stays visible until new tile overwrites it atomically, // preventing flickering during tile re-renders. self.tiles.remove(tile); + // Also clear the corresponding region in the persistent atlas to avoid + // leaving stale pixels when shapes move/delete. + let _ = self.clear_tile_in_atlas(gpu_state, tile); } pub fn draw_cached_tile_surface(&mut self, tile: Tile, rect: skia::Rect, color: skia::Color) { @@ -837,17 +1190,17 @@ impl Surfaces { let mut paint = skia::Paint::default(); paint.set_color(color); - self.target.canvas().draw_rect(rect, &paint); + self.backbuffer.canvas().draw_rect(rect, &paint); - self.target + self.backbuffer .canvas() .draw_image_rect(&image, None, rect, &skia::Paint::default()); } } - /// Draws a cached tile texture to the Cache surface at the given + /// Draws a cached tile texture to the Cache self.backbuffer at the given /// cache-aligned rect. This keeps the Cache surface in sync with - /// Target so that `render_from_cache` (used during pan) has the + /// Backbuffer so that `render_from_cache` (used during pan) has the /// full scene including tiles served from the texture cache. pub fn draw_cached_tile_to_cache( &mut self, @@ -868,10 +1221,15 @@ impl Surfaces { } } - /// Draws the current tile directly to the target and cache surfaces without + /// Draws the current tile directly to the backbuffer and cache surfaces without /// creating a snapshot. This avoids GPU stalls from ReadPixels but doesn't /// populate the tile texture cache (suitable for one-shot renders like tests). - pub fn draw_current_tile_direct(&mut self, tile_rect: &skia::Rect, color: skia::Color) { + pub fn draw_current_tile_direct( + &mut self, + tile_rect: &skia::Rect, + color: skia::Color, + draw_on_cache: DrawOnCache, + ) { let sampling_options = self.sampling_options; let src_rect = IRect::from_xywh( self.margins.width, @@ -881,14 +1239,15 @@ impl Surfaces { ); let src_rect_f = skia::Rect::from(src_rect); + let backbuffer_canvas = self.backbuffer.canvas(); // Draw background let mut paint = skia::Paint::default(); paint.set_color(color); - self.target.canvas().draw_rect(tile_rect, &paint); + backbuffer_canvas.draw_rect(tile_rect, &paint); // Draw current surface directly to target (no snapshot) - self.current.clone().draw( - self.target.canvas(), + self.current.draw( + backbuffer_canvas, ( tile_rect.left - src_rect_f.left, tile_rect.top - src_rect_f.top, @@ -898,15 +1257,17 @@ impl Surfaces { ); // Also draw to cache for render_from_cache - self.current.clone().draw( - self.cache.canvas(), - ( - tile_rect.left - src_rect_f.left, - tile_rect.top - src_rect_f.top, - ), - sampling_options, - None, - ); + if draw_on_cache == DrawOnCache::Yes { + self.current.draw( + self.cache.canvas(), + ( + tile_rect.left - src_rect_f.left, + tile_rect.top - src_rect_f.top, + ), + sampling_options, + None, + ); + } } /// Full cache reset: clears both the tile texture cache and the cache canvas. @@ -914,6 +1275,7 @@ impl Surfaces { /// the cache canvas for scaled previews, use `invalidate_tile_cache` instead. pub fn remove_cached_tiles(&mut self, color: skia::Color) { self.tiles.clear(); + self.atlas_tile_doc_rects.clear(); self.cache.canvas().clear(color); } @@ -923,6 +1285,7 @@ impl Surfaces { /// content while new tiles are being rendered. pub fn invalidate_tile_cache(&mut self) { self.tiles.clear(); + self.atlas_tile_doc_rects.clear(); } pub fn gc(&mut self) { diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index 9478e8a0eb..8c990942ec 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -3,7 +3,7 @@ use crate::shapes::{Shape, TextContent, Type, VerticalAlign}; use crate::state::{TextEditorState, TextSelection}; use crate::view::Viewbox; use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; -use skia_safe::{BlendMode, Canvas, Paint, Rect}; +use skia_safe::{BlendMode, Canvas, Color, Paint, Rect}; pub fn render_overlay( canvas: &Canvas, @@ -21,7 +21,7 @@ pub fn render_overlay( }; canvas.save(); - let zoom = viewbox.zoom * options.dpr(); + let zoom = viewbox.zoom * options.dpr; canvas.scale((zoom, zoom)); canvas.translate((-viewbox.area.left, -viewbox.area.top)); @@ -48,11 +48,26 @@ fn render_cursor( }; let mut cursor_rect = Rect::new_empty(); - cursor_rect.set_xywh(rect.x(), rect.y(), 1.5 / zoom, rect.height()); + cursor_rect.set_xywh( + rect.x(), + rect.y(), + if editor_state.is_overtype_mode { + rect.width() + } else { + editor_state.theme.cursor_width / zoom + }, + rect.height(), + ); let mut paint = Paint::default(); - paint.set_color(editor_state.theme.cursor_color); - paint.set_anti_alias(true); + paint.set_anti_alias(false); + if editor_state.is_overtype_mode { + paint.set_blend_mode(BlendMode::Exclusion); + paint.set_color(Color::WHITE); + } else { + paint.set_blend_mode(BlendMode::SrcOver); + paint.set_color(editor_state.theme.cursor_color); + } let shape_matrix = shape.get_matrix(); canvas.save(); @@ -132,9 +147,9 @@ fn calculate_cursor_rect( .map(|span| span.text.chars().count()) .sum(); - let (cursor_x, cursor_y, cursor_height) = if para_char_count == 0 { + let (cursor_x, cursor_y, cursor_width, cursor_height) = if para_char_count == 0 { // Empty paragraph - use default height - (0.0, 0.0, laid_out_para.height()) + (0.0, 0.0, 1.0, laid_out_para.height()) } else if char_pos == 0 { let rects = laid_out_para.get_rects_for_range( 0..1, @@ -143,9 +158,9 @@ fn calculate_cursor_rect( ); if !rects.is_empty() { let r = &rects[0].rect; - (r.left(), r.top(), r.height()) + (r.left(), r.top(), r.width(), r.height()) } else { - (0.0, 0.0, laid_out_para.height()) + (0.0, 0.0, 1.0, laid_out_para.height()) } } else if char_pos >= para_char_count { let rects = laid_out_para.get_rects_for_range( @@ -155,9 +170,14 @@ fn calculate_cursor_rect( ); if !rects.is_empty() { let r = &rects[0].rect; - (r.right(), r.top(), r.height()) + (r.right(), r.top(), r.width(), r.height()) } else { - (laid_out_para.longest_line(), 0.0, laid_out_para.height()) + ( + laid_out_para.longest_line(), + 0.0, + 1.0, + laid_out_para.height(), + ) } } else { let rects = laid_out_para.get_rects_for_range( @@ -167,18 +187,18 @@ fn calculate_cursor_rect( ); if !rects.is_empty() { let r = &rects[0].rect; - (r.left(), r.top(), r.height()) + (r.left(), r.top(), r.width(), r.height()) } else { // Fallback: use glyph position let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0)); - (pos.position as f32, 0.0, laid_out_para.height()) + (pos.position as f32, 0.0, 1.0, laid_out_para.height()) } }; return Some(Rect::from_xywh( cursor_x, y_offset + cursor_y, - 1.0, // cursor_width + cursor_width, // cursor_width cursor_height, )); } diff --git a/render-wasm/src/render/ui.rs b/render-wasm/src/render/ui.rs index bb7cc72c0f..71ff44eec6 100644 --- a/render-wasm/src/render/ui.rs +++ b/render-wasm/src/render/ui.rs @@ -6,21 +6,15 @@ use crate::shapes::{Layout, Type}; pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) { let canvas = render_state.surfaces.canvas(SurfaceId::UI); + let viewbox = render_state.viewbox; + let zoom = viewbox.zoom * render_state.options.dpr; + let show_grid_id = render_state.show_grid; canvas.clear(Color4f::new(0.0, 0.0, 0.0, 0.0)); canvas.save(); - - let viewbox = render_state.viewbox; - let zoom = viewbox.zoom * render_state.options.dpr(); - canvas.scale((zoom, zoom)); - canvas.translate((-viewbox.area.left, -viewbox.area.top)); - let canvas = render_state.surfaces.canvas(SurfaceId::UI); - - let show_grid_id = render_state.show_grid; - if let Some(id) = show_grid_id { if let Some(shape) = shapes.get(&id) { grid_layout::render_overlay( @@ -67,6 +61,7 @@ pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) { } canvas.restore(); + render_state.surfaces.draw_into( SurfaceId::UI, SurfaceId::Target, diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 9e17de41ee..0b26cd632f 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -195,7 +195,7 @@ pub struct Shape { pub shadows: Vec, pub layout_item: Option, pub bounds: OnceCell, - pub extrect_cache: RefCell>, + pub extrect_cache: RefCell>, pub svg_transform: Option, pub ignore_constraints: bool, deleted: bool, @@ -1015,17 +1015,14 @@ impl Shape { } pub fn calculate_extrect(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect { - let scale_key = (scale * 1000.0).round() as u32; - - if let Some((cached_extrect, cached_scale)) = *self.extrect_cache.borrow() { - if cached_scale == scale_key { - return cached_extrect; - } + // `scale` is forwarded to children but intentionally NOT part of the cache key. + if let Some(cached_extrect) = *self.extrect_cache.borrow() { + return cached_extrect; } let extrect = self.calculate_extrect_uncached(shapes_pool, scale); - *self.extrect_cache.borrow_mut() = Some((extrect, scale_key)); + *self.extrect_cache.borrow_mut() = Some(extrect); extrect } @@ -1349,13 +1346,10 @@ impl Shape { pub fn get_skia_path(&self) -> Option { if let Some(path) = self.shape_type.path() { - let mut skia_path = path.to_skia_path(); - if let Some(path_transform) = self.to_path_transform() { - skia_path = skia_path.make_transform(&path_transform); - } - if let Some(svg_attrs) = &self.svg_attrs { - if svg_attrs.fill_rule == FillRule::Evenodd { - skia_path.set_fill_type(skia::PathFillType::EvenOdd); + let mut skia_path = path.to_skia_path(self.svg_attrs.as_ref()); + if !math::identitish(&self.transform) { + if let Some(path_transform) = self.to_path_transform() { + skia_path = skia_path.make_transform(&path_transform); } } Some(skia_path) @@ -1364,7 +1358,120 @@ impl Shape { } } + /// Same `concat` applied around [`center`](Self::center) as in `render_shape` (non-text branch). + fn shape_document_transform(&self) -> Matrix { + let c = self.center(); + let mut m = self.transform; + m.post_translate(c); + m.pre_translate(-c); + m + } + + /// Fill silhouette only, document space (matches fill rendering). + fn drag_crop_fill_clip_path_skia(&self) -> Option { + match &self.shape_type { + Type::Rect(r) => { + let p = Path::new(shape_to_path::rect_segments(self, r.corners)); + Some(p.to_skia_path(self.svg_attrs.as_ref())) + } + Type::Circle => { + let p = Path::new(shape_to_path::circle_segments(self)); + Some(p.to_skia_path(self.svg_attrs.as_ref())) + } + Type::Path(_) | Type::Bool(_) => { + let sk = self.get_skia_path()?; + Some(sk.make_transform(&self.shape_document_transform())) + } + _ => None, + } + } + + /// Whether this shape may use the backbuffer crop fast path during interactive drag. + /// + /// Conservative: only effects and fills that match what we snapshot and clip in + /// [`drag_crop_clip_path`](Self::drag_crop_clip_path). Text is never safe (glyph layout, + /// no `drag_crop_clip_path`). + pub fn is_safe_for_drag_crop_cache(&self, shapes_pool: ShapesPoolRef) -> bool { + if matches!(self.shape_type, Type::Text(_)) { + return false; + } + + // If a frame shows overflow (clip_content=false) and its visible content exceeds the + // frame bounds, a cached crop anchored to the frame can easily become incorrect while + // moving (children can extend beyond selrect). Be conservative and render live. + if matches!(self.shape_type, Type::Frame(_)) && !self.clip_content { + let extrect = self.extrect(shapes_pool, 1.0); + let sr = self.selrect; + let exceeds = extrect.left < sr.left + || extrect.top < sr.top + || extrect.right > sr.right + || extrect.bottom > sr.bottom; + if exceeds { + return false; + } + } + + self.blur.is_none() + && self.shadows.is_empty() + && (self.opacity - 1.0).abs() <= 1e-4 + && self.blend_mode().0 == skia::BlendMode::SrcOver + } + + /// Fill + visible strokes in **document space** for clipping interactive drag textures. + /// + /// The backbuffer crop uses an axis-aligned `extrect`; we clip the blit so backdrop pixels + /// outside the real silhouette (fill and stroke regions) are not smeared. Strokes use + /// [`stroke_to_path`](stroke_to_path) like the main renderer, then union with the fill path. + pub fn drag_crop_clip_path(&self) -> Option { + let mut acc = self.drag_crop_fill_clip_path_skia()?; + if !self.has_visible_strokes() { + return Some(acc); + } + + let shape_path = match &self.shape_type { + Type::Rect(r) => Path::new(shape_to_path::rect_segments(self, r.corners)), + Type::Circle => Path::new(shape_to_path::circle_segments(self)), + Type::Path(_) | Type::Bool(_) => self.shape_type.path()?.clone(), + _ => return Some(acc), + }; + + let path_transform = self.to_path_transform(); + let apply_doc_transform = path_transform.is_some(); + + for stroke in self.visible_strokes() { + let Some(stroke_region) = stroke_to_path( + stroke, + &shape_path, + path_transform.as_ref(), + &self.selrect, + self.svg_attrs.as_ref(), + ) else { + continue; + }; + let mut sk = stroke_region.to_skia_path(self.svg_attrs.as_ref()); + if apply_doc_transform { + sk = sk.make_transform(&self.shape_document_transform()); + } + acc = acc.op(&sk, skia::PathOp::Union).unwrap_or(acc); + } + + Some(acc) + } + fn transform_selrect(&mut self, transform: &Matrix) { + if math::is_move_only_matrix(transform) { + let tx = transform.translate_x(); + let ty = transform.translate_y(); + // `self.transform` (rotation/scale around center) is unchanged by translation. + self.selrect = math::Rect::from_xywh( + self.selrect.left + tx, + self.selrect.top + ty, + self.selrect.width(), + self.selrect.height(), + ); + return; + } + let mut center = self.selrect.center(); center = transform.map_point(center); @@ -1386,9 +1493,26 @@ impl Shape { pub fn apply_transform(&mut self, transform: &Matrix) { self.transform_selrect(transform); - // TODO: See if we can change this invalidation to a transformation - self.invalidate_extrect(); - self.invalidate_bounds(); + // Outsets (strokes, shadows, blur, children) are translation-invariant, + // so the cached extrect can be shifted instead of invalidated. + // The bounds cache must always be invalidated so that callers such as + // grid_cell_data get the updated position after a drag. + if math::is_move_only_matrix(transform) { + let tx = transform.translate_x(); + let ty = transform.translate_y(); + if let Some(rect) = self.extrect_cache.borrow_mut().as_mut() { + *rect = math::Rect::from_xywh( + rect.left + tx, + rect.top + ty, + rect.width(), + rect.height(), + ); + } + self.invalidate_bounds(); + } else { + self.invalidate_extrect(); + self.invalidate_bounds(); + } if let shape_type @ (Type::Path(_) | Type::Bool(_)) = &mut self.shape_type { if let Some(path) = shape_type.path_mut() { diff --git a/render-wasm/src/shapes/fills.rs b/render-wasm/src/shapes/fills.rs index 5b61b3ee2a..87da3881bf 100644 --- a/render-wasm/src/shapes/fills.rs +++ b/render-wasm/src/shapes/fills.rs @@ -259,15 +259,21 @@ pub fn get_fill_shader(fill: &Fill, bounding_box: &Rect) -> Option } pub fn merge_fills(fills: &[Fill], bounding_box: Rect) -> skia::Paint { - let mut combined_shader: Option = None; let mut fills_paint = skia::Paint::default(); if fills.is_empty() { - combined_shader = Some(skia::shaders::color(skia::Color::TRANSPARENT)); - fills_paint.set_shader(combined_shader); + fills_paint.set_color(skia::Color::TRANSPARENT); return fills_paint; } + if fills.len() == 1 { + if let Fill::Solid(SolidColor(color)) = &fills[0] { + fills_paint.set_color(*color); + return fills_paint; + } + } + + let mut combined_shader: Option = None; for fill in fills { let shader = get_fill_shader(fill, &bounding_box); @@ -287,7 +293,7 @@ pub fn merge_fills(fills: &[Fill], bounding_box: Rect) -> skia::Paint { } } - fills_paint.set_shader(combined_shader.clone()); + fills_paint.set_shader(combined_shader); fills_paint } diff --git a/render-wasm/src/shapes/modifiers.rs b/render-wasm/src/shapes/modifiers.rs index 4c7f6d69d4..32752cbbc3 100644 --- a/render-wasm/src/shapes/modifiers.rs +++ b/render-wasm/src/shapes/modifiers.rs @@ -10,6 +10,7 @@ use crate::math::{self as math, bools, identitish, is_close_to, Bounds, Matrix, use common::GetBounds; use crate::error::Result; +use crate::shapes; use crate::shapes::{ ConstraintH, ConstraintV, Frame, Group, GrowType, Layout, Modifier, Shape, TransformEntry, TransformEntrySource, Type, @@ -166,6 +167,7 @@ fn set_pixel_precision(transform: &mut Matrix, bounds: &mut Bounds) { } } +#[allow(clippy::too_many_arguments)] fn propagate_transform( entry: TransformEntry, pixel_precision: bool, @@ -173,6 +175,8 @@ fn propagate_transform( entries: &mut VecDeque, bounds: &mut HashMap, modifiers: &mut HashMap, + reflown: &mut HashSet, + reflowed_shapes: &mut HashSet, ) -> Result<()> { let Some(shape) = state.shapes.get(&entry.id) else { return Ok(()); @@ -188,7 +192,11 @@ fn propagate_transform( if !is_close_to(shape_bounds_before.width(), shape_bounds_after.width()) || !is_close_to(shape_bounds_before.height(), shape_bounds_after.height()) { - if let Type::Text(text_content) = &mut shape.shape_type.clone() { + if let Type::Text(text_content) = &shape.shape_type { + let width_changed = + !is_close_to(shape_bounds_before.width(), shape_bounds_after.width()); + let height_changed = + !is_close_to(shape_bounds_before.height(), shape_bounds_after.height()); let resized_selrect = math::Rect::from_xywh( shape.selrect.left(), shape.selrect.top(), @@ -197,38 +205,47 @@ fn propagate_transform( ); match text_content.grow_type() { GrowType::AutoHeight => { - // For auto-height, always update layout when width changes - // because the new width affects how text wraps - let width_changed = - !is_close_to(shape_bounds_before.width(), shape_bounds_after.width()); - if width_changed || text_content.needs_update_layout() { - text_content.update_layout(resized_selrect); + let height_before = text_content.size.height; + let new_height = if width_changed { + let mut clone = text_content.clone(); + clone.update_layout(resized_selrect); + clone.size.height + } else { + height_before + }; + if !is_close_to(height_before, new_height) && reflowed_shapes.insert(shape.id) { + entries.push_back(Modifier::reflow(shape.id, false)); + + if let Some(parent_id) = shape.parent_id { + for pid in + shapes::all_with_ancestors(&[parent_id], shapes, false).iter() + { + reflown.remove(pid); + } + } } - let height = text_content.size.height; let resize_transform = math::resize_matrix( &shape_bounds_after, &shape_bounds_after, shape_bounds_after.width(), - height, + new_height, ); shape_bounds_after = shape_bounds_after.transform(&resize_transform); transform.post_concat(&resize_transform); } GrowType::AutoWidth => { - // For auto-width, always update layout when height changes - // because the new height affects how text flows - let height_changed = - !is_close_to(shape_bounds_before.height(), shape_bounds_after.height()); - if height_changed || text_content.needs_update_layout() { - text_content.update_layout(resized_selrect); - } - let width = text_content.width(); - let height = text_content.size.height; + let (new_width, new_height) = if height_changed { + let mut clone = text_content.clone(); + clone.update_layout(resized_selrect); + (clone.width(), clone.size.height) + } else { + (text_content.width(), text_content.size.height) + }; let resize_transform = math::resize_matrix( &shape_bounds_after, &shape_bounds_after, - width, - height, + new_width, + new_height, ); shape_bounds_after = shape_bounds_after.transform(&resize_transform); transform.post_concat(&resize_transform); @@ -393,6 +410,7 @@ pub fn propagate_modifiers( // In order for loop to eventualy finish, we limit the flex reflow to just // one (the reflown set). while !entries.is_empty() { + let mut reflowed_shapes = HashSet::::new(); while let Some(modifier) = entries.pop_front() { match modifier { Modifier::Transform(entry, pixel) => propagate_transform( @@ -402,6 +420,8 @@ pub fn propagate_modifiers( &mut entries, &mut bounds, &mut modifiers, + &mut reflown, + &mut reflowed_shapes, )?, Modifier::Reflow(id, force_reflow) => { if force_reflow { diff --git a/render-wasm/src/shapes/modifiers/flex_layout.rs b/render-wasm/src/shapes/modifiers/flex_layout.rs index e4f0d29311..41d3e02687 100644 --- a/render-wasm/src/shapes/modifiers/flex_layout.rs +++ b/render-wasm/src/shapes/modifiers/flex_layout.rs @@ -236,7 +236,11 @@ fn initialize_tracks( let gap_main = if first { 0.0 } else { layout_axis.gap_main }; - let next_main_size = current_track.main_size + child_main_size + gap_main; + let next_main_size = if current_track.shapes.is_empty() { + child_main_size + } else { + current_track.main_size + child_main_size + gap_main + }; let main_space = layout_axis.main_space(); let exceeds_main_space = next_main_size > main_space + TRACK_TOLERANCE; @@ -329,9 +333,19 @@ fn distribute_fill_across_space(layout_axis: &LayoutAxis, tracks: &mut [TrackDat let current = left_space / to_resize_tracks.len() as f32; for i in (0..to_resize_tracks.len()).rev() { let track = &mut to_resize_tracks[i]; - let delta = - f32::min(track.max_across_size, track.across_size + current) - track.across_size; - track.across_size += delta; + + let delta = if math::is_close_to(track.across_size, MIN_SIZE) { + f32::min(track.max_across_size, track.across_size + current) + } else { + f32::min(track.max_across_size, track.across_size + current) - track.across_size + }; + + if math::is_close_to(track.across_size, MIN_SIZE) { + track.across_size = delta; + } else { + track.across_size += delta; + } + left_space -= delta; if (track.across_size - track.max_across_size).abs() < MIN_SIZE { @@ -686,7 +700,7 @@ pub fn reflow_flex_layout( + (nshapes as f32 - 1.0) * layout_axis.gap_main }) .reduce(f32::max) - .unwrap_or(0.01) + .unwrap_or(MIN_SIZE) + layout_axis.padding_main_start + layout_axis.padding_main_end } else { diff --git a/render-wasm/src/shapes/modifiers/grid_layout.rs b/render-wasm/src/shapes/modifiers/grid_layout.rs index 3599f1e595..362d362851 100644 --- a/render-wasm/src/shapes/modifiers/grid_layout.rs +++ b/render-wasm/src/shapes/modifiers/grid_layout.rs @@ -651,6 +651,37 @@ pub fn grid_cell_data<'a>( ) } +// Returns `(h_min, v_min, h_size, v_size)` — the child's bounding box expressed in the +// layout frame's own coordinate system (projected onto its `hv`/`vv` unit vectors). +// +// Using the frame axes rather than screen x/y is necessary when the parent grid frame +// is itself rotated: in that case `max_x - min_x` is the screen-AABB width, which +// differs from the width measured along the frame's horizontal axis. +fn child_frame_aabb(child_bounds: &Bounds, hv: Vector, vv: Vector) -> (f32, f32, f32, f32) { + let corners = child_bounds.points(); + let mut h_min = f32::INFINITY; + let mut h_max = f32::NEG_INFINITY; + let mut v_min = f32::INFINITY; + let mut v_max = f32::NEG_INFINITY; + for p in &corners { + let h = hv.x * p.x + hv.y * p.y; + let v = vv.x * p.x + vv.y * p.y; + if h < h_min { + h_min = h; + } + if h > h_max { + h_max = h; + } + if v < v_min { + v_min = v; + } + if v > v_max { + v_max = v; + } + } + (h_min, v_min, h_max - h_min, v_max - v_min) +} + fn child_position( child: &Shape, layout_bounds: &Bounds, @@ -667,12 +698,17 @@ fn child_position( let margin_right = layout_item.map(|i| i.margin_right).unwrap_or(0.0); let margin_bottom = layout_item.map(|i| i.margin_bottom).unwrap_or(0.0); + // Project corners onto the frame's own axes so that both a rotated child *and* a + // rotated parent frame are handled correctly. For an axis-aligned frame this + // reduces to max_x-min_x / max_y-min_y, so non-rotated layouts are unaffected. + let (_, _, child_width, child_height) = child_frame_aabb(child_bounds, hv, vv); + let vpos = match (cell.align_self, layout_data.align_items) { (Some(AlignSelf::Start), _) => margin_top, - (Some(AlignSelf::Center), _) => (cell.height - child_bounds.height()) / 2.0, - (Some(AlignSelf::End), _) => margin_bottom + cell.height - child_bounds.height(), - (_, AlignItems::Center) => (cell.height - child_bounds.height()) / 2.0, - (_, AlignItems::End) => margin_bottom + cell.height - child_bounds.height(), + (Some(AlignSelf::Center), _) => (cell.height - child_height) / 2.0, + (Some(AlignSelf::End), _) => margin_bottom + cell.height - child_height, + (_, AlignItems::Center) => (cell.height - child_height) / 2.0, + (_, AlignItems::End) => margin_bottom + cell.height - child_height, _ => margin_top, }; @@ -684,10 +720,10 @@ fn child_position( let hpos = match (cell.justify_self, layout_data.justify_items) { (Some(JustifySelf::Start), _) => margin_left, - (Some(JustifySelf::Center), _) => (cell.width - child_bounds.width()) / 2.0, - (Some(JustifySelf::End), _) => margin_right + cell.width - child_bounds.width(), - (_, JustifyItems::Center) => (cell.width - child_bounds.width()) / 2.0, - (_, JustifyItems::End) => margin_right + cell.width - child_bounds.width(), + (Some(JustifySelf::Center), _) => (cell.width - child_width) / 2.0, + (Some(JustifySelf::End), _) => margin_right + cell.width - child_width, + (_, JustifyItems::Center) => (cell.width - child_width) / 2.0, + (_, JustifyItems::End) => margin_right + cell.width - child_width, _ => margin_left, }; @@ -747,11 +783,27 @@ pub fn reflow_grid_layout( let Some(child) = cell.shape else { continue }; let child_bounds = bounds.find(child); + // Compute frame-axis projections once; used for both sizing and positioning. + let hv = layout_bounds.hv(1.0); + let vv = layout_bounds.vv(1.0); + let (h_min, v_min, child_frame_w, child_frame_h) = child_frame_aabb(&child_bounds, hv, vv); + + // resize_matrix scales the child in the parent's local frame coordinate system + // by (new_width / child_bounds.width()) in the h-axis and + // (new_height / child_bounds.height()) in the v-axis. For a rotated child the + // frame-projected extent differs from the intrinsic bounds dimensions, so we + // back-calculate the intrinsic target that will produce the desired + // frame-projected extent. let mut new_width = child_bounds.width(); if child.is_layout_horizontal_fill() { let margin_left = child.layout_item.map(|i| i.margin_left).unwrap_or(0.0); let margin_right = child.layout_item.map(|i| i.margin_right).unwrap_or(0.0); - new_width = cell.width - margin_left - margin_right; + let target_frame_w = cell.width - margin_left - margin_right; + new_width = if child_frame_w > MIN_SIZE { + target_frame_w * child_bounds.width() / child_frame_w + } else { + target_frame_w + }; let min_width = child.layout_item.and_then(|i| i.min_w).unwrap_or(MIN_SIZE); let max_width = child.layout_item.and_then(|i| i.max_w).unwrap_or(MAX_SIZE); new_width = new_width.clamp(min_width, max_width); @@ -761,7 +813,12 @@ pub fn reflow_grid_layout( if child.is_layout_vertical_fill() { let margin_top = child.layout_item.map(|i| i.margin_top).unwrap_or(0.0); let margin_bottom = child.layout_item.map(|i| i.margin_bottom).unwrap_or(0.0); - new_height = cell.height - margin_top - margin_bottom; + let target_frame_h = cell.height - margin_top - margin_bottom; + new_height = if child_frame_h > MIN_SIZE { + target_frame_h * child_bounds.height() / child_frame_h + } else { + target_frame_h + }; let min_height = child.layout_item.and_then(|i| i.min_h).unwrap_or(MIN_SIZE); let max_height = child.layout_item.and_then(|i| i.max_h).unwrap_or(MAX_SIZE); new_height = new_height.clamp(min_height, max_height); @@ -792,7 +849,36 @@ pub fn reflow_grid_layout( cell, ); - let delta_v = Vector::new_points(&child_bounds.nw, &position); + // Compute the child's reference point in the frame's coordinate system. + // For a rotated parent frame, (min_x, min_y) is wrong because it is the + // screen-AABB corner, not the frame-axis-aligned corner. + // child_ref = h_min * hv + v_min * vv gives the world-space point whose + // projections onto hv/vv are the child's minima along those axes — + // the "top-left in frame coordinates". + // + // For fill axes, resize_matrix scales local-x/y by (new_w / child_bounds.width()) + // anchored at nw. This shifts h_min/v_min: the post-resize minimum is + // h_min_new = nw_h + (h_min - nw_h) * scale_w + // We must translate FROM this post-resize minimum, not the pre-resize one. + let nw_h = hv.x * child_bounds.nw.x + hv.y * child_bounds.nw.y; + let nw_v = vv.x * child_bounds.nw.x + vv.y * child_bounds.nw.y; + let h_anchor = if child.is_layout_horizontal_fill() && child_bounds.width() > MIN_SIZE { + let scale_w = new_width / child_bounds.width(); + nw_h + (h_min - nw_h) * scale_w + } else { + h_min + }; + let v_anchor = if child.is_layout_vertical_fill() && child_bounds.height() > MIN_SIZE { + let scale_h = new_height / child_bounds.height(); + nw_v + (v_min - nw_v) * scale_h + } else { + v_min + }; + let child_ref = Point::new( + h_anchor * hv.x + v_anchor * vv.x, + h_anchor * hv.y + v_anchor * vv.y, + ); + let delta_v = Vector::new_points(&child_ref, &position); if delta_v.x.abs() > MIN_SIZE || delta_v.y.abs() > MIN_SIZE { transform.post_concat(&Matrix::translate(delta_v)); diff --git a/render-wasm/src/shapes/paths.rs b/render-wasm/src/shapes/paths.rs index 2debf44958..aa14953341 100644 --- a/render-wasm/src/shapes/paths.rs +++ b/render-wasm/src/shapes/paths.rs @@ -1,6 +1,6 @@ -use skia_safe::{self as skia, Matrix}; - use crate::math; +use crate::shapes::svg_attrs::{FillRule, SvgAttrs}; +use skia_safe::{self as skia, Matrix}; mod subpaths; @@ -217,8 +217,14 @@ impl Path { Path::new(segments) } - pub fn to_skia_path(&self) -> skia::Path { - self.skia_path.snapshot() + pub fn to_skia_path(&self, svg_attrs: Option<&SvgAttrs>) -> skia::Path { + let mut path = self.skia_path.snapshot(); + if let Some(attrs) = svg_attrs { + if attrs.fill_rule == FillRule::Evenodd { + path.set_fill_type(skia::PathFillType::EvenOdd); + } + } + path } pub fn contains(&self, p: skia::Point) -> bool { @@ -230,6 +236,28 @@ impl Path { } pub fn transform(&mut self, mtx: &Matrix) { + if math::is_move_only_matrix(mtx) { + let tx = mtx.translate_x(); + let ty = mtx.translate_y(); + self.segments.iter_mut().for_each(|s| match s { + Segment::MoveTo(p) | Segment::LineTo(p) => { + p.0 += tx; + p.1 += ty; + } + Segment::CurveTo((c1, c2, p)) => { + c1.0 += tx; + c1.1 += ty; + c2.0 += tx; + c2.1 += ty; + p.0 += tx; + p.1 += ty; + } + _ => {} + }); + self.skia_path = self.skia_path.with_offset((tx, ty)); + return; + } + self.segments.iter_mut().for_each(|s| match s { Segment::MoveTo(p) => { let np = mtx.map_point(skia::Point::new(p.0, p.1)); diff --git a/render-wasm/src/shapes/stroke_paths.rs b/render-wasm/src/shapes/stroke_paths.rs index 14f9c09229..f98c01ca0f 100644 --- a/render-wasm/src/shapes/stroke_paths.rs +++ b/render-wasm/src/shapes/stroke_paths.rs @@ -17,7 +17,7 @@ pub fn stroke_to_path( selrect: &Rect, svg_attrs: Option<&SvgAttrs>, ) -> Option { - let skia_shape_path = shape_path.to_skia_path(); + let skia_shape_path = shape_path.to_skia_path(svg_attrs); let transformed_shape_path = if let Some(pt) = path_transform { skia_shape_path.make_transform(pt) diff --git a/render-wasm/src/shapes/strokes.rs b/render-wasm/src/shapes/strokes.rs index 4ca3b5f906..48f43d975b 100644 --- a/render-wasm/src/shapes/strokes.rs +++ b/render-wasm/src/shapes/strokes.rs @@ -41,6 +41,10 @@ pub struct Stroke { pub cap_end: Option, pub cap_start: Option, pub kind: StrokeKind, + // Dash and gap overrides for the `Dashed` style. `None` falls back to the + // default `width + 10` pattern to keep existing designs visually identical. + pub dash: Option, + pub gap: Option, } impl Stroke { @@ -72,6 +76,8 @@ impl Stroke { style: StrokeStyle, cap_start: Option, cap_end: Option, + dash: Option, + gap: Option, ) -> Self { Stroke { fill: Fill::Solid(SolidColor(skia::Color::TRANSPARENT)), @@ -80,6 +86,8 @@ impl Stroke { cap_end, cap_start, kind: StrokeKind::Center, + dash, + gap, } } @@ -88,6 +96,8 @@ impl Stroke { style: StrokeStyle, cap_start: Option, cap_end: Option, + dash: Option, + gap: Option, ) -> Self { Stroke { fill: Fill::Solid(SolidColor(skia::Color::TRANSPARENT)), @@ -96,6 +106,8 @@ impl Stroke { cap_end, cap_start, kind: StrokeKind::Inner, + dash, + gap, } } @@ -104,6 +116,8 @@ impl Stroke { style: StrokeStyle, cap_start: Option, cap_end: Option, + dash: Option, + gap: Option, ) -> Self { Stroke { fill: Fill::Solid(SolidColor(skia::Color::TRANSPARENT)), @@ -112,11 +126,19 @@ impl Stroke { cap_end, cap_start, kind: StrokeKind::Outer, + dash, + gap, } } pub fn scale_content(&mut self, value: f32) { self.width *= value; + if let Some(dash) = self.dash { + self.dash = Some(dash * value); + } + if let Some(gap) = self.gap { + self.gap = Some(gap * value); + } } /// Returns the clip operation for dotted inner/outer strokes. @@ -256,7 +278,9 @@ impl Stroke { ) } StrokeStyle::Dashed => { - skia::PathEffect::dash(&[self.width + 10., self.width + 10.], 0.) + let dash = self.dash.unwrap_or(self.width + 10.); + let gap = self.gap.unwrap_or(self.width + 10.); + skia::PathEffect::dash(&[dash, gap], 0.) } StrokeStyle::Mixed => skia::PathEffect::dash( &[ @@ -293,6 +317,10 @@ impl Stroke { } } + if let Some(cap) = self.to_skia_linecap() { + paint.set_stroke_cap(cap); + } + paint } @@ -330,6 +358,19 @@ impl Stroke { cap_margin_for_cap(self.cap_start, self.width) .max(cap_margin_for_cap(self.cap_end, self.width)) } + + /// Returns a Skia `PaintCap` to apply natively on the stroke paint when + /// both ends share the same simple line cap (`Round/Round` or + /// `Square/Square`). Skia only emits cap geometry at sub-path endpoints, + /// so this is a no-op on closed paths and avoids the extra fill draw the + /// manual caps would otherwise require on open paths. + pub fn to_skia_linecap(&self) -> Option { + match (self.cap_start, self.cap_end) { + (Some(StrokeCap::Round), Some(StrokeCap::Round)) => Some(skia::paint::Cap::Round), + (Some(StrokeCap::Square), Some(StrokeCap::Square)) => Some(skia::paint::Cap::Square), + _ => None, + } + } } fn align_rect_to_half_pixel(rect: &Rect, stroke_width: f32, scale: f32) -> Rect { diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 541747229a..c5dec42f4a 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -26,7 +26,6 @@ use crate::math::Point; use crate::shapes::{self, merge_fills, Shape, VerticalAlign}; use crate::utils::{get_fallback_fonts, get_font_collection}; use crate::Uuid; -use crate::STATE; // TODO: maybe move this to the wasm module? pub type ParagraphBuilderGroup = Vec; @@ -199,6 +198,12 @@ pub struct TextContentLayout { cached_extrect: Cell>, } +impl Default for TextContentLayout { + fn default() -> Self { + Self::new() + } +} + impl Clone for TextContentLayout { fn clone(&self) -> Self { Self { @@ -328,13 +333,26 @@ pub fn calculate_normalized_line_height( normalized_line_height } -#[derive(Debug, PartialEq, Clone)] +#[derive(Debug, Clone)] pub struct TextContent { pub paragraphs: Vec, pub bounds: Rect, pub grow_type: GrowType, pub size: TextContentSize, pub layout: TextContentLayout, + content_version: u64, + layout_version: u64, + layout_width: Option, +} + +impl PartialEq for TextContent { + fn eq(&self, other: &Self) -> bool { + self.paragraphs == other.paragraphs + && self.bounds == other.bounds + && self.grow_type == other.grow_type + && self.size == other.size + && self.layout == other.layout + } } impl TextContent { @@ -345,6 +363,9 @@ impl TextContent { grow_type, size: TextContentSize::default(), layout: TextContentLayout::new(), + content_version: 0, + layout_version: 0, + layout_width: None, } } @@ -357,6 +378,9 @@ impl TextContent { grow_type, size: TextContentSize::new_with_size(bounds.width(), bounds.height()), layout: TextContentLayout::new(), + content_version: 0, + layout_version: 0, + layout_width: None, } } @@ -380,6 +404,7 @@ impl TextContent { pub fn add_paragraph(&mut self, paragraph: Paragraph) { self.paragraphs.push(paragraph); + self.content_version = self.content_version.wrapping_add(1); } pub fn paragraphs(&self) -> &[Paragraph] { @@ -387,6 +412,7 @@ impl TextContent { } pub fn paragraphs_mut(&mut self) -> &mut Vec { + self.content_version = self.content_version.wrapping_add(1); &mut self.paragraphs } @@ -403,7 +429,10 @@ impl TextContent { } pub fn set_grow_type(&mut self, grow_type: GrowType) { - self.grow_type = grow_type; + if self.grow_type != grow_type { + self.grow_type = grow_type; + self.content_version = self.content_version.wrapping_add(1); + } } /// Compute a tight text rect from laid-out Skia paragraphs using glyph @@ -886,19 +915,31 @@ impl TextContent { } pub fn update_layout(&mut self, selrect: Rect) -> TextContentSize { + if !self.layout.needs_update() + && self.layout_version == self.content_version + && self + .layout_width + .is_some_and(|w| (w - selrect.width()).abs() < f32::EPSILON) + { + return self.size; + } + self.size.set_size(selrect.width(), selrect.height()); match self.grow_type() { GrowType::AutoHeight => { let result = self.text_layout_auto_height(); + self.layout_width = Some(result.2.width); self.set_layout_from_result(result, selrect.width(), selrect.height()); } GrowType::AutoWidth => { let result = self.text_layout_auto_width(); + self.layout_width = Some(result.2.width); self.set_layout_from_result(result, selrect.width(), selrect.height()); } GrowType::Fixed => { let result = self.text_layout_fixed(); + self.layout_width = Some(result.2.width); self.set_layout_from_result(result, selrect.width(), selrect.height()); } } @@ -910,6 +951,7 @@ impl TextContent { self.size.max_width = placeholder_width; } + self.layout_version = self.content_version; self.size } @@ -1043,6 +1085,9 @@ impl Default for TextContent { grow_type: GrowType::Fixed, size: TextContentSize::default(), layout: TextContentLayout::new(), + content_version: 0, + layout_version: 0, + layout_width: None, } } } @@ -1424,11 +1469,14 @@ pub fn calculate_text_layout_data( let mut previous_line_height = text_content.normalized_line_height(); let text_paragraphs = text_content.paragraphs(); - // 1. Calculate paragraph heights + // 1. Build + layout each paragraph once, recording heights as we go. let mut paragraph_heights: Vec = Vec::new(); + let mut built_groups: Vec> = + Vec::with_capacity(paragraph_builder_groups.len()); for paragraph_builder_group in paragraph_builder_groups.iter_mut() { let group_len = paragraph_builder_group.len(); let mut paragraph_offset_y = previous_line_height; + let mut group_paragraphs: Vec = Vec::with_capacity(group_len); for (builder_index, paragraph_builder) in paragraph_builder_group.iter_mut().enumerate() { let mut skia_paragraph = paragraph_builder.build(); skia_paragraph.layout(text_width); @@ -1442,11 +1490,13 @@ pub fn calculate_text_layout_data( if builder_index == 0 { paragraph_heights.push(skia_paragraph.height()); } + group_paragraphs.push(skia_paragraph); } previous_line_height = paragraph_offset_y; + built_groups.push(group_paragraphs); } - // 2. Calculate vertical offset and build paragraphs with positions + // 2. Position each built paragraph using the heights from step 1. let total_text_height: f32 = paragraph_heights.iter().sum(); let vertical_offset = match shape.vertical_align() { VerticalAlign::Center => (selrect_height - total_text_height) / 2.0, @@ -1455,12 +1505,9 @@ pub fn calculate_text_layout_data( }; let mut paragraph_layouts: Vec = Vec::new(); let mut y_accum = base_y + vertical_offset; - for (i, paragraph_builder_group) in paragraph_builder_groups.iter_mut().enumerate() { + for (i, group_paragraphs) in built_groups.into_iter().enumerate() { // For each paragraph in the group (e.g., fill, stroke, etc.) - for paragraph_builder in paragraph_builder_group.iter_mut() { - let mut skia_paragraph = paragraph_builder.build(); - skia_paragraph.layout(text_width); - + for skia_paragraph in group_paragraphs.into_iter() { let spans = if let Some(text_para) = text_paragraphs.get(i) { text_para.children().to_vec() } else { diff --git a/render-wasm/src/shapes/text_paths.rs b/render-wasm/src/shapes/text_paths.rs index 238207152b..38cf30226f 100644 --- a/render-wasm/src/shapes/text_paths.rs +++ b/render-wasm/src/shapes/text_paths.rs @@ -1,11 +1,10 @@ +use crate::get_render_state; use crate::shapes::text::TextContent; use skia_safe::{ self as skia, textlayout::Paragraph as SkiaParagraph, FontMetrics, Point, Rect, TextBlob, }; use std::ops::Deref; -use crate::{with_state_mut, STATE}; - pub struct TextPaths(TextContent); // Note: This class is not being currently used. @@ -173,20 +172,18 @@ impl TextPaths { blob_offset_x: f32, blob_offset_y: f32, ) -> Option<(skia::Path, skia::Rect)> { - with_state_mut!(state, { - let utf16_text = span_text.encode_utf16().collect::>(); - let text = unsafe { skia_safe::as_utf16_unchecked(&utf16_text) }; - let emoji_font = state.render_state.fonts().get_emoji_font(font.size()); - let use_font = emoji_font.as_ref().unwrap_or(font); + let utf16_text = span_text.encode_utf16().collect::>(); + let text = unsafe { skia_safe::as_utf16_unchecked(&utf16_text) }; + let emoji_font = get_render_state().fonts().get_emoji_font(font.size()); + let use_font = emoji_font.as_ref().unwrap_or(font); - if let Some(mut text_blob) = TextBlob::from_text(text, use_font) { - let path = SkiaParagraph::get_path(&mut text_blob); - let d = Point::new(blob_offset_x, blob_offset_y); - let offset_path = path.with_offset(d); - let bounds = text_blob.bounds(); - return Some((offset_path, *bounds)); - } - }); + if let Some(mut text_blob) = TextBlob::from_text(text, use_font) { + let path = SkiaParagraph::get_path(&mut text_blob); + let d = Point::new(blob_offset_x, blob_offset_y); + let offset_path = path.with_offset(d); + let bounds = text_blob.bounds(); + return Some((offset_path, *bounds)); + } None } } diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index c624dad43d..116e7a67c4 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -7,12 +7,9 @@ pub use shapes_pool::{ShapesPool, ShapesPoolMutRef, ShapesPoolRef}; pub use text_editor::*; use crate::error::{Error, Result}; -use crate::render::RenderState; -use crate::shapes::Shape; -use crate::tiles; +use crate::shapes::{grid_layout::grid_cell_data, Shape}; use crate::uuid::Uuid; - -use crate::shapes::modifiers::grid_layout::grid_cell_data; +use crate::{get_render_state, tiles}; /// This struct holds the state of the Rust application between JS calls. /// @@ -20,8 +17,6 @@ use crate::shapes::modifiers::grid_layout::grid_cell_data; /// Note that rust-skia data structures are not thread safe, so a state /// must not be shared between different Web Workers. pub(crate) struct State { - pub render_state: RenderState, - pub text_editor_state: TextEditorState, pub current_id: Option, pub current_browser: u8, pub shapes: ShapesPool, @@ -31,76 +26,50 @@ pub(crate) struct State { } impl State { - pub fn try_new(width: i32, height: i32) -> Result { - Ok(State { - render_state: RenderState::try_new(width, height)?, - text_editor_state: TextEditorState::new(), + pub fn new() -> Self { + Self { current_id: None, current_browser: 0, shapes: ShapesPool::new(), saved_shapes: None, loading: false, - }) + } } // Creates a new temporary shapes pool. // Will panic if a previous temporary pool exists. - pub fn start_temp_objects(mut self) -> Result { + pub fn start_temp_objects(&mut self) -> Result<()> { if self.saved_shapes.is_some() { return Err(Error::CriticalError( "Tried to start a temp objects while the previous have not been restored" .to_string(), )); } - self.saved_shapes = Some(self.shapes); + self.saved_shapes = Some(self.shapes.clone()); self.shapes = ShapesPool::new(); - Ok(self) + Ok(()) } // Disposes of the temporary shapes pool restoring the normal pool // Will panic if a there is no temporary pool. - pub fn end_temp_objects(mut self) -> Result { - self.shapes = self.saved_shapes.ok_or(Error::CriticalError( + pub fn end_temp_objects(&mut self) -> Result<()> { + self.shapes = self.saved_shapes.clone().ok_or(Error::CriticalError( "Tried to end temp objects but not content to be restored is present".to_string(), ))?; self.saved_shapes = None; - Ok(self) - } - - pub fn resize(&mut self, width: i32, height: i32) -> Result<()> { - self.render_state.resize(width, height) - } - - pub fn render_state_mut(&mut self) -> &mut RenderState { - &mut self.render_state - } - - pub fn render_state(&self) -> &RenderState { - &self.render_state - } - - #[allow(dead_code)] - pub fn text_editor_state_mut(&mut self) -> &mut TextEditorState { - &mut self.text_editor_state - } - - #[allow(dead_code)] - pub fn text_editor_state(&self) -> &TextEditorState { - &self.text_editor_state + Ok(()) } pub fn render_from_cache(&mut self) { - self.render_state.render_from_cache(&self.shapes); + get_render_state().render_from_cache(&self.shapes); } pub fn render_sync(&mut self, timestamp: i32) -> Result<()> { - self.render_state - .start_render_loop(None, &self.shapes, timestamp, true) + get_render_state().start_render_loop(None, &self.shapes, timestamp, true) } pub fn render_sync_shape(&mut self, id: &Uuid, timestamp: i32) -> Result<()> { - self.render_state - .start_render_loop(Some(id), &self.shapes, timestamp, true) + get_render_state().start_render_loop(Some(id), &self.shapes, timestamp, true) } pub fn render_shape_pixels( @@ -109,36 +78,34 @@ impl State { scale: f32, timestamp: i32, ) -> Result<(Vec, i32, i32)> { - self.render_state - .render_shape_pixels(id, &self.shapes, scale, timestamp) + get_render_state().render_shape_pixels(id, &self.shapes, scale, timestamp) } pub fn start_render_loop(&mut self, timestamp: i32) -> Result<()> { + let render_state = get_render_state(); // If zoom changed (e.g. interrupted zoom render followed by pan), the // tile index may be stale for the new viewport position. Rebuild the // index so shapes are mapped to the correct tiles. We use // rebuild_tile_index (NOT rebuild_tiles_shallow) to preserve the tile // texture cache — otherwise cached tiles with shadows/blur would be // cleared and re-rendered in fast mode without effects. - if self.render_state.zoom_changed() { - self.render_state.rebuild_tile_index(&self.shapes); + if render_state.zoom_changed() { + render_state.rebuild_tile_index(&self.shapes); } - self.render_state - .start_render_loop(None, &self.shapes, timestamp, false) + render_state.start_render_loop(None, &self.shapes, timestamp, false) } pub fn process_animation_frame(&mut self, timestamp: i32) -> Result<()> { - self.render_state - .process_animation_frame(None, &self.shapes, timestamp) + get_render_state().process_animation_frame(None, &self.shapes, timestamp) } pub fn clear_focus_mode(&mut self) { - self.render_state.clear_focus_mode(); + get_render_state().clear_focus_mode(); } pub fn set_focus_mode(&mut self, shapes: Vec) { - self.render_state.set_focus_mode(shapes); + get_render_state().set_focus_mode(shapes); } pub fn init_shapes_pool(&mut self, capacity: usize) { @@ -153,6 +120,8 @@ impl State { } pub fn delete_shape_children(&mut self, parent_id: Uuid, id: Uuid) { + let render_state = get_render_state(); + // We don't really do a self.shapes.remove so that redo/undo keep working let Some(shape) = self.shapes.get(&id) else { return; @@ -168,16 +137,15 @@ impl State { // // Instead, remove the shape from *all* tiles where it was indexed, and // drop cached tiles for those entries. - let indexed_tiles: Vec = self - .render_state + let indexed_tiles: Vec = render_state .tiles .get_tiles_of(shape.id) .map(|t| t.iter().copied().collect()) .unwrap_or_default(); for tile in indexed_tiles { - self.render_state.remove_cached_tile(tile); - self.render_state.tiles.remove_shape_at(tile, shape.id); + render_state.remove_cached_tile(tile); + render_state.tiles.remove_shape_at(tile, shape.id); } if let Some(shape_to_delete) = self.shapes.get(&id) { @@ -186,8 +154,8 @@ impl State { if let Some(shape_to_delete) = self.shapes.get_mut(&shape_id) { shape_to_delete.set_deleted(true); } - if self.render_state.show_grid == Some(shape_id) { - self.render_state.show_grid = None; + if render_state.show_grid == Some(shape_id) { + render_state.show_grid = None; } } } @@ -203,7 +171,7 @@ impl State { } pub fn set_background_color(&mut self, color: skia::Color) { - self.render_state.set_background_color(color); + get_render_state().set_background_color(color); } pub fn set_browser(&mut self, browser: u8) { @@ -238,33 +206,31 @@ impl State { } pub fn rebuild_tiles_shallow(&mut self) { - self.render_state.rebuild_tiles_shallow(&self.shapes); + get_render_state().rebuild_tiles_shallow(&self.shapes); } pub fn rebuild_tiles(&mut self) { - self.render_state.rebuild_tiles_from(&self.shapes, None); + get_render_state().rebuild_tiles_from(&self.shapes, None); } pub fn rebuild_tiles_from(&mut self, base_id: Option<&Uuid>) { - self.render_state.rebuild_tiles_from(&self.shapes, base_id); + get_render_state().rebuild_tiles_from(&self.shapes, base_id); } pub fn rebuild_touched_tiles(&mut self) { - self.render_state.rebuild_touched_tiles(&self.shapes); + get_render_state().rebuild_touched_tiles(&self.shapes); } pub fn render_preview(&mut self, timestamp: i32) { - let _ = self.render_state.render_preview(&self.shapes, timestamp); + let _ = get_render_state().render_preview(&self.shapes, timestamp); } - pub fn rebuild_modifier_tiles(&mut self, ids: Vec) -> Result<()> { - // Index-based storage is safe - self.render_state - .rebuild_modifier_tiles(&mut self.shapes, ids) + pub fn rebuild_modifier_tiles(&mut self, ids: &[Uuid]) -> Result<()> { + get_render_state().rebuild_modifier_tiles(&mut self.shapes, ids) } pub fn font_collection(&self) -> &FontCollection { - self.render_state.fonts().font_collection() + get_render_state().fonts().font_collection() } pub fn get_grid_coords(&self, pos_x: f32, pos_y: f32) -> Option<(i32, i32)> { @@ -297,16 +263,18 @@ impl State { } pub fn touch_current(&mut self) { + let render_state = get_render_state(); if !self.loading { if let Some(current_id) = self.current_id { - self.render_state.mark_touched(current_id); + render_state.mark_touched(current_id); } } } pub fn touch_shape(&mut self, id: Uuid) { + let render_state = get_render_state(); if !self.loading { - self.render_state.mark_touched(id); + render_state.mark_touched(id); } } } diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index 7e03befa01..f57bbcb51b 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::collections::VecDeque; use std::iter; use crate::performance; @@ -49,6 +50,11 @@ pub struct ShapesPoolImpl { modified_shape_cache: HashMap>, /// Transform modifiers, keyed by index modifiers: HashMap, + /// UUIDs of shapes that have an active transform modifier, kept in sync + /// with `modifiers`. Stored explicitly so that `modifier_ids()` is O(K) + /// (K = number of modified shapes) instead of O(N_shapes) — avoids + /// building a full reverse-index HashMap on every call. + modifier_uuids: Vec, /// Structure entries, keyed by index structure: HashMap>, /// Scale content values, keyed by index @@ -69,6 +75,7 @@ impl ShapesPoolImpl { modified_shape_cache: HashMap::default(), modifiers: HashMap::default(), + modifier_uuids: Vec::new(), structure: HashMap::default(), scale_content: HashMap::default(), } @@ -140,6 +147,18 @@ impl ShapesPoolImpl { Some(&mut self.shapes[idx]) } + /// Returns the current transform modifier matrix for the shape, if any. + pub fn get_modifier(&self, id: &Uuid) -> Option<&skia::Matrix> { + let idx = *self.uuid_to_idx.get(id)?; + self.modifiers.get(&idx) + } + + /// Get a shape by UUID without applying modifiers/structure/scale-content. + pub fn get_raw(&self, id: &Uuid) -> Option<&Shape> { + let idx = *self.uuid_to_idx.get(id)?; + Some(&self.shapes[idx]) + } + /// Get a shape by UUID. Returns the modified shape if modifiers/structure /// are applied, otherwise returns the base shape. pub fn get(&self, id: &Uuid) -> Option<&Shape> { @@ -173,6 +192,15 @@ impl ShapesPoolImpl { Some(shape) } } else { + if let Some(cell) = self.modified_shape_cache.get(&idx) { + return Some(cell.get_or_init(|| { + if let Some(m) = self.find_nearest_ancestor_modifier(idx) { + shape.transformed(Some(&m), None) + } else { + shape.clone() + } + })); + } Some(shape) } } @@ -212,9 +240,6 @@ impl ShapesPoolImpl { } pub fn set_modifiers(&mut self, modifiers: HashMap) { - // Convert HashMap to HashMap using indices - // Initialize the cache cells for affected shapes - let mut ids = Vec::::new(); let mut modifiers_with_idx = HashMap::with_capacity(modifiers.len()); @@ -224,9 +249,48 @@ impl ShapesPoolImpl { ids.push(uuid); } } + + // Expand every root modifier to its full descendant subtree. + // When CLJS sends only root shapes (translation on drag), descendants + // need the same matrix. + // For resize/rotate, propagate-modifiers already includes all descendants. + // Descendants are NOT pushed into `ids` / `modifier_uuids`: tile invalidation + // via rebuild_modifier_tiles only runs for roots, which is sufficient because + // descendants always lie inside the parent's bounding box and are therefore + // covered by the parent's old/new tile ranges. + let root_pairs: Vec<(usize, skia::Matrix)> = ids + .iter() + .filter_map(|uuid| { + let idx = self.uuid_to_idx.get(uuid).copied()?; + let matrix = modifiers_with_idx.get(&idx).copied()?; + Some((idx, matrix)) + }) + .collect(); + + let mut descendants_idxs: Vec = Vec::new(); + for (root_idx, matrix) in root_pairs { + for descendant_idx in self.collect_all_descendants(root_idx) { + if let std::collections::hash_map::Entry::Vacant(e) = + modifiers_with_idx.entry(descendant_idx) + { + e.insert(matrix); + descendants_idxs.push(descendant_idx); + } + } + } + self.modifiers = modifiers_with_idx; + for descendant_idx in descendants_idxs { + self.modified_shape_cache + .insert(descendant_idx, OnceCell::new()); + } + + // Compute ancestors before consuming `ids` so we can move it into + // `modifier_uuids` without a clone. let all_ids = shapes::all_with_ancestors(&ids, self, true); + // rebuild_modifier_tiles doesn't process every descendant individually. + self.modifier_uuids = ids; for uuid in all_ids { if let Some(idx) = self.uuid_to_idx.get(&uuid).copied() { self.modified_shape_cache.insert(idx, OnceCell::new()); @@ -288,19 +352,9 @@ impl ShapesPoolImpl { pub fn clean_all(&mut self) -> Vec { self.clean_shape_cache(); - let modified_uuids: Vec = if self.modifiers.is_empty() { - Vec::new() - } else { - let mut idx_to_uuid: HashMap = - HashMap::with_capacity(self.uuid_to_idx.len()); - for (uuid, idx) in self.uuid_to_idx.iter() { - idx_to_uuid.insert(*idx, *uuid); - } - self.modifiers - .keys() - .filter_map(|idx| idx_to_uuid.get(idx).copied()) - .collect() - }; + // `modifier_uuids` is kept in sync with `modifiers` by `set_modifiers`, + // so we can take it directly — no need to rebuild a reverse index. + let modified_uuids = std::mem::take(&mut self.modifier_uuids); self.modifiers = HashMap::default(); self.structure = HashMap::default(); @@ -309,6 +363,18 @@ impl ShapesPoolImpl { modified_uuids } + /// UUIDs of all shapes that currently have a transform modifier. + /// Used by the throttled drag path so per-rAF tile invalidation can + /// be done once with the current modifier set instead of once per + /// pointer move. + /// + /// Returns a reference to avoid allocation on every call — callers + /// inside hot render loops should hold this reference rather than + /// calling `modifier_ids()` repeatedly. + pub fn modifier_ids(&self) -> &[Uuid] { + &self.modifier_uuids + } + pub fn subtree(&self, id: &Uuid) -> ShapesPoolImpl { let Some(shape) = self.get(id) else { panic!("Subtree not found"); @@ -333,11 +399,47 @@ impl ShapesPoolImpl { uuid_to_idx, modified_shape_cache: HashMap::default(), modifiers: HashMap::default(), + modifier_uuids: Vec::new(), structure: HashMap::default(), scale_content: HashMap::default(), } } + fn collect_all_descendants(&self, idx: usize) -> Vec { + let mut result = Vec::new(); + let mut queue: VecDeque<&Uuid> = VecDeque::new(); + let shape = &self.shapes[idx]; + for child_id in shape.children_ids_iter(false) { + queue.push_back(child_id); + } + while let Some(child_id) = queue.pop_front() { + if let Some(&child_idx) = self.uuid_to_idx.get(child_id) { + result.push(child_idx); + let child_shape = &self.shapes[child_idx]; + for grandchild_id in child_shape.children_ids_iter(false) { + queue.push_back(grandchild_id); + } + } + } + result + } + + fn find_nearest_ancestor_modifier(&self, idx: usize) -> Option { + let mut current_idx = idx; + loop { + let shape = &self.shapes[current_idx]; + let parent_id = shape.parent_id?; + if parent_id == Uuid::nil() { + return None; + } + let &parent_idx = self.uuid_to_idx.get(&parent_id)?; + if let Some(matrix) = self.modifiers.get(&parent_idx) { + return Some(*matrix); + } + current_idx = parent_idx; + } + } + fn to_update_bool(&self, shape: &Shape) -> bool { if !shape.is_bool() { return false; @@ -362,3 +464,26 @@ impl ShapesPoolImpl { }) } } + +impl Default for ShapesPoolImpl { + fn default() -> Self { + Self::new() + } +} + +impl Clone for ShapesPoolImpl { + fn clone(&self) -> Self { + ShapesPoolImpl { + shapes: self.shapes.clone(), + counter: self.counter, + uuid_to_idx: self.uuid_to_idx.clone(), + // The modified_shape_cache is a derived/computed cache; reset it on clone + // so it gets lazily rebuilt on demand rather than cloning OnceCell state. + modified_shape_cache: HashMap::default(), + modifiers: self.modifiers.clone(), + modifier_uuids: self.modifier_uuids.clone(), + structure: self.structure.clone(), + scale_content: self.scale_content.clone(), + } + } +} diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index 6bba86b3a3..974c1c74ce 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -101,7 +101,8 @@ pub enum TextEditorEvent { /// FIXME: It should be better to get these constants from the frontend through the API. const SELECTION_COLOR: Color = Color::from_argb(127, 0, 209, 184); const CURSOR_COLOR: Color = Color::BLACK; -const CURSOR_BLINK_INTERVAL_MS: f64 = 530.0; +const CURSOR_WIDTH: f32 = 1.0; +const CURSOR_BLINK_INTERVAL_MS: f32 = 530.0; #[derive(Debug)] pub struct TextEditorStyles { @@ -254,9 +255,16 @@ impl TextEditorStyles { } } +impl Default for TextEditorStyles { + fn default() -> Self { + Self::new() + } +} + pub struct TextEditorTheme { pub selection_color: Color, pub cursor_color: Color, + pub cursor_width: f32, } pub struct TextComposition { @@ -317,6 +325,12 @@ impl TextComposition { } } +impl Default for TextComposition { + fn default() -> Self { + Self::new() + } +} + pub struct TextEditorState { pub theme: TextEditorTheme, pub selection: TextSelection, @@ -326,9 +340,10 @@ pub struct TextEditorState { // selecting something with the pointer. pub is_pointer_selection_active: bool, pub is_click_event_skipped: bool, + pub is_overtype_mode: bool, pub active_shape_id: Option, pub cursor_visible: bool, - pub last_blink_time: f64, + pub last_blink_time_ms: f32, pub current_styles: TextEditorStyles, pending_events: Vec, } @@ -339,15 +354,17 @@ impl TextEditorState { theme: TextEditorTheme { selection_color: SELECTION_COLOR, cursor_color: CURSOR_COLOR, + cursor_width: CURSOR_WIDTH, }, selection: TextSelection::new(), composition: TextComposition::new(), has_focus: false, is_pointer_selection_active: false, is_click_event_skipped: false, + is_overtype_mode: false, active_shape_id: None, cursor_visible: true, - last_blink_time: 0.0, + last_blink_time_ms: 0.0, pending_events: Vec::new(), current_styles: TextEditorStyles::new(), } @@ -357,9 +374,10 @@ impl TextEditorState { self.has_focus = true; self.active_shape_id = Some(shape_id); self.cursor_visible = true; - self.last_blink_time = 0.0; + self.last_blink_time_ms = 0.0; self.selection.reset(); self.is_pointer_selection_active = false; + self.is_overtype_mode = false; self.pending_events.clear(); } @@ -367,9 +385,10 @@ impl TextEditorState { self.has_focus = false; // self.active_shape_id = None; self.cursor_visible = false; - self.last_blink_time = 0.0; + self.last_blink_time_ms = 0.0; // self.selection.reset(); self.is_pointer_selection_active = false; + self.is_overtype_mode = false; self.pending_events.clear(); } @@ -377,9 +396,10 @@ impl TextEditorState { self.has_focus = false; self.active_shape_id = None; self.cursor_visible = false; - self.last_blink_time = 0.0; + self.last_blink_time_ms = 0.0; self.selection.reset(); self.is_pointer_selection_active = false; + self.is_overtype_mode = false; self.pending_events.clear(); } @@ -516,6 +536,14 @@ impl TextEditorState { self.push_event(TextEditorEvent::SelectionChanged); } + pub fn set_overtype_mode(&mut self, overtype_mode: bool) { + self.is_overtype_mode = overtype_mode; + } + + pub fn toggle_overtype_mode(&mut self) { + self.set_overtype_mode(!self.is_overtype_mode); + } + fn update_styles_from_selection(&mut self, text_content: &TextContent) -> bool { let paragraphs = text_content.paragraphs(); if paragraphs.is_empty() { @@ -687,27 +715,27 @@ impl TextEditorState { styles_were_updated } - pub fn update_blink(&mut self, timestamp_ms: f64) { + pub fn update_blink(&mut self, timestamp_ms: f32) { if !self.has_focus { return; } - if self.last_blink_time == 0.0 { - self.last_blink_time = timestamp_ms; + if self.last_blink_time_ms == 0.0 { + self.last_blink_time_ms = timestamp_ms; self.cursor_visible = true; return; } - let elapsed = timestamp_ms - self.last_blink_time; + let elapsed = timestamp_ms - self.last_blink_time_ms; if elapsed >= CURSOR_BLINK_INTERVAL_MS { self.cursor_visible = !self.cursor_visible; - self.last_blink_time = timestamp_ms; + self.last_blink_time_ms = timestamp_ms; } } pub fn reset_blink(&mut self) { self.cursor_visible = true; - self.last_blink_time = 0.0; + self.last_blink_time_ms = 0.0; } pub fn push_event(&mut self, event: TextEditorEvent) { @@ -864,6 +892,12 @@ impl TextEditorState { } } +impl Default for TextEditorState { + fn default() -> Self { + Self::new() + } +} + fn is_word_char(c: char) -> bool { c.is_alphanumeric() || c == '_' } diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index 13ed4c1aeb..7c078ad2e6 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -22,43 +22,95 @@ impl Tile { pub struct TileRect(pub i32, pub i32, pub i32, pub i32); impl TileRect { + pub fn empty() -> Self { + Self(0, 0, 0, 0) + } + + #[inline] pub fn x1(&self) -> i32 { self.0 } + #[inline] pub fn y1(&self) -> i32 { self.1 } + #[inline] pub fn x2(&self) -> i32 { self.2 } + #[inline] pub fn y2(&self) -> i32 { self.3 } + #[inline] + pub fn left(&self) -> i32 { + self.0 + } + + #[inline] + pub fn top(&self) -> i32 { + self.1 + } + + #[inline] + pub fn right(&self) -> i32 { + self.2 + } + + #[inline] + pub fn bottom(&self) -> i32 { + self.3 + } + + #[inline] + pub fn x(&self) -> i32 { + self.0 + } + + #[inline] + pub fn y(&self) -> i32 { + self.1 + } + + #[inline] pub fn width(&self) -> i32 { self.x2() - self.x1() } + #[inline] + pub fn half_width(&self) -> i32 { + self.width() / 2 + } + + #[inline] pub fn height(&self) -> i32 { self.y2() - self.y1() } - pub fn center_x(&self) -> i32 { - self.x1() + self.width() / 2 + #[inline] + pub fn half_height(&self) -> i32 { + self.height() / 2 } + #[inline] + pub fn center_x(&self) -> i32 { + self.x() + self.half_width() + } + + #[inline] pub fn center_y(&self) -> i32 { - self.y1() + self.height() / 2 + self.y() + self.half_height() } pub fn contains(&self, tile: &Tile) -> bool { - tile.x() >= self.x1() - && tile.y() >= self.y1() - && tile.x() <= self.x2() - && tile.y() <= self.y2() + tile.x() >= self.left() + && tile.y() >= self.top() + && tile.x() <= self.right() + && tile.y() <= self.bottom() } } @@ -86,6 +138,10 @@ impl TileViewbox { self.center = get_tile_center_for_viewbox(viewbox, scale); } + pub fn set_interest(&mut self, interest: i32) { + self.interest = interest; + } + pub fn is_visible(&self, tile: &Tile) -> bool { // TO CHECK self.interest_rect.contains(tile) self.visible_rect.contains(tile) @@ -191,33 +247,31 @@ impl TileHashMap { } const VIEWPORT_DEFAULT_CAPACITY: usize = 24 * 12; +const VIEWPORT_SPIRAL_DEFAULT_CAPACITY: usize = 64; // This structure keeps the list of tiles that are in the pending list, the // ones that are going to be rendered. pub struct PendingTiles { pub list: Vec, + pub spiral: Vec, + pub spiral_rect: TileRect, } impl PendingTiles { - pub fn new_empty() -> Self { + pub fn new() -> Self { Self { list: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), + spiral: Vec::with_capacity(VIEWPORT_SPIRAL_DEFAULT_CAPACITY), + spiral_rect: TileRect::empty(), } } // Generate tiles in spiral order from center - fn generate_spiral(rect: &TileRect) -> Vec { - let columns = rect.width(); - let rows = rect.height(); + fn generate_spiral(columns: usize, rows: usize) -> Vec { let total = columns * rows; - - if total <= 0 { - return Vec::new(); - } - - let mut result = Vec::with_capacity(total as usize); - let mut cx = rect.center_x(); - let mut cy = rect.center_y(); + let mut result = Vec::with_capacity(total); + let mut cx = 0; + let mut cy = 0; let ratio = (columns as f32 / rows as f32).ceil() as i32; @@ -225,10 +279,9 @@ impl PendingTiles { let mut direction_total_x = ratio; let mut direction_total_y = 1; let mut direction = 0; - let mut current = 0; result.push(Tile(cx, cy)); - while current < total { + while result.len() < total { match direction { 0 => cx += 1, 1 => cy += 1, @@ -255,17 +308,36 @@ impl PendingTiles { direction = (direction + 1) % 4; direction_current = 0; } - current += 1; } result.reverse(); result } - pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces) { + pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces, only_visible: bool) { self.list.clear(); - // Generate spiral for the interest area (viewport + margin) - let spiral = Self::generate_spiral(&tile_viewbox.interest_rect); + // During interactive transform, skip the interest-area ring + // entirely — the user is dragging, every rAF is on the critical + // path, and pre-rendering tiles outside the viewport is wasted + // work that just gets evicted on the next pointer move. The ring + // is repopulated naturally on gesture end / on idle rAFs. + let spiral_rect = if only_visible { + &tile_viewbox.visible_rect + } else { + &tile_viewbox.interest_rect + }; + + self.spiral_rect = *spiral_rect; + + // We do not regenerate spiral if the spiral_rect + // doesn't change. The spiral_rect is based on the + // viewbox so, if the viewbox doesn't change + // the spiral should not change. + let total = (spiral_rect.width() * spiral_rect.height()) as usize; + if self.spiral.len() < total { + self.spiral = + Self::generate_spiral(spiral_rect.width() as usize, spiral_rect.height() as usize); + } // Partition tiles into 4 priority groups (highest priority = processed last due to pop()): // 1. visible + cached (fastest - just blit from cache) @@ -277,7 +349,9 @@ impl PendingTiles { let mut interest_cached = Vec::new(); let mut interest_uncached = Vec::new(); - for tile in spiral { + let center_tile = Tile(spiral_rect.center_x(), spiral_rect.center_y()); + for spiral_tile in self.spiral.iter() { + let tile = Tile(spiral_tile.0 + center_tile.0, spiral_tile.1 + center_tile.1); let is_visible = tile_viewbox.visible_rect.contains(&tile); let is_cached = surfaces.has_cached_tile_surface(tile); diff --git a/render-wasm/src/utils.rs b/render-wasm/src/utils.rs index 63a031d761..206b91dbb0 100644 --- a/render-wasm/src/utils.rs +++ b/render-wasm/src/utils.rs @@ -1,8 +1,8 @@ +use crate::get_render_state; use crate::skia::textlayout::FontCollection; use crate::skia::Image; use crate::uuid::Uuid; -use crate::with_state_mut; -use crate::STATE; +use crate::with_state; use std::collections::HashSet; pub fn uuid_from_u32_quartet(a: u32, b: u32, c: u32, d: u32) -> Uuid { @@ -25,16 +25,16 @@ pub fn uuid_from_u32(id: [u32; 4]) -> Uuid { } pub fn get_image(image_id: &Uuid) -> Option<&Image> { - with_state_mut!(state, { state.render_state_mut().images.get(image_id) }) + get_render_state().images.get(image_id) } // FIXME: move to a different place ? pub fn get_fallback_fonts() -> &'static HashSet { - with_state_mut!(state, { state.render_state().fonts().get_fallback() }) + get_render_state().fonts().get_fallback() } pub fn get_font_collection() -> &'static FontCollection { - with_state_mut!(state, { state.font_collection() }) + with_state!(state, { state.font_collection() }) } #[derive(Debug, Clone, Copy)] diff --git a/render-wasm/src/wasm/blend.rs b/render-wasm/src/wasm/blend.rs index d0dcbad118..832547cecb 100644 --- a/render-wasm/src/wasm/blend.rs +++ b/render-wasm/src/wasm/blend.rs @@ -2,7 +2,7 @@ use macros::ToJs; use skia_safe as skia; use crate::shapes::BlendMode; -use crate::{with_current_shape_mut, STATE}; +use crate::with_current_shape_mut; #[derive(Debug, PartialEq, Clone, Copy, ToJs)] #[repr(u8)] diff --git a/render-wasm/src/wasm/blurs.rs b/render-wasm/src/wasm/blurs.rs index e8e1a4242c..022439ab44 100644 --- a/render-wasm/src/wasm/blurs.rs +++ b/render-wasm/src/wasm/blurs.rs @@ -1,7 +1,7 @@ use macros::ToJs; use crate::shapes::{Blur, BlurType}; -use crate::{with_current_shape_mut, STATE}; +use crate::with_current_shape_mut; #[derive(Debug, Clone, Copy, PartialEq, ToJs)] #[repr(u8)] diff --git a/render-wasm/src/wasm/fills.rs b/render-wasm/src/wasm/fills.rs index 19fc49e575..2e652b11b1 100644 --- a/render-wasm/src/wasm/fills.rs +++ b/render-wasm/src/wasm/fills.rs @@ -3,7 +3,6 @@ use macros::{wasm_error, ToJs}; use crate::mem; use crate::shapes; use crate::with_current_shape_mut; -use crate::STATE; mod gradient; mod image; diff --git a/render-wasm/src/wasm/fills/image.rs b/render-wasm/src/wasm/fills/image.rs index b122de4cc8..65a4697181 100644 --- a/render-wasm/src/wasm/fills/image.rs +++ b/render-wasm/src/wasm/fills/image.rs @@ -1,11 +1,34 @@ use crate::error::{Error, Result}; +use crate::get_render_state; use crate::mem; +use crate::shapes::Fill; +use crate::state::State; use crate::uuid::Uuid; -use crate::with_state_mut; -use crate::STATE; +use crate::with_state; use crate::{shapes::ImageFill, utils::uuid_from_u32_quartet}; use macros::wasm_error; +fn touch_shapes_with_image(state: &mut State, image_id: Uuid) { + let ids: Vec = state + .shapes + .iter() + .filter(|shape| { + shape + .fills() + .any(|f| matches!(f, Fill::Image(i) if i.id() == image_id)) + || shape + .strokes + .iter() + .any(|s| matches!(&s.fill, Fill::Image(i) if i.id() == image_id)) + }) + .map(|shape| shape.id) + .collect(); + + for id in ids { + state.touch_shape(id); + } +} + const FLAG_KEEP_ASPECT_RATIO: u8 = 1 << 0; const IMAGE_IDS_SIZE: usize = 32; const IMAGE_HEADER_SIZE: usize = 36; // 32 bytes for IDs + 4 bytes for is_thumbnail flag @@ -47,22 +70,20 @@ pub struct ShapeImageIds { image_id: Uuid, } -impl From<[u8; IMAGE_IDS_SIZE]> for ShapeImageIds { - fn from(bytes: [u8; IMAGE_IDS_SIZE]) -> Self { - // FIXME: this should probably be a try_from instead - let shape_id = Uuid::try_from(&bytes[0..16]).unwrap(); - let image_id = Uuid::try_from(&bytes[16..32]).unwrap(); - ShapeImageIds { shape_id, image_id } - } -} - -impl TryFrom> for ShapeImageIds { +impl TryFrom<&[u8]> for ShapeImageIds { type Error = Error; - fn try_from(value: Vec) -> Result { - let mut arr = [0u8; IMAGE_IDS_SIZE]; - arr.copy_from_slice(&value); - Ok(ShapeImageIds::from(arr)) + fn try_from(bytes: &[u8]) -> Result { + if bytes.len() < IMAGE_IDS_SIZE { + return Err(Error::CriticalError(format!( + "Invalid image ids byte length: expected at least {}, got {}", + IMAGE_IDS_SIZE, + bytes.len() + ))); + } + let shape_id = Uuid::try_from(&bytes[0..16]).map_err(Error::CriticalError)?; + let image_id = Uuid::try_from(&bytes[16..32]).map_err(Error::CriticalError)?; + Ok(ShapeImageIds { shape_id, image_id }) } } @@ -70,7 +91,7 @@ impl TryFrom> for ShapeImageIds { #[wasm_error] pub extern "C" fn store_image() -> Result<()> { let bytes = mem::bytes(); - let ids = ShapeImageIds::try_from(bytes[0..IMAGE_IDS_SIZE].to_vec())?; + let ids = ShapeImageIds::try_from(&bytes[0..IMAGE_IDS_SIZE])?; // Read is_thumbnail flag (4 bytes as u32) let is_thumbnail_bytes = &bytes[IMAGE_IDS_SIZE..IMAGE_HEADER_SIZE]; @@ -82,15 +103,11 @@ pub extern "C" fn store_image() -> Result<()> { let image_bytes = &bytes[IMAGE_HEADER_SIZE..]; - with_state_mut!(state, { - if let Err(msg) = - state - .render_state_mut() - .add_image(ids.image_id, is_thumbnail, image_bytes) - { + with_state!(state, { + if let Err(msg) = get_render_state().add_image(ids.image_id, is_thumbnail, image_bytes) { eprintln!("{}", msg); } - state.touch_shape(ids.shape_id); + touch_shapes_with_image(state, ids.image_id); }); mem::free_bytes()?; @@ -120,7 +137,7 @@ pub extern "C" fn store_image_from_texture() -> Result<()> { )); } - let ids = ShapeImageIds::try_from(bytes[0..IMAGE_IDS_SIZE].to_vec()) + let ids = ShapeImageIds::try_from(&bytes[0..IMAGE_IDS_SIZE]) .map_err(|_| Error::CriticalError("Invalid image ids".to_string()))?; // FIXME: read bytes in a safe way @@ -156,8 +173,8 @@ pub extern "C" fn store_image_from_texture() -> Result<()> { .map_err(|_| Error::CriticalError("Invalid bytes for height".to_string()))?, ); - with_state_mut!(state, { - if let Err(msg) = state.render_state_mut().add_image_from_gl_texture( + with_state!(state, { + if let Err(msg) = get_render_state().add_image_from_gl_texture( ids.image_id, is_thumbnail, texture_id, @@ -167,7 +184,7 @@ pub extern "C" fn store_image_from_texture() -> Result<()> { // FIXME: Review if we should return a RecoverableError eprintln!("store_image_from_texture error: {}", msg); } - state.touch_shape(ids.shape_id); + touch_shapes_with_image(state, ids.image_id); }); mem::free_bytes()?; diff --git a/render-wasm/src/wasm/fonts.rs b/render-wasm/src/wasm/fonts.rs index f4723e20c3..21c4e6a797 100644 --- a/render-wasm/src/wasm/fonts.rs +++ b/render-wasm/src/wasm/fonts.rs @@ -1,10 +1,9 @@ use macros::{wasm_error, ToJs}; +use crate::get_render_state; use crate::mem; use crate::shapes::{FontFamily, FontStyle}; use crate::utils::uuid_from_u32_quartet; -use crate::with_state_mut; -use crate::STATE; #[derive(Debug, PartialEq, Clone, Copy, ToJs)] #[repr(u8)] @@ -41,20 +40,16 @@ pub extern "C" fn store_font( is_emoji: bool, is_fallback: bool, ) -> Result<()> { - with_state_mut!(state, { - let id = uuid_from_u32_quartet(a, b, c, d); - let font_bytes = mem::bytes(); - let font_style = RawFontStyle::from(style); + let id = uuid_from_u32_quartet(a, b, c, d); + let font_bytes = mem::bytes(); + let font_style = RawFontStyle::from(style); - let family = FontFamily::new(id, weight, font_style.into()); - let _ = - state - .render_state_mut() - .fonts_mut() - .add(family, &font_bytes, is_emoji, is_fallback); + let family = FontFamily::new(id, weight, font_style.into()); + let _ = get_render_state() + .fonts_mut() + .add(family, &font_bytes, is_emoji, is_fallback); - mem::free_bytes()?; - }); + mem::free_bytes()?; Ok(()) } @@ -68,12 +63,10 @@ pub extern "C" fn is_font_uploaded( style: u8, is_emoji: bool, ) -> bool { - with_state_mut!(state, { - let id = uuid_from_u32_quartet(a, b, c, d); - let font_style = RawFontStyle::from(style); - let family = FontFamily::new(id, weight, font_style.into()); - let res = state.render_state().fonts().has_family(&family, is_emoji); + let id = uuid_from_u32_quartet(a, b, c, d); + let font_style = RawFontStyle::from(style); + let family = FontFamily::new(id, weight, font_style.into()); + let res = get_render_state().fonts().has_family(&family, is_emoji); - res - }) + res } diff --git a/render-wasm/src/wasm/layouts.rs b/render-wasm/src/wasm/layouts.rs index 81ea59240d..d179188e51 100644 --- a/render-wasm/src/wasm/layouts.rs +++ b/render-wasm/src/wasm/layouts.rs @@ -1,5 +1,5 @@ use crate::shapes::Sizing; -use crate::{with_current_shape_mut, STATE}; +use crate::with_current_shape_mut; use macros::ToJs; mod align; diff --git a/render-wasm/src/wasm/layouts/align.rs b/render-wasm/src/wasm/layouts/align.rs index 42810588d9..75ce0816cb 100644 --- a/render-wasm/src/wasm/layouts/align.rs +++ b/render-wasm/src/wasm/layouts/align.rs @@ -3,7 +3,7 @@ use macros::ToJs; use crate::shapes::{ AlignContent, AlignItems, AlignSelf, JustifyContent, JustifyItems, JustifySelf, VerticalAlign, }; -use crate::{with_current_shape_mut, STATE}; +use crate::with_current_shape_mut; #[derive(Debug, Clone, PartialEq, Copy, ToJs)] #[repr(u8)] diff --git a/render-wasm/src/wasm/layouts/constraints.rs b/render-wasm/src/wasm/layouts/constraints.rs index 057e739241..8760ed5592 100644 --- a/render-wasm/src/wasm/layouts/constraints.rs +++ b/render-wasm/src/wasm/layouts/constraints.rs @@ -1,7 +1,7 @@ use macros::ToJs; use crate::shapes::{ConstraintH, ConstraintV}; -use crate::{with_current_shape_mut, STATE}; +use crate::with_current_shape_mut; #[derive(Debug, Clone, PartialEq, Copy, ToJs)] #[repr(u8)] diff --git a/render-wasm/src/wasm/layouts/flex.rs b/render-wasm/src/wasm/layouts/flex.rs index 10d1f991e8..2935165fcf 100644 --- a/render-wasm/src/wasm/layouts/flex.rs +++ b/render-wasm/src/wasm/layouts/flex.rs @@ -1,5 +1,5 @@ use crate::shapes::{FlexDirection, WrapType}; -use crate::{with_current_shape_mut, STATE}; +use crate::with_current_shape_mut; use macros::ToJs; use super::align; diff --git a/render-wasm/src/wasm/layouts/grid.rs b/render-wasm/src/wasm/layouts/grid.rs index aac2fd1928..15284250c2 100644 --- a/render-wasm/src/wasm/layouts/grid.rs +++ b/render-wasm/src/wasm/layouts/grid.rs @@ -1,9 +1,10 @@ use macros::{wasm_error, ToJs}; +use crate::get_render_state; use crate::mem; use crate::shapes::{GridCell, GridDirection, GridTrack, GridTrackType}; use crate::uuid::Uuid; -use crate::{uuid_from_u32_quartet, with_current_shape_mut, with_state, with_state_mut, STATE}; +use crate::{uuid_from_u32_quartet, with_current_shape_mut, with_state}; use super::align; @@ -241,17 +242,13 @@ pub extern "C" fn set_grid_cells() -> Result<()> { #[no_mangle] pub extern "C" fn show_grid(a: u32, b: u32, c: u32, d: u32) { - with_state_mut!(state, { - let id = uuid_from_u32_quartet(a, b, c, d); - state.render_state.show_grid = Some(id); - }); + let id = uuid_from_u32_quartet(a, b, c, d); + get_render_state().show_grid = Some(id); } #[no_mangle] pub extern "C" fn hide_grid() { - with_state_mut!(state, { - state.render_state.show_grid = None; - }); + get_render_state().show_grid = None; } #[no_mangle] diff --git a/render-wasm/src/wasm/mem.rs b/render-wasm/src/wasm/mem.rs index 8f6b7508ef..4cff8d0cd2 100644 --- a/render-wasm/src/wasm/mem.rs +++ b/render-wasm/src/wasm/mem.rs @@ -9,15 +9,22 @@ use macros::wasm_error; #[no_mangle] #[wasm_error] pub extern "C" fn alloc_bytes(len: usize) -> Result<*mut u8> { - let mut guard = BUFFERU8 - .lock() - .map_err(|_| Error::CriticalError("Failed to lock buffer".to_string()))?; - - if guard.is_some() { - return Err(Error::CriticalError("Bytes already allocated".to_string())); - } - unsafe { + // If we don't put this allow, the compiler shows a warning like this: + // + // shared references to mutable statics are dangerous; it's undefined behavior + // if the static is mutated or if a mutable reference is created for it while + // the shared reference lives + // + // https://doc.rust-lang.org/edition-guide/rust-2024/static-mut-references.html + // + // But this isn't a problem in a single-threaded environment like WebAssembly + // because access/modification is always sequential, not parallel. + #[allow(static_mut_refs)] + if BUFFERU8.is_some() { + return Err(Error::CriticalError("Bytes already allocated".to_string())); + } + let layout = Layout::from_size_align_unchecked(len, LAYOUT_ALIGN); let ptr = alloc(layout); if ptr.is_null() { @@ -25,7 +32,7 @@ pub extern "C" fn alloc_bytes(len: usize) -> Result<*mut u8> { } // TODO: Maybe this could be removed. ptr::write_bytes(ptr, 0, len); - *guard = Some(Vec::from_raw_parts(ptr, len, len)); + BUFFERU8 = Some(Vec::from_raw_parts(ptr, len, len)); Ok(ptr) } } diff --git a/render-wasm/src/wasm/paths.rs b/render-wasm/src/wasm/paths.rs index 7690a9001f..730bb6fe85 100644 --- a/render-wasm/src/wasm/paths.rs +++ b/render-wasm/src/wasm/paths.rs @@ -6,7 +6,7 @@ use std::sync::{Mutex, OnceLock}; use crate::error::{Error, Result}; use crate::shapes::{stroke_to_path, Path, Segment, ToPath}; -use crate::{mem, with_current_shape, with_current_shape_mut, STATE}; +use crate::{mem, with_current_shape, with_current_shape_mut}; const RAW_SEGMENT_DATA_SIZE: usize = size_of::(); diff --git a/render-wasm/src/wasm/paths/bools.rs b/render-wasm/src/wasm/paths/bools.rs index 0e6636e2f2..3858641f0a 100644 --- a/render-wasm/src/wasm/paths/bools.rs +++ b/render-wasm/src/wasm/paths/bools.rs @@ -5,7 +5,7 @@ use crate::math; use crate::shapes::BoolType; use crate::uuid::Uuid; use crate::{mem, SerializableResult}; -use crate::{with_current_shape_mut, with_state, STATE}; +use crate::{with_current_shape_mut, with_state}; use std::mem::size_of; #[allow(unused_imports)] diff --git a/render-wasm/src/wasm/shadows.rs b/render-wasm/src/wasm/shadows.rs index c7a95aacfe..57ecae2613 100644 --- a/render-wasm/src/wasm/shadows.rs +++ b/render-wasm/src/wasm/shadows.rs @@ -2,7 +2,7 @@ use macros::ToJs; use skia_safe as skia; use crate::shapes::{Shadow, ShadowStyle}; -use crate::{with_current_shape_mut, STATE}; +use crate::with_current_shape_mut; #[derive(Debug, Clone, Copy, PartialEq, ToJs)] #[repr(u8)] diff --git a/render-wasm/src/wasm/shapes/base_props.rs b/render-wasm/src/wasm/shapes/base_props.rs index 5e0146f276..e9b6a6e7b0 100644 --- a/render-wasm/src/wasm/shapes/base_props.rs +++ b/render-wasm/src/wasm/shapes/base_props.rs @@ -4,7 +4,7 @@ use crate::utils::uuid_from_u32_quartet; use crate::uuid::Uuid; use crate::wasm::blend::RawBlendMode; use crate::wasm::layouts::constraints::{RawConstraintH, RawConstraintV}; -use crate::{with_state_mut, STATE}; +use crate::with_state; #[allow(unused_imports)] use crate::error::{Error, Result}; @@ -128,7 +128,7 @@ pub extern "C" fn set_shape_base_props() -> Result<()> { let parent_id = raw.parent_id(); let shape_type = RawShapeType::from(raw.shape_type); - with_state_mut!(state, { + with_state!(state, { state.use_shape(id); state.set_parent_for_current_shape(parent_id); state.touch_current(); diff --git a/render-wasm/src/wasm/shapes/mod.rs b/render-wasm/src/wasm/shapes/mod.rs index 3f32e824c3..901feb57f8 100644 --- a/render-wasm/src/wasm/shapes/mod.rs +++ b/render-wasm/src/wasm/shapes/mod.rs @@ -3,7 +3,7 @@ mod base_props; use macros::ToJs; use crate::shapes::{Bool, Frame, Group, Path, Rect, SVGRaw, TextContent, Type}; -use crate::{with_current_shape_mut, STATE}; +use crate::with_current_shape_mut; #[derive(Debug, Clone, PartialEq, ToJs)] #[repr(u8)] diff --git a/render-wasm/src/wasm/strokes.rs b/render-wasm/src/wasm/strokes.rs index 21a7e321ff..1c14ac1077 100644 --- a/render-wasm/src/wasm/strokes.rs +++ b/render-wasm/src/wasm/strokes.rs @@ -3,7 +3,6 @@ use macros::ToJs; use crate::mem; use crate::shapes::{self, StrokeCap, StrokeStyle}; use crate::with_current_shape_mut; -use crate::STATE; #[derive(Debug, Clone, PartialEq, Copy, ToJs)] #[repr(u8)] @@ -69,8 +68,26 @@ impl TryFrom for StrokeCap { } } +// A negative value means "unset" — the renderer falls back to its default +// dash pattern. We use a sentinel instead of passing a bool because adding +// two f32 params keeps the FFI signature flat and allocation-free. +fn decode_optional(value: f32) -> Option { + if value.is_finite() && value >= 0.0 { + Some(value) + } else { + None + } +} + #[no_mangle] -pub extern "C" fn add_shape_center_stroke(width: f32, style: u8, cap_start: u8, cap_end: u8) { +pub extern "C" fn add_shape_center_stroke( + width: f32, + style: u8, + cap_start: u8, + cap_end: u8, + dash: f32, + gap: f32, +) { let stroke_style = RawStrokeStyle::from(style); let cap_start = RawStrokeCap::from(cap_start); let cap_end = RawStrokeCap::from(cap_end); @@ -81,12 +98,21 @@ pub extern "C" fn add_shape_center_stroke(width: f32, style: u8, cap_start: u8, stroke_style.into(), cap_start.try_into().ok(), cap_end.try_into().ok(), + decode_optional(dash), + decode_optional(gap), )); }); } #[no_mangle] -pub extern "C" fn add_shape_inner_stroke(width: f32, style: u8, cap_start: u8, cap_end: u8) { +pub extern "C" fn add_shape_inner_stroke( + width: f32, + style: u8, + cap_start: u8, + cap_end: u8, + dash: f32, + gap: f32, +) { let stroke_style = RawStrokeStyle::from(style); let cap_start = RawStrokeCap::from(cap_start); let cap_end = RawStrokeCap::from(cap_end); @@ -97,12 +123,21 @@ pub extern "C" fn add_shape_inner_stroke(width: f32, style: u8, cap_start: u8, c stroke_style.into(), cap_start.try_into().ok(), cap_end.try_into().ok(), + decode_optional(dash), + decode_optional(gap), )); }); } #[no_mangle] -pub extern "C" fn add_shape_outer_stroke(width: f32, style: u8, cap_start: u8, cap_end: u8) { +pub extern "C" fn add_shape_outer_stroke( + width: f32, + style: u8, + cap_start: u8, + cap_end: u8, + dash: f32, + gap: f32, +) { let stroke_style = RawStrokeStyle::from(style); let cap_start = RawStrokeCap::from(cap_start); let cap_end = RawStrokeCap::from(cap_end); @@ -113,6 +148,8 @@ pub extern "C" fn add_shape_outer_stroke(width: f32, style: u8, cap_start: u8, c stroke_style.into(), cap_start.try_into().ok(), cap_end.try_into().ok(), + decode_optional(dash), + decode_optional(gap), )); }); } diff --git a/render-wasm/src/wasm/svg_attrs.rs b/render-wasm/src/wasm/svg_attrs.rs index 73c146eb2c..ac4e7c376a 100644 --- a/render-wasm/src/wasm/svg_attrs.rs +++ b/render-wasm/src/wasm/svg_attrs.rs @@ -1,7 +1,7 @@ use macros::ToJs; use crate::shapes::{FillRule, StrokeLineCap, StrokeLineJoin, SvgAttrs}; -use crate::{with_current_shape_mut, STATE}; +use crate::with_current_shape_mut; #[derive(PartialEq, ToJs)] #[repr(u8)] diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index 88726de91e..c97ec81276 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -7,7 +7,7 @@ use crate::shapes::{ self, GrowType, Shape, TextAlign, TextDecoration, TextDirection, TextTransform, Type, }; use crate::utils::{uuid_from_u32, uuid_from_u32_quartet}; -use crate::{with_current_shape, with_current_shape_mut, with_state, with_state_mut, STATE}; +use crate::{with_current_shape, with_current_shape_mut, with_state}; use crate::error::Error; @@ -386,7 +386,7 @@ pub extern "C" fn update_shape_text_layout() { #[no_mangle] pub extern "C" fn update_shape_text_layout_for(a: u32, b: u32, c: u32, d: u32) { - with_state_mut!(state, { + with_state!(state, { let shape_id = uuid_from_u32_quartet(a, b, c, d); if let Some(shape) = state.shapes.get_mut(&shape_id) { update_text_layout(shape); diff --git a/render-wasm/src/wasm/text/helpers.rs b/render-wasm/src/wasm/text/helpers.rs index b0c57c5288..67044b4788 100644 --- a/render-wasm/src/wasm/text/helpers.rs +++ b/render-wasm/src/wasm/text/helpers.rs @@ -276,6 +276,43 @@ pub fn find_text_span_at_offset(para: &Paragraph, char_offset: usize) -> Option< None } +pub fn replace_text_with_newlines( + text_content: &mut TextContent, + cursor: &TextPositionWithAffinity, + text: &str, +) -> Option { + let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); + let lines: Vec<&str> = normalized.split('\n').collect(); + if lines.is_empty() { + return None; + } + + let mut current_cursor = *cursor; + + if let Some(new_offset) = replace_text_at_cursor(text_content, ¤t_cursor, lines[0]) { + current_cursor = + TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph, new_offset); + } else { + return None; + } + + for line in lines.iter().skip(1) { + if !split_paragraph_at_cursor(text_content, ¤t_cursor) { + break; + } + current_cursor = + TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph + 1, 0); + if let Some(new_offset) = replace_text_at_cursor(text_content, ¤t_cursor, line) { + current_cursor = TextPositionWithAffinity::new_without_affinity( + current_cursor.paragraph, + new_offset, + ); + } + } + + Some(current_cursor) +} + /// Insert text at a cursor position, splitting on newlines into multiple paragraphs. /// Returns the final cursor position after insertion. pub fn insert_text_with_newlines( @@ -356,6 +393,58 @@ pub fn insert_text_at_cursor( Some(cursor.offset + text.chars().count()) } +/// Replace text at cursor position (overtype mode). Replaces N characters where N is the +/// length of the input text, returning the new cursor offset. +pub fn replace_text_at_cursor( + text_content: &mut TextContent, + cursor: &TextPositionWithAffinity, + text: &str, +) -> Option { + let text_len = text.chars().count(); + if text_len == 0 { + return Some(cursor.offset); + } + + let paragraphs = text_content.paragraphs_mut(); + if cursor.paragraph >= paragraphs.len() { + return None; + } + + let para = &mut paragraphs[cursor.paragraph]; + let children = para.children_mut(); + if children.is_empty() { + return None; + } + + if children.len() == 1 && children[0].text.is_empty() { + children[0].set_text(text.to_string()); + return Some(text_len); + } + + let (span_idx, offset_in_span) = find_text_span_at_offset(para, cursor.offset)?; + + let children = para.children_mut(); + let span = &mut children[span_idx]; + let mut new_text = span.text.clone(); + + let byte_offset = new_text + .char_indices() + .nth(offset_in_span) + .map(|(i, _)| i) + .unwrap_or(new_text.len()); + + let end_byte_offset = new_text + .char_indices() + .nth(offset_in_span + text_len) + .map(|(i, _)| i) + .unwrap_or(new_text.len()); + + new_text.replace_range(byte_offset..end_byte_offset, text); + span.set_text(new_text); + + Some(cursor.offset + text_len) +} + /// Delete a range of text specified by a selection. pub fn delete_selection_range(text_content: &mut TextContent, selection: &TextSelection) { let start = selection.start(); diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 9db9910ee2..6fc4a3e20a 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -1,5 +1,6 @@ use macros::{wasm_error, ToJs}; +use crate::globals::{get_render_state, get_text_editor_state}; use crate::math::{Matrix, Point, Rect}; use crate::mem; use crate::render::text_editor as text_editor_render; @@ -12,7 +13,7 @@ use crate::wasm::fills::RawFillData; use crate::wasm::text::{ helpers as text_helpers, RawTextAlign, RawTextDecoration, RawTextDirection, RawTextTransform, }; -use crate::{with_state, with_state_mut, STATE}; +use crate::with_state; use skia_safe::Color; #[derive(PartialEq, ToJs)] @@ -33,17 +34,15 @@ pub enum CursorDirection { #[no_mangle] pub extern "C" fn text_editor_apply_theme(selection_color: u32, cursor_color: u32) { - with_state_mut!(state, { - // NOTE: In the future could be interesting to fill al this data from - // a structure pointer. - state.text_editor_state.theme.selection_color = Color::new(selection_color); - state.text_editor_state.theme.cursor_color = Color::new(cursor_color); - }) + // NOTE: In the future could be interesting to fill al this data from + // a structure pointer. + get_text_editor_state().theme.selection_color = Color::new(selection_color); + get_text_editor_state().theme.cursor_color = Color::new(cursor_color); } #[no_mangle] pub extern "C" fn text_editor_focus(a: u32, b: u32, c: u32, d: u32) -> bool { - with_state_mut!(state, { + with_state!(state, { let shape_id = uuid_from_u32_quartet(a, b, c, d); let Some(shape) = state.shapes.get(&shape_id) else { @@ -54,74 +53,66 @@ pub extern "C" fn text_editor_focus(a: u32, b: u32, c: u32, d: u32) -> bool { return false; } - state.text_editor_state.focus(shape_id); + get_text_editor_state().focus(shape_id); true }) } #[no_mangle] pub extern "C" fn text_editor_blur() -> bool { - with_state_mut!(state, { - if !state.text_editor_state.has_focus { - return false; - } - state.text_editor_state.blur(); - true - }) + if !get_text_editor_state().has_focus { + return false; + } + get_text_editor_state().blur(); + true } #[no_mangle] pub extern "C" fn text_editor_dispose() -> bool { - with_state_mut!(state, { - state.text_editor_state.dispose(); - true - }) + get_text_editor_state().dispose(); + true } #[no_mangle] pub extern "C" fn text_editor_has_selection() -> bool { - with_state!(state, { state.text_editor_state.selection.is_selection() }) + get_text_editor_state().selection.is_selection() } #[no_mangle] pub extern "C" fn text_editor_has_focus() -> bool { - with_state!(state, { state.text_editor_state.has_focus }) + get_text_editor_state().has_focus } #[no_mangle] pub extern "C" fn text_editor_has_focus_with_id(a: u32, b: u32, c: u32, d: u32) -> bool { - with_state!(state, { - let shape_id = uuid_from_u32_quartet(a, b, c, d); - let Some(active_shape_id) = state.text_editor_state.active_shape_id else { - return false; - }; - state.text_editor_state.has_focus && active_shape_id == shape_id - }) + let shape_id = uuid_from_u32_quartet(a, b, c, d); + let Some(active_shape_id) = get_text_editor_state().active_shape_id else { + return false; + }; + get_text_editor_state().has_focus && active_shape_id == shape_id } #[no_mangle] pub extern "C" fn text_editor_get_active_shape_id(buffer_ptr: *mut u32) { - with_state!(state, { - if let Some(shape_id) = state.text_editor_state.active_shape_id { - let (a, b, c, d) = uuid_to_u32_quartet(&shape_id); - unsafe { - *buffer_ptr = a; - *buffer_ptr.add(1) = b; - *buffer_ptr.add(2) = c; - *buffer_ptr.add(3) = d; - } + if let Some(shape_id) = get_text_editor_state().active_shape_id { + let (a, b, c, d) = uuid_to_u32_quartet(&shape_id); + unsafe { + *buffer_ptr = a; + *buffer_ptr.add(1) = b; + *buffer_ptr.add(2) = c; + *buffer_ptr.add(3) = d; } - }) + } } #[no_mangle] pub extern "C" fn text_editor_select_all() -> bool { - with_state_mut!(state, { - if !state.text_editor_state.has_focus { + with_state!(state, { + if !get_text_editor_state().has_focus { return false; } - let Some(shape_id) = state.text_editor_state.active_shape_id else { + let Some(shape_id) = get_text_editor_state().active_shape_id else { return false; }; @@ -132,18 +123,18 @@ pub extern "C" fn text_editor_select_all() -> bool { let Type::Text(text_content) = &shape.shape_type else { return false; }; - state.text_editor_state.select_all(text_content) + get_text_editor_state().select_all(text_content) }) } #[no_mangle] pub extern "C" fn text_editor_select_word_boundary(x: f32, y: f32) { - with_state_mut!(state, { - if !state.text_editor_state.has_focus { + with_state!(state, { + if !get_text_editor_state().has_focus { return; } - let Some(shape_id) = state.text_editor_state.active_shape_id else { + let Some(shape_id) = get_text_editor_state().active_shape_id else { return; }; @@ -157,16 +148,14 @@ pub extern "C" fn text_editor_select_word_boundary(x: f32, y: f32) { let point = Point::new(x, y); if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { - state - .text_editor_state - .select_word_boundary(text_content, &position); + get_text_editor_state().select_word_boundary(text_content, &position); } }) } #[no_mangle] pub extern "C" fn text_editor_poll_event() -> u8 { - with_state_mut!(state, { state.text_editor_state.poll_event() as u8 }) + get_text_editor_state().poll_event() as u8 } // ============================================================================ @@ -175,11 +164,11 @@ pub extern "C" fn text_editor_poll_event() -> u8 { #[no_mangle] pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) { - with_state_mut!(state, { - if !state.text_editor_state.has_focus { + with_state!(state, { + if !get_text_editor_state().has_focus { return; } - let Some(shape_id) = state.text_editor_state.active_shape_id else { + let Some(shape_id) = get_text_editor_state().active_shape_id else { return; }; let Some(shape) = state.shapes.get(&shape_id) else { @@ -189,23 +178,23 @@ pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) { return; }; let point = Point::new(x, y); - state.text_editor_state.start_pointer_selection(); + get_text_editor_state().start_pointer_selection(); if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { - state.text_editor_state.set_caret_from_position(&position); - state.text_editor_state.update_styles(text_content); + get_text_editor_state().set_caret_from_position(&position); + get_text_editor_state().update_styles(text_content); } }); } #[no_mangle] pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { - with_state_mut!(state, { - if !state.text_editor_state.has_focus { + with_state!(state, { + if !get_text_editor_state().has_focus { return; } let point = Point::new(x, y); - let Some(shape_id) = state.text_editor_state.active_shape_id else { + let Some(shape_id) = get_text_editor_state().active_shape_id else { return; }; @@ -213,7 +202,7 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { return; }; - if !state.text_editor_state.is_pointer_selection_active { + if !get_text_editor_state().is_pointer_selection_active { return; } @@ -222,62 +211,58 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { }; if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { - state - .text_editor_state - .extend_selection_from_position(&position); + get_text_editor_state().extend_selection_from_position(&position); // We need this flag to prevent handling the click behavior // just after a pointerup event. - state.text_editor_state.is_click_event_skipped = true; - state.text_editor_state.update_styles(text_content); + get_text_editor_state().is_click_event_skipped = true; + get_text_editor_state().update_styles(text_content); } }); } #[no_mangle] pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) { - with_state_mut!(state, { - if !state.text_editor_state.has_focus { + with_state!(state, { + if !get_text_editor_state().has_focus { return; } let point = Point::new(x, y); - let Some(shape_id) = state.text_editor_state.active_shape_id else { + let Some(shape_id) = get_text_editor_state().active_shape_id else { return; }; let Some(shape) = state.shapes.get(&shape_id) else { return; }; - if !state.text_editor_state.is_pointer_selection_active { + if !get_text_editor_state().is_pointer_selection_active { return; } let Type::Text(text_content) = &shape.shape_type else { return; }; if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { - state - .text_editor_state - .extend_selection_from_position(&position); - state.text_editor_state.update_styles(text_content); + get_text_editor_state().extend_selection_from_position(&position); + get_text_editor_state().update_styles(text_content); } - state.text_editor_state.stop_pointer_selection(); + get_text_editor_state().stop_pointer_selection(); }); } #[no_mangle] pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) { - with_state_mut!(state, { + with_state!(state, { // We need this flag to prevent handling the click behavior // just after a pointerup event. - if state.text_editor_state.is_click_event_skipped { - state.text_editor_state.is_click_event_skipped = false; + if get_text_editor_state().is_click_event_skipped { + get_text_editor_state().is_click_event_skipped = false; return; } - if !state.text_editor_state.has_focus { + if !get_text_editor_state().has_focus { return; } let point = Point::new(x, y); - let Some(shape_id) = state.text_editor_state.active_shape_id else { + let Some(shape_id) = get_text_editor_state().active_shape_id else { return; }; @@ -290,21 +275,21 @@ pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) { }; if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { - state.text_editor_state.set_caret_from_position(&position); + get_text_editor_state().set_caret_from_position(&position); } }); } #[no_mangle] pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) { - with_state_mut!(state, { - if !state.text_editor_state.has_focus { + with_state!(state, { + if !get_text_editor_state().has_focus { return; } - let view_matrix: Matrix = state.render_state.viewbox.get_matrix(); + let view_matrix: Matrix = get_render_state().viewbox.get_matrix(); let point = Point::new(x, y); - let Some(shape_id) = state.text_editor_state.active_shape_id else { + let Some(shape_id) = get_text_editor_state().active_shape_id else { return; }; let Some(shape) = state.shapes.get(&shape_id) else { @@ -317,7 +302,7 @@ pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) { if let Some(position) = text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix) { - state.text_editor_state.set_caret_from_position(&position); + get_text_editor_state().set_caret_from_position(&position); } }); } @@ -329,13 +314,10 @@ pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) { #[no_mangle] #[wasm_error] pub extern "C" fn text_editor_composition_start() -> Result<()> { - with_state_mut!(state, { - if !state.text_editor_state.has_focus { - return Ok(()); - } - state.text_editor_state.composition.start(); - }); - + if !get_text_editor_state().has_focus { + return Ok(()); + } + get_text_editor_state().composition.start(); Ok(()) } @@ -348,12 +330,12 @@ pub extern "C" fn text_editor_composition_end() -> Result<()> { Err(_) => return Ok(()), }; - with_state_mut!(state, { - if !state.text_editor_state.has_focus { + with_state!(state, { + if !get_text_editor_state().has_focus { return Ok(()); } - let Some(shape_id) = state.text_editor_state.active_shape_id else { + let Some(shape_id) = get_text_editor_state().active_shape_id else { return Ok(()); }; @@ -365,35 +347,30 @@ pub extern "C" fn text_editor_composition_end() -> Result<()> { return Ok(()); }; - state.text_editor_state.composition.update(&text); + get_text_editor_state().composition.update(&text); - let selection = state - .text_editor_state + let selection = get_text_editor_state() .composition - .get_selection(&state.text_editor_state.selection); + .get_selection(&get_text_editor_state().selection); text_helpers::delete_selection_range(text_content, &selection); - let cursor = state.text_editor_state.selection.focus; + let cursor = get_text_editor_state().selection.focus; if let Some(new_cursor) = text_helpers::insert_text_with_newlines(text_content, &cursor, &text) { - state.text_editor_state.selection.set_caret(new_cursor); + get_text_editor_state().selection.set_caret(new_cursor); } text_content.layout.paragraphs.clear(); text_content.layout.paragraph_builders.clear(); - state.text_editor_state.reset_blink(); - state - .text_editor_state - .push_event(crate::state::TextEditorEvent::ContentChanged); - state - .text_editor_state - .push_event(crate::state::TextEditorEvent::NeedsLayout); + get_text_editor_state().reset_blink(); + get_text_editor_state().push_event(crate::state::TextEditorEvent::ContentChanged); + get_text_editor_state().push_event(crate::state::TextEditorEvent::NeedsLayout); - state.render_state.mark_touched(shape_id); + get_render_state().mark_touched(shape_id); - state.text_editor_state.composition.end(); + get_text_editor_state().composition.end(); }); crate::mem::free_bytes()?; @@ -409,12 +386,12 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> { Err(_) => return Ok(()), }; - with_state_mut!(state, { - if !state.text_editor_state.has_focus { + with_state!(state, { + if !get_text_editor_state().has_focus { return Ok(()); } - let Some(shape_id) = state.text_editor_state.active_shape_id else { + let Some(shape_id) = get_text_editor_state().active_shape_id else { return Ok(()); }; @@ -426,35 +403,37 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> { return Ok(()); }; - state.text_editor_state.composition.update(&text); + get_text_editor_state().composition.update(&text); - let selection = state - .text_editor_state + let selection = get_text_editor_state() .composition - .get_selection(&state.text_editor_state.selection); + .get_selection(&get_text_editor_state().selection); text_helpers::delete_selection_range(text_content, &selection); - let cursor = state.text_editor_state.selection.focus; + let cursor = get_text_editor_state().selection.focus; text_helpers::insert_text_with_newlines(text_content, &cursor, &text); text_content.layout.paragraphs.clear(); text_content.layout.paragraph_builders.clear(); - state.text_editor_state.reset_blink(); - state - .text_editor_state - .push_event(crate::state::TextEditorEvent::ContentChanged); - state - .text_editor_state - .push_event(crate::state::TextEditorEvent::NeedsLayout); + get_text_editor_state().reset_blink(); + get_text_editor_state().push_event(crate::state::TextEditorEvent::ContentChanged); + get_text_editor_state().push_event(crate::state::TextEditorEvent::NeedsLayout); - state.render_state.mark_touched(shape_id); + get_render_state().mark_touched(shape_id); }); crate::mem::free_bytes()?; Ok(()) } +#[no_mangle] +#[wasm_error] +pub extern "C" fn text_editor_toggle_overtype_mode() -> Result<()> { + get_text_editor_state().toggle_overtype_mode(); + Ok(()) +} + // FIXME: Review if all the return Ok(()) should be Err instead. #[no_mangle] #[wasm_error] @@ -465,12 +444,12 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> { Err(_) => return Ok(()), }; - with_state_mut!(state, { - if !state.text_editor_state.has_focus { + with_state!(state, { + if !get_text_editor_state().has_focus { return Ok(()); } - let Some(shape_id) = state.text_editor_state.active_shape_id else { + let Some(shape_id) = get_text_editor_state().active_shape_id else { return Ok(()); }; @@ -482,34 +461,35 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> { return Ok(()); }; - let selection = state.text_editor_state.selection; + let selection = get_text_editor_state().selection; if selection.is_selection() { text_helpers::delete_selection_range(text_content, &selection); let start = selection.start(); - state.text_editor_state.selection.set_caret(start); + get_text_editor_state().selection.set_caret(start); } - let cursor = state.text_editor_state.selection.focus; - - if let Some(new_cursor) = - text_helpers::insert_text_with_newlines(text_content, &cursor, &text) + let cursor = get_text_editor_state().selection.focus; + if !get_text_editor_state().is_overtype_mode { + if let Some(new_cursor) = + text_helpers::insert_text_with_newlines(text_content, &cursor, &text) + { + get_text_editor_state().selection.set_caret(new_cursor); + } + } else if let Some(new_cursor) = + text_helpers::replace_text_with_newlines(text_content, &cursor, &text) { - state.text_editor_state.selection.set_caret(new_cursor); + get_text_editor_state().selection.set_caret(new_cursor); } text_content.layout.paragraphs.clear(); text_content.layout.paragraph_builders.clear(); - state.text_editor_state.reset_blink(); - state - .text_editor_state - .push_event(TextEditorEvent::ContentChanged); - state - .text_editor_state - .push_event(TextEditorEvent::NeedsLayout); + get_text_editor_state().reset_blink(); + get_text_editor_state().push_event(TextEditorEvent::ContentChanged); + get_text_editor_state().push_event(TextEditorEvent::NeedsLayout); - state.render_state.mark_touched(shape_id); + get_render_state().mark_touched(shape_id); }); crate::mem::free_bytes()?; @@ -518,12 +498,12 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> { #[no_mangle] pub extern "C" fn text_editor_delete_backward(word_boundary: bool) { - with_state_mut!(state, { - if !state.text_editor_state.has_focus { + with_state!(state, { + if !get_text_editor_state().has_focus { return; } - let Some(shape_id) = state.text_editor_state.active_shape_id else { + let Some(shape_id) = get_text_editor_state().active_shape_id else { return; }; @@ -535,21 +515,19 @@ pub extern "C" fn text_editor_delete_backward(word_boundary: bool) { return; }; - state - .text_editor_state - .delete_backward(text_content, word_boundary); - state.render_state.mark_touched(shape_id); + get_text_editor_state().delete_backward(text_content, word_boundary); + get_render_state().mark_touched(shape_id); }); } #[no_mangle] pub extern "C" fn text_editor_delete_forward(word_boundary: bool) { - with_state_mut!(state, { - if !state.text_editor_state.has_focus { + with_state!(state, { + if !get_text_editor_state().has_focus { return; } - let Some(shape_id) = state.text_editor_state.active_shape_id else { + let Some(shape_id) = get_text_editor_state().active_shape_id else { return; }; @@ -561,21 +539,19 @@ pub extern "C" fn text_editor_delete_forward(word_boundary: bool) { return; }; - state - .text_editor_state - .delete_forward(text_content, word_boundary); - state.render_state.mark_touched(shape_id); + get_text_editor_state().delete_forward(text_content, word_boundary); + get_render_state().mark_touched(shape_id); }); } #[no_mangle] pub extern "C" fn text_editor_insert_paragraph() { - with_state_mut!(state, { - if !state.text_editor_state.has_focus { + with_state!(state, { + if !get_text_editor_state().has_focus { return; } - let Some(shape_id) = state.text_editor_state.active_shape_id else { + let Some(shape_id) = get_text_editor_state().active_shape_id else { return; }; @@ -587,8 +563,8 @@ pub extern "C" fn text_editor_insert_paragraph() { return; }; - state.text_editor_state.insert_paragraph(text_content); - state.render_state.mark_touched(shape_id); + get_text_editor_state().insert_paragraph(text_content); + get_render_state().mark_touched(shape_id); }); } @@ -602,12 +578,12 @@ pub extern "C" fn text_editor_move_cursor( word_boundary: bool, extend_selection: bool, ) { - with_state_mut!(state, { - if !state.text_editor_state.has_focus { + with_state!(state, { + if !get_text_editor_state().has_focus { return; } - let Some(shape_id) = state.text_editor_state.active_shape_id else { + let Some(shape_id) = get_text_editor_state().active_shape_id else { return; }; @@ -619,7 +595,7 @@ pub extern "C" fn text_editor_move_cursor( return; }; - state.text_editor_state.move_cursor( + get_text_editor_state().move_cursor( text_content, direction, word_boundary, @@ -634,12 +610,12 @@ pub extern "C" fn text_editor_move_cursor( #[no_mangle] pub extern "C" fn text_editor_get_cursor_rect() -> *mut u8 { - with_state_mut!(state, { - if !state.text_editor_state.has_focus || !state.text_editor_state.cursor_visible { + with_state!(state, { + if !get_text_editor_state().has_focus || !get_text_editor_state().cursor_visible { return std::ptr::null_mut(); } - let Some(shape_id) = state.text_editor_state.active_shape_id else { + let Some(shape_id) = get_text_editor_state().active_shape_id else { return std::ptr::null_mut(); }; @@ -651,7 +627,7 @@ pub extern "C" fn text_editor_get_cursor_rect() -> *mut u8 { return std::ptr::null_mut(); }; - let cursor = &state.text_editor_state.selection.focus; + let cursor = &get_text_editor_state().selection.focus; if let Some(rect) = get_cursor_rect(text_content, cursor, shape) { let mut bytes = vec![0u8; 16]; @@ -668,12 +644,12 @@ pub extern "C" fn text_editor_get_cursor_rect() -> *mut u8 { #[no_mangle] pub extern "C" fn text_editor_get_current_styles() -> *mut u8 { - with_state_mut!(state, { - if !state.text_editor_state.has_focus { + with_state!(state, { + if !get_text_editor_state().has_focus { return std::ptr::null_mut(); } - let Some(shape_id) = state.text_editor_state.active_shape_id else { + let Some(shape_id) = get_text_editor_state().active_shape_id else { return std::ptr::null_mut(); }; @@ -685,7 +661,7 @@ pub extern "C" fn text_editor_get_current_styles() -> *mut u8 { return std::ptr::null_mut(); }; - let styles = &state.text_editor_state.current_styles; + let styles = &get_text_editor_state().current_styles; let vertical_align = match styles.vertical_align { VerticalAlign::Top => 0_u32, @@ -836,16 +812,16 @@ pub extern "C" fn text_editor_get_current_styles() -> *mut u8 { #[no_mangle] pub extern "C" fn text_editor_get_selection_rects() -> *mut u8 { - with_state_mut!(state, { - if !state.text_editor_state.has_focus { + with_state!(state, { + if !get_text_editor_state().has_focus { return std::ptr::null_mut(); } - if state.text_editor_state.selection.is_collapsed() { + if get_text_editor_state().selection.is_collapsed() { return std::ptr::null_mut(); } - let Some(shape_id) = state.text_editor_state.active_shape_id else { + let Some(shape_id) = get_text_editor_state().active_shape_id else { return std::ptr::null_mut(); }; @@ -857,7 +833,7 @@ pub extern "C" fn text_editor_get_selection_rects() -> *mut u8 { return std::ptr::null_mut(); }; - let selection = &state.text_editor_state.selection; + let selection = &get_text_editor_state().selection; let rects = get_selection_rects(text_content, selection, shape); if rects.is_empty() { return std::ptr::null_mut(); @@ -876,16 +852,14 @@ pub extern "C" fn text_editor_get_selection_rects() -> *mut u8 { } #[no_mangle] -pub extern "C" fn text_editor_update_blink(timestamp_ms: f64) { - with_state_mut!(state, { - state.text_editor_state.update_blink(timestamp_ms); - }); +pub extern "C" fn text_editor_update_blink(timestamp_ms: f32) { + get_text_editor_state().update_blink(timestamp_ms); } #[no_mangle] pub extern "C" fn text_editor_render_overlay() { - with_state_mut!(state, { - let Some(shape_id) = state.text_editor_state.active_shape_id else { + with_state!(state, { + let Some(shape_id) = get_text_editor_state().active_shape_id else { return; }; @@ -906,27 +880,27 @@ pub extern "C" fn text_editor_render_overlay() { return; }; - let canvas = state.render_state.surfaces.canvas(SurfaceId::Target); - let viewbox = state.render_state.viewbox; + let canvas = get_render_state().surfaces.canvas(SurfaceId::Target); + let viewbox = get_render_state().viewbox; text_editor_render::render_overlay( canvas, &viewbox, - &state.render_state.options, - &state.text_editor_state, + &get_render_state().options, + get_text_editor_state(), shape, ); - state.render_state.flush_and_submit(); + get_render_state().flush_and_submit(); }); } #[no_mangle] pub extern "C" fn text_editor_export_content() -> *mut u8 { with_state!(state, { - if !state.text_editor_state.has_focus { + if !get_text_editor_state().has_focus { return std::ptr::null_mut(); } - let Some(shape_id) = state.text_editor_state.active_shape_id else { + let Some(shape_id) = get_text_editor_state().active_shape_id else { return std::ptr::null_mut(); }; @@ -965,10 +939,10 @@ pub extern "C" fn text_editor_export_content() -> *mut u8 { pub extern "C" fn text_editor_export_selection() -> *mut u8 { use std::ptr; with_state!(state, { - if !state.text_editor_state.has_focus { + if !get_text_editor_state().has_focus { return ptr::null_mut(); } - let Some(shape_id) = state.text_editor_state.active_shape_id else { + let Some(shape_id) = get_text_editor_state().active_shape_id else { return ptr::null_mut(); }; let Some(shape) = state.shapes.get(&shape_id) else { @@ -977,7 +951,7 @@ pub extern "C" fn text_editor_export_selection() -> *mut u8 { let Type::Text(text_content) = &shape.shape_type else { return ptr::null_mut(); }; - let selection = &state.text_editor_state.selection; + let selection = &get_text_editor_state().selection; let start = selection.start(); let end = selection.end(); let paragraphs = text_content.paragraphs(); @@ -1041,19 +1015,17 @@ pub extern "C" fn text_editor_export_selection() -> *mut u8 { #[no_mangle] pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> bool { - with_state!(state, { - if !state.text_editor_state.selection.is_selection() { - return false; - } - let sel = &state.text_editor_state.selection; - unsafe { - *buffer_ptr = sel.anchor.paragraph as u32; - *buffer_ptr.add(1) = sel.anchor.offset as u32; - *buffer_ptr.add(2) = sel.focus.paragraph as u32; - *buffer_ptr.add(3) = sel.focus.offset as u32; - } - true - }) + if !get_text_editor_state().selection.is_selection() { + return false; + } + let sel = &get_text_editor_state().selection; + unsafe { + *buffer_ptr = sel.anchor.paragraph as u32; + *buffer_ptr.add(1) = sel.anchor.offset as u32; + *buffer_ptr.add(2) = sel.focus.paragraph as u32; + *buffer_ptr.add(3) = sel.focus.offset as u32; + } + true } // ============================================================================ diff --git a/render-wasm/src/wasm/transforms.rs b/render-wasm/src/wasm/transforms.rs index b0e0a2d84d..87989c545b 100644 --- a/render-wasm/src/wasm/transforms.rs +++ b/render-wasm/src/wasm/transforms.rs @@ -7,7 +7,7 @@ use skia_safe as skia; use crate::mem; use crate::shapes::{self, TransformEntry, TransformEntrySource}; use crate::utils::uuid_from_u32_quartet; -use crate::{with_state, STATE}; +use crate::with_state; #[derive(Debug, PartialEq, Clone, Copy, ToJs)] #[repr(u8)] diff --git a/scripts/check-commit b/scripts/check-commit new file mode 100755 index 0000000000..478aee5156 --- /dev/null +++ b/scripts/check-commit @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +""" +Check commit messages against Penpot's commit guidelines. + +Validates commit messages using the rules defined in: + - .github/workflows/commit-checker.yml (regex pattern) + - CONTRIBUTING.md (formatting rules, subject length, DCO) + +By default, checks HEAD. Use --commit to specify a different commit. + +Usage: + ./scripts/check-commit + ./scripts/check-commit --commit HEAD~1 + ./scripts/check-commit -c abc1234 +""" + +import argparse +import re +import subprocess +import sys + +# ── Emoji list ─────────────────────────────────────────────────────────────── +# Combined from commit-checker.yml AND CONTRIBUTING.md +VALID_EMOJIS = ( + "lipstick|globe_with_meridians|wrench|books|" + "arrow_up|arrow_down|zap|ambulance|construction|" + "boom|fire|whale|bug|sparkles|paperclip|tada|" + "recycle|rewind|construction_worker|rocket" +) + +# ── Regex from .github/workflows/commit-checker.yml ────────────────────────── +# Matches: +# 1) ":emoji: " +# 2) "Merge|Revert|Reapply ... without trailing dot" +COMMIT_PATTERN = re.compile( + r"^((:(" + VALID_EMOJIS + r"):\s[A-Z].*[^.]))$" +) + +MERGE_PATTERN = re.compile(r"^(Merge|Revert|Reapply).+[^.]$") + +# ═══════════════════════════════════════════════════════════════════════════════ +# Helpers +# ═══════════════════════════════════════════════════════════════════════════════ + +def run_git(args): + """Run a git command and return (returncode, stdout, stderr).""" + try: + result = subprocess.run( + ["git"] + args, + capture_output=True, + text=True, + check=False, + ) + return result.returncode, result.stdout, result.stderr + except FileNotFoundError: + print("ERROR: git not found. Is it installed?", file=sys.stderr) + sys.exit(1) + + +def get_commit_message(commit_ref): + """Return the full commit message for *commit_ref*.""" + rc, out, err = run_git(["log", "--format=%B", "-n", "1", commit_ref]) + if rc != 0: + print(f"ERROR: could not read commit {commit_ref}: {err.strip()}", file=sys.stderr) + sys.exit(1) + if not out.strip(): + print(f"ERROR: commit {commit_ref} has no message", file=sys.stderr) + sys.exit(1) + return out.rstrip("\n") + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Validators +# ═══════════════════════════════════════════════════════════════════════════════ + +def check_regex(message): + """Check the commit message against the CI regex pattern.""" + # Normalise: strip trailing newlines for single-line matching + first_line = message.split("\n")[0] + + if MERGE_PATTERN.match(first_line): + return True, None + + if COMMIT_PATTERN.match(first_line): + return True, None + + return False, ( + "Commit subject must match one of:\n" + " :emoji: \n" + " Merge|Revert|Reapply \n" + f"Got: {first_line!r}" + ) + + +def check_subject_length(message): + """Subject line must be ≤ 90 characters.""" + first_line = message.split("\n")[0] + if len(first_line) > 90: + return False, ( + f"Subject line exceeds 90 characters ({len(first_line)} chars):\n" + f" {first_line}" + ) + return True, None + + +def check_subject_no_trailing_dot(message): + """Subject line must not end with a period ('.').""" + first_line = message.split("\n")[0] + if first_line.endswith("."): + return False, ( + "Subject line must not end with a period:\n" + f" {first_line}" + ) + return True, None + + +def check_subject_capitalized(message): + """Subject must be capitalized, but only if it's a regular commit (not Merge/Revert/Reapply).""" + first_line = message.split("\n")[0] + + # Skip check for Merge/Revert/Reapply commits + if MERGE_PATTERN.match(first_line): + return True, None + + # Strip emoji prefix before checking capitalization + emoji_match = re.match(r"^:([a-z_]+):\s+(.*)", first_line) + if emoji_match: + rest = emoji_match.group(2) + else: + rest = first_line + + if rest and not rest[0].isupper(): + return False, ( + "Subject line must start with a capital letter " + "(after the emoji prefix):\n" + f" {first_line}" + ) + return True, None + + +def check_body_blank_line(message): + """If a body exists, there must be a blank line between subject and body.""" + lines = message.split("\n") + if len(lines) >= 3 and lines[1] != "": + return False, ( + "A blank line must separate the subject from the body." + ) + return True, None + + +def check_signed_off_by(message): + """Check for the DCO Signed-off-by line (required for code changes).""" + if "Signed-off-by:" not in message: + return False, ( + "Missing 'Signed-off-by:' line in the commit footer.\n" + " Add it with 'git commit -s' or append it manually:\n" + " Signed-off-by: Your Real Name " + ) + return True, None + + +# ═══════════════════════════════════════════════════════════════════════════════ + +def main(): + parser = argparse.ArgumentParser( + description="Check a commit message against Penpot commit guidelines." + ) + parser.add_argument( + "-c", "--commit", + default="HEAD", + help="Commit to check (default: HEAD)", + ) + args = parser.parse_args() + + commit_ref = args.commit + message = get_commit_message(commit_ref) + + print(f"Checking commit {commit_ref} ...\n") + + validators = [ + ("Regex pattern", check_regex), + ("Subject ≤ 90 chars", check_subject_length), + ("No trailing period in subject", check_subject_no_trailing_dot), + ("Subject capitalized", check_subject_capitalized), + ("Blank line after subject", check_body_blank_line), + ] + + all_ok = True + + for name, validator in validators: + ok, error_msg = validator(message) + status = "✓" if ok else "✗" + print(f" [{status}] {name}") + if not ok: + all_ok = False + print(f" {error_msg}", file=sys.stderr) + + print() + if all_ok: + print("All checks passed.") + sys.exit(0) + else: + print("Some checks FAILED. See messages above.", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tools/gh.py b/tools/gh.py new file mode 100755 index 0000000000..afd81da619 --- /dev/null +++ b/tools/gh.py @@ -0,0 +1,373 @@ +#!/usr/bin/env python3 +""" +gh.py — Multi-purpose CLI helper for penpot/penpot GitHub operations. + +Uses GitHub GraphQL and REST APIs via the authenticated ``gh`` CLI. + +Subcommands: + issues List issues in a milestone + prs Fetch details for one or more PRs + +Usage: + python3 tools/gh.py issues (default: state=closed) + python3 tools/gh.py issues "2.16.0" --state all + python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog" + python3 tools/gh.py issues "2.16.0" --compare CHANGES.md + python3 tools/gh.py prs 9179 9204 9311 + python3 tools/gh.py prs --file prs.txt + cat prs.txt | python3 tools/gh.py prs --stdin + +Prerequisites: + - gh CLI authenticated (gh auth status) + - Python 3.8+ +""" + +import argparse +import json +import re +import subprocess +import sys +from typing import Any + + +REPO = "penpot/penpot" +OWNER = "penpot" +REPO_NAME = "penpot" + + +# ───────────────────────────────────────────── +# Shared helpers +# ───────────────────────────────────────────── + + +def run_gh(method: str, endpoint: str, **kwargs: Any) -> Any: + """Run a ``gh api`` call and return parsed JSON.""" + cmd = ["gh", "api", endpoint, "--method", method] + for key, val in kwargs.items(): + if val is not None: + cmd.extend(["-f", f"{key}={val}"]) + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f"gh error: {result.stderr}", file=sys.stderr) + sys.exit(1) + return json.loads(result.stdout) + + +def run_gh_graphql(query: str, variables: dict) -> Any: + """Run a GraphQL query via ``gh api graphql --input -``.""" + payload = json.dumps({"query": query, "variables": variables}) + cmd = ["gh", "api", "graphql", "--input", "-"] + result = subprocess.run(cmd, input=payload, capture_output=True, text=True) + if result.returncode != 0: + print(f"gh error: {result.stderr}", file=sys.stderr) + sys.exit(1) + body = json.loads(result.stdout) + if "errors" in body: + for err in body["errors"]: + print(f"GraphQL error: {err.get('message')}", file=sys.stderr) + sys.exit(1) + return body["data"] + + +# ───────────────────────────────────────────── +# Subcommand: issues +# ───────────────────────────────────────────── + +GQL_ISSUES_QUERY = """\ +query($owner: String!, $repo: String!, $milestone: Int!, $cursor: String) { + repository(owner: $owner, name: $repo) { + milestone(number: $milestone) { + issues(first: 100, after: $cursor, states: __STATES__) { + totalCount + pageInfo { hasNextPage endCursor } + nodes { + ... on Issue { + number + title + state + issueType { name } + labels(first: 20) { nodes { name } } + closedByPullRequestsReferences(first: 5) { nodes { number } } + } + } + } + } + } +} +""" + + +def find_milestone(title: str) -> dict: + """Look up milestone by title, return {number, title, open_issues, closed_issues}.""" + data = run_gh("GET", f"repos/{OWNER}/{REPO_NAME}/milestones?per_page=100&state=all") + for ms in data: + if ms["title"] == title: + return { + "number": ms["number"], + "title": ms["title"], + "open_issues": ms["open_issues"], + "closed_issues": ms["closed_issues"], + } + print(f"ERROR: Milestone \"{title}\" not found in {REPO}", file=sys.stderr) + sys.exit(1) + + +def fetch_milestone_issues(milestone_num: int, states: str) -> list[dict]: + """ + Fetch all issues in a milestone via paginated GraphQL. + + Args: + milestone_num: milestone number + states: GraphQL states enum array literal, e.g. ``"[CLOSED]"`` or ``"[OPEN CLOSED]"`` + + Returns: + List of {number, title, state, issue_type: str|None, labels: [str], closing_prs: [int]} + """ + query = GQL_ISSUES_QUERY.replace("__STATES__", states) + all_nodes: list[dict] = [] + cursor: str | None = None + + while True: + variables: dict[str, Any] = { + "owner": OWNER, + "repo": REPO_NAME, + "milestone": milestone_num, + "cursor": cursor, + } + data = run_gh_graphql(query, variables) + issues = data["repository"]["milestone"]["issues"] + page_info = issues["pageInfo"] + + for node in issues["nodes"]: + if node is None: + continue + issue_type = node.get("issueType") + all_nodes.append({ + "number": node["number"], + "title": node["title"], + "state": node["state"], + "issue_type": issue_type["name"] if issue_type else None, + "labels": [lbl["name"] for lbl in node["labels"]["nodes"]], + "closing_prs": [pr["number"] for pr in node["closedByPullRequestsReferences"]["nodes"]], + }) + + total = len(all_nodes) + print(f" ... fetched {total} issues so far", file=sys.stderr) + + if not page_info["hasNextPage"]: + break + cursor = page_info["endCursor"] + + return all_nodes + + +def load_existing_issue_numbers(filepath: str) -> set[int]: + """Parse all ``#NNNN`` references from a file (e.g. CHANGES.md).""" + pattern = re.compile(r"#(\d{3,5})\b") + nums: set[int] = set() + with open(filepath) as f: + for line in f: + for m in pattern.finditer(line): + nums.add(int(m.group(1))) + return nums + + +def cmd_issues(args: argparse.Namespace) -> None: + """Handle the ``issues`` subcommand.""" + + # Resolve milestone + print(f"Looking up milestone \"{args.milestone}\"...", file=sys.stderr) + ms = find_milestone(args.milestone) + print(f"Milestone #{ms['number']}: {ms['open_issues']} open, {ms['closed_issues']} closed", + file=sys.stderr) + + # Map state to GraphQL enum array literal + state_map = {"open": "[OPEN]", "closed": "[CLOSED]", "all": "[OPEN CLOSED]"} + gql_states = state_map[args.state] + + # Fetch issues + print(f"Fetching {args.state} issues via GraphQL...", file=sys.stderr) + issues = fetch_milestone_issues(ms["number"], gql_states) + print(f"Fetched {len(issues)} issues total", file=sys.stderr) + + # Filter by excluded labels + if args.exclude: + exclusions = set(label.strip() for label in args.exclude.split(",")) + filtered = [issue for issue in issues + if not any(lbl in exclusions for lbl in issue["labels"])] + print(f"After excluding labels: {len(filtered)} issues", file=sys.stderr) + issues = filtered + + # Filter to issues NOT yet in the comparison file (if --compare given) + if args.compare: + existing_nums = load_existing_issue_numbers(args.compare) + missing = [iss for iss in issues if iss["number"] not in existing_nums] + missing.sort(key=lambda x: x["number"]) + print(f"Issues not yet in changelog: {len(missing)}", file=sys.stderr) + issues = missing + + print(json.dumps(issues, indent=2)) + + +# ───────────────────────────────────────────── +# Subcommand: prs +# ───────────────────────────────────────────── + +PRS_BATCH_SIZE = 50 + +GQL_PRS_QUERY_ITEM = """\ + pr_{num}: pullRequest(number: {num}) {{ + number + title + body + state + mergedAt + createdAt + author {{ login }} + labels(first: 20) {{ nodes {{ name }} }} + closingIssuesReferences(first: 5) {{ nodes {{ number }} }} + }} +""" + +GQL_PRS_QUERY_WRAPPER = """\ +query($owner: String!, $repo: String!) {{ + repository(owner: $owner, name: $repo) {{ +{items} + }} +}} +""" + + +def fetch_prs_batch(pr_numbers: list[int]) -> list[dict]: + """ + Fetch details for a list of PR numbers in a single GraphQL query. + + Uses numbered aliases (pr_1234, pr_5678, …) so each PR is looked up by + number in one round-trip. Returns entries in the same order as the input. + """ + items = "\n".join( + GQL_PRS_QUERY_ITEM.format(num=n) for n in pr_numbers + ) + query = GQL_PRS_QUERY_WRAPPER.format(items=items) + variables = {"owner": OWNER, "repo": REPO_NAME} + + data = run_gh_graphql(query, variables) + repo = data["repository"] + + results: list[dict] = [] + for num in pr_numbers: + pr = repo.get(f"pr_{num}") + if pr is None: + results.append({ + "number": num, + "error": "not_found", + }) + continue + results.append({ + "number": pr["number"], + "title": pr["title"], + "body": pr.get("body"), + "state": pr["state"], + "merged_at": pr.get("mergedAt"), + "created_at": pr.get("createdAt"), + "author": pr["author"]["login"] if pr["author"] else None, + "labels": [lbl["name"] for lbl in pr["labels"]["nodes"]], + "closing_issues": [iss["number"] for iss in pr["closingIssuesReferences"]["nodes"]], + }) + return results + + +def cmd_prs(args: argparse.Namespace) -> None: + """Handle the ``prs`` subcommand.""" + + # Collect PR numbers from args / file / stdin + pr_numbers: list[int] = [] + + if args.numbers: + pr_numbers.extend(args.numbers) + + if args.file: + with open(args.file) as f: + for line in f: + line = line.strip() + if line: + pr_numbers.append(int(line)) + + if args.stdin: + for line in sys.stdin: + line = line.strip() + if line: + pr_numbers.append(int(line)) + + if not pr_numbers: + print("ERROR: no PR numbers provided (pass numbers, --file, or --stdin)", + file=sys.stderr) + sys.exit(1) + + # Deduplicate while preserving order + seen: set[int] = set() + pr_numbers = [n for n in pr_numbers if not (n in seen or seen.add(n))] + + print(f"Fetching {len(pr_numbers)} PRs in batches of {PRS_BATCH_SIZE}...", + file=sys.stderr) + + all_results: list[dict] = [] + for i in range(0, len(pr_numbers), PRS_BATCH_SIZE): + batch = pr_numbers[i : i + PRS_BATCH_SIZE] + print(f" batch {i // PRS_BATCH_SIZE + 1}: PRs {batch[0]}..{batch[-1]}", + file=sys.stderr) + all_results.extend(fetch_prs_batch(batch)) + + print(json.dumps(all_results, indent=2)) + + +# ───────────────────────────────────────────── +# CLI entrypoint +# ───────────────────────────────────────────── + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Multi-purpose CLI helper for penpot/penpot GitHub operations" + ) + sub = parser.add_subparsers(dest="command", required=True, title="subcommands") + + # --- issues --- + p_issues = sub.add_parser("issues", help="List issues in a milestone") + p_issues.add_argument("milestone", help="Milestone title, e.g. '2.16.0'") + p_issues.add_argument( + "--state", choices=["open", "closed", "all"], default="closed", + help="Issue state filter (default: closed)" + ) + p_issues.add_argument( + "--exclude", "--exclude-labels", + help="Comma-separated labels to exclude, e.g. 'release blocker,no changelog'" + ) + p_issues.add_argument( + "--compare", + help="Path to CHANGES.md; only show issues NOT yet referenced in that file" + ) + p_issues.set_defaults(func=cmd_issues) + + # --- prs --- + p_prs = sub.add_parser("prs", help="Fetch details for one or more PRs") + p_prs.add_argument( + "numbers", type=int, nargs="*", + help="PR numbers to fetch (space-separated)" + ) + p_prs.add_argument( + "--file", type=str, + help="File with one PR number per line" + ) + p_prs.add_argument( + "--stdin", action="store_true", + help="Read PR numbers from stdin (one per line)" + ) + p_prs.set_defaults(func=cmd_prs) + + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/tools/nrepl-eval.mjs b/tools/nrepl-eval.mjs new file mode 100755 index 0000000000..45da2445de --- /dev/null +++ b/tools/nrepl-eval.mjs @@ -0,0 +1,259 @@ +#!/usr/bin/env node +import nreplClient from "nrepl-client"; +import { readFileSync, writeFileSync, existsSync, unlinkSync } from "fs"; +import path from "path"; +import os from "os"; + +const DEFAULT_TIMEOUT = 120000; + +// ============================================================================ +// Session persistence +// ============================================================================ + +function sessionFilePath(host, port) { + return path.join(os.tmpdir(), `penpot-nrepl-session-${host}-${port}`); +} + +function readSession(host, port) { + const fp = sessionFilePath(host, port); + try { + return readFileSync(fp, "utf8").trim() || null; + } catch { + return null; + } +} + +function writeSession(host, port, id) { + writeFileSync(sessionFilePath(host, port), id, "utf8"); +} + +function deleteSession(host, port) { + const fp = sessionFilePath(host, port); + if (existsSync(fp)) unlinkSync(fp); +} + +// ============================================================================ +// nREPL helpers (promisified) +// ============================================================================ + +function nreplSend(con, msg) { + return new Promise((resolve, reject) => { + con.send(msg, (err, messages) => { + if (err) { + const text = Array.isArray(err) + ? err.map((e) => (e && e.message) || String(e)).join("; ") + : String(err); + reject(new Error(text)); + } else { + resolve(messages || []); + } + }); + }); +} + +function nreplEval({ host, port, code, sessionId, timeout = DEFAULT_TIMEOUT }) { + return new Promise((resolve, reject) => { + const con = nreplClient.connect({ host, port }); + let finished = false; + + const finish = (err, result) => { + if (finished) return; + finished = true; + clearTimeout(timer); + try { con.end(); } catch (_) {} + if (err) reject(err); + else resolve(result); + }; + + const timer = setTimeout(() => { + finish(new Error(`nREPL eval timed out after ${timeout}ms`)); + }, timeout); + + con.on("error", (err) => { + finish(err); + }); + + con.once("connect", async () => { + try { + let sid = sessionId; + + if (!sid) { + const msgs = await nreplSend(con, { op: "clone" }); + const m = msgs.find((m) => m["new-session"]); + if (!m) throw new Error("Clone response missing new-session"); + sid = m["new-session"]; + } + + const messages = await nreplSend(con, { op: "eval", code, session: sid }); + finish(null, { messages, sessionId: sid }); + } catch (err) { + finish(err); + } + }); + }); +} + +// ============================================================================ +// Output formatting +// ============================================================================ + +function formatEvalMessages(messages) { + const lines = []; + let hasContent = false; + for (const msg of messages) { + if (msg.out) { + lines.push(msg.out); + hasContent = true; + } + if (msg.err) { + lines.push(`[ERROR] ${msg.err}`); + hasContent = true; + } + if (msg.value) { + const ns = msg.ns ? ` (ns: ${msg.ns})` : ""; + lines.push(`=> ${msg.value}${ns}`); + hasContent = true; + } + } + if (!hasContent) { + const statuses = messages.map((m) => m.status).filter(Boolean).flat(); + return `Evaluation completed. Status: ${statuses.join(", ") || "done"}`; + } + return lines.join("\n"); +} + +// ============================================================================ +// CLI argument parsing +// ============================================================================ + +function parseArgs(argv) { + const args = { + port: 6064, + host: "127.0.0.1", + timeout: DEFAULT_TIMEOUT, + help: false, + resetSession: false, + lastError: false, + code: null, + }; + + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "-p" || a === "--port") { + const val = argv[++i]; + if (val === undefined) { console.error("Error: --port requires a value."); process.exit(1); } + args.port = parseInt(val, 10); + } else if (a === "-H" || a === "--host") { + const val = argv[++i]; + if (val === undefined) { console.error("Error: --host requires a value."); process.exit(1); } + args.host = val; + } else if (a === "-t" || a === "--timeout") { + const val = argv[++i]; + if (val === undefined) { console.error("Error: --timeout requires a value."); process.exit(1); } + args.timeout = parseInt(val, 10); + } else if (a === "--reset-session") { + args.resetSession = true; + } else if (a === "-e" || a === "--last-error") { + args.lastError = true; + } else if (a === "-h" || a === "--help") { + args.help = true; + } else { + if (args.code === null) { + args.code = a; + } else { + args.code += " " + a; + } + } + } + + return args; +} + +function printHelp() { + const bin = path.basename(process.argv[1]); + console.log(`Usage: ${bin} [options] [] + +Evaluate Clojure code via a running nREPL server. Session state (defs, in-ns) +persists across invocations via a stored session ID. + +Options: + -p, --port PORT nREPL port (default: 6064) + -H, --host HOST nREPL host (default: 127.0.0.1) + -t, --timeout MILLISECONDS Timeout in milliseconds (default: 120000) + --reset-session Discard stored session and start fresh + -e, --last-error Evaluate *e to retrieve the last exception + -h, --help Show this help message + +Examples: + ${bin} '(def x 42)' + ${bin} 'x' + ${bin} --reset-session '(def x 0)' + ${bin} --last-error + ${bin} <<'EOF' + (def x 10) + (+ x 20) + EOF`); +} + +function readStdin() { + return new Promise((resolve) => { + if (process.stdin.isTTY) { + resolve(""); + return; + } + let data = ""; + process.stdin.setEncoding("utf-8"); + process.stdin.on("data", (chunk) => { data += chunk; }); + process.stdin.on("end", () => { resolve(data.trim()); }); + }); +} + +// ============================================================================ +// Main +// ============================================================================ + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + if (args.help) { + printHelp(); + return; + } + + if (isNaN(args.port) || args.port < 1 || args.port > 65535) { + console.error("Error: invalid port number."); + process.exit(1); + } + + if (args.resetSession) { + deleteSession(args.host, args.port); + } + + let code = args.lastError ? "*e" : args.code; + if (!code) { + code = await readStdin(); + } + + if (!code) { + console.error("Error: No code provided. Pass code as an argument or pipe it via stdin."); + process.exit(1); + } + + const storedSession = readSession(args.host, args.port); + + const { messages, sessionId } = await nreplEval({ + host: args.host, + port: args.port, + code, + sessionId: storedSession, + timeout: args.timeout, + }); + + writeSession(args.host, args.port, sessionId); + console.log(formatEvalMessages(messages)); +} + +main().catch((err) => { + console.error(`Error: ${err.message || err}`); + process.exit(1); +}); diff --git a/tools/taiga.py b/tools/taiga.py new file mode 100755 index 0000000000..1e96364a31 --- /dev/null +++ b/tools/taiga.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +""" +Taiga API client — fetch public issues, user stories, and tasks from the +Penpot project (id 345963) without authentication. + +Usage: + python3 tools/taiga.py + python3 tools/taiga.py + python3 tools/taiga.py [--json] + python3 tools/taiga.py [--json] + +Examples: + python3 tools/taiga.py https://tree.taiga.io/project/penpot/issue/13714 + python3 tools/taiga.py --json https://tree.taiga.io/project/penpot/us/14128 + python3 tools/taiga.py task 13648 +""" + +import argparse +import json +import re +import sys +import urllib.error +import urllib.request + +API_BASE = "https://api.taiga.io/api/v1" +PROJECT_ID = 345963 + +ENDPOINT_MAP = { + "issue": "issues", + "us": "userstories", + "task": "tasks", +} + +TYPE_LABELS = { + "issue": "Issue", + "us": "User Story", + "task": "Task", +} + + +# ── URL Parsing ────────────────────────────────────────────────────────────── + +def parse_taiga_url(url: str) -> tuple[str, int] | None: + """Extract (type, ref) from a tree.taiga.io URL. + + Supported patterns: + .../project/penpot/issue/13714 + .../project/penpot/us/14128 + .../project/penpot/task/13648 + """ + m = re.search(r"/project/penpot/(issue|us|task)/(\d+)", url) + if not m: + return None + return m.group(1), int(m.group(2)) + + +# ── API call ───────────────────────────────────────────────────────────────── + +def fetch_item(endpoint: str, ref: int) -> dict | None: + """Fetch a single item by ref using the 'by_ref' endpoint.""" + url = f"{API_BASE}/{endpoint}/by_ref?ref={ref}&project={PROJECT_ID}" + try: + with urllib.request.urlopen(url, timeout=15) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + print(f"Error: HTTP {e.code} — {e.reason}", file=sys.stderr) + if e.code == 404: + print( + f" Item (ref={ref}) not found in project {PROJECT_ID}.", + file=sys.stderr, + ) + return None + except urllib.error.URLError as e: + print(f"Error: {e.reason}", file=sys.stderr) + return None + except json.JSONDecodeError as e: + print(f"Error: invalid JSON response — {e}", file=sys.stderr) + return None + + +# ── Output formatting ──────────────────────────────────────────────────────── + +def _val(value, default="—"): + return value if value is not None else default + + +def _tag_list(tags): + """Pretty-print tag list. Tags are arrays of [name, color] pairs.""" + if not tags: + return "—" + names = [t[0] if isinstance(t, list) else str(t) for t in tags] + return ", ".join(names) + + +def _extra_name(extra_info): + """Extract a display name from an *_extra_info dict.""" + if not extra_info: + return "—" + return extra_info.get("full_name_display") or extra_info.get("username") or "—" + + +def _status_name(status_info): + """Extract status name from status_extra_info.""" + if not status_info: + return "—" + return status_info.get("name", "—") + + +def _project_name(proj_info): + """Extract project name from project_extra_info.""" + if not proj_info: + return "—" + return proj_info.get("name", "—") + + +def _assignee(item): + """Return the assignee display name.""" + return _extra_name(item.get("assigned_to_extra_info")) + + +def _owner(item): + return _extra_name(item.get("owner_extra_info")) + + +def format_summary(item: dict, item_type: str) -> str: + """Build a printable summary matching the requested format.""" + label = TYPE_LABELS.get(item_type, item_type.capitalize()) + subject = item.get("subject", "(no subject)") + ref = item.get("ref", "?") + status = _status_name(item.get("status_extra_info")) + assignee = _assignee(item) + owner = _owner(item) + created = item.get("created_date", "")[:10] if item.get("created_date") else "" + tags = _tag_list(item.get("tags", [])) + + # Title line + title = f"{label} #{ref} — {subject}" + + # Fields section (no indent) + fields = [] + fields.append(f"Status: {status}") + + if item_type == "us": + milestone = item.get("milestone_slug") or "" + points = item.get("points") or {} + point_count = len(points) + fields.append(f"Milestone: {milestone}") + fields.append(f"Points: {point_count} role(s)") + elif item_type == "task": + milestone = item.get("milestone_slug") or "" + parent = item.get("user_story") + fields.append(f"Milestone: {milestone}") + fields.append(f"Parent US: {parent if parent else '—'}") + elif item_type == "issue": + issue_type_id = item.get("type", "") + severity_id = item.get("severity", "") + priority_id = item.get("priority", "") + fields.append(f"Type ID: {issue_type_id}") + fields.append(f"Severity ID: {severity_id}") + fields.append(f"Priority ID: {priority_id}") + + fields.append(f"Assignee: {assignee}") + fields.append(f"Author: {owner}") + fields.append(f"Created: {created}") + fields.append(f"Tags: {tags}") + + url = f"https://tree.taiga.io/project/penpot/{item_type}/{ref}" + fields.append(f"URL: {url}") + + # Assemble output + sep = "================================" + parts = [title, sep] + parts.extend(fields) + + # Full description after second separator + desc = item.get("description") or "" + if desc.strip(): + parts.append(sep) + parts.append(desc) + + return "\n".join(parts) + + +# ── CLI ─────────────────────────────────────────────────────────────────────── + +def build_parser(): + parser = argparse.ArgumentParser( + description="Fetch public items from the Penpot Taiga project.", + epilog=( + "Examples:\n" + " %(prog)s https://tree.taiga.io/project/penpot/issue/13714\n" + " %(prog)s --json us 14128\n" + " %(prog)s task 13648" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--json", + action="store_true", + dest="raw_json", + help="Output raw JSON instead of formatted summary.", + ) + parser.add_argument( + "args", + nargs="+", + help='Either a Taiga URL, or " " (e.g. issue 13714).', + ) + return parser + + +def main(): + parser = build_parser() + opts = parser.parse_args() + + # Determine (type, ref) from arguments + item_type: str | None = None + ref: int | None = None + + if len(opts.args) == 1: + # Single argument — must be a Taiga URL + parsed = parse_taiga_url(opts.args[0]) + if parsed is None: + print( + "Error: could not parse Taiga URL. " + 'Expected format: https://tree.taiga.io/project/penpot//', + file=sys.stderr, + ) + sys.exit(1) + item_type, ref = parsed + elif len(opts.args) == 2: + item_type, ref_str = opts.args + if item_type not in ENDPOINT_MAP: + print( + f"Error: unknown type '{item_type}'. " + f"Expected one of: {', '.join(ENDPOINT_MAP)}", + file=sys.stderr, + ) + sys.exit(1) + try: + ref = int(ref_str) + except ValueError: + print(f"Error: ref must be a number, got '{ref_str}'", file=sys.stderr) + sys.exit(1) + else: + parser.print_help() + sys.exit(1) + + endpoint = ENDPOINT_MAP[item_type] + item = fetch_item(endpoint, ref) + + if item is None: + sys.exit(1) + + if opts.raw_json: + print(json.dumps(item, indent=2, ensure_ascii=False)) + else: + print(format_summary(item, item_type)) + + +if __name__ == "__main__": + main()