Merge branch 'develop' into niwinz-performance-tests

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
This commit is contained in:
Andrey Antukh 2026-07-08 12:15:31 +02:00 committed by GitHub
commit 8e739e01e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
427 changed files with 36652 additions and 11605 deletions

View File

@ -10,10 +10,18 @@ jobs:
triage:
runs-on: ubuntu-latest
steps:
- name: Generate GitHub App token
id: triage-app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.TRIAGE_APP_ID }}
private-key: ${{ secrets.TRIAGE_APP_PRIVATE_KEY }}
owner: penpot
- name: Process Issue or PR
uses: actions/github-script@v7
with:
github-token: ${{ secrets.PROJECT_TOKEN }}
github-token: ${{ steps.triage-app-token.outputs.token }}
script: |
// === 1. CONFIGURATION ===
const PROJECT_NUMBER = 8; // <--- Replace with your project board number

View File

@ -0,0 +1,133 @@
name: "CI: Plugin API Test Suite"
# Runs the Plugin API Test Suite (it exercises the real Penpot Plugin API, so it
# needs a running frontend + the plugin runtime). Two jobs:
#
# - api-test-suite-mocked (pull_request / push): the per-PR gate. Serves the
# prebuilt frontend bundle and intercepts every backend RPC with Playwright
# (MOCK_BACKEND=1). No backend / no login. Validates the frontend Plugin API
# binding + in-memory store; backend-result-dependent tests are skipped via the
# `skipIfMocked` tag. See plugins/apps/plugin-api-test-suite/README.md.
#
# - api-test-suite-live (workflow_dispatch): true end-to-end against a LIVE
# instance. Point PENPOT_BASE_URL at a reachable instance and provide login
# credentials via repo secrets. Manual because the CI runner has no Docker to
# stand up a full stack.
defaults:
run:
shell: bash
on:
workflow_dispatch:
inputs:
base_url:
description: "Penpot base URL (e.g. https://localhost:3449)"
required: false
default: "https://localhost:3449"
pull_request:
paths:
- 'plugins/**'
- 'frontend/**'
- 'common/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'plugins/**'
- 'frontend/src/app/plugins/**'
- 'common/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
api-test-suite-mocked:
if: ${{ github.event_name != 'workflow_dispatch' && !github.event.pull_request.draft }}
name: "Run Plugin API Test Suite (mocked)"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- uses: actions/checkout@v6
# Mocked mode serves the prebuilt bundle from frontend/resources/public.
- name: Build frontend bundle
working-directory: ./frontend
run: ./scripts/build
- name: Install deps
working-directory: ./plugins
run: |
corepack enable;
corepack install;
pnpm install;
- name: Install Playwright Chromium
working-directory: ./plugins
run: pnpm --filter plugin-api-test-suite exec playwright install --with-deps chromium
- name: Generate API surface
working-directory: ./plugins
run: pnpm --filter plugin-api-test-suite run gen:api
- name: Run API test suite (mocked)
working-directory: ./plugins
env:
MOCK_BACKEND: "1"
run: pnpm --filter plugin-api-test-suite run test:ci
## The following job will launch the whole suite of tests but we need
## to have a full environment in the CI for this to work.
# api-test-suite-live:
# if: ${{ github.event_name == 'workflow_dispatch' }}
# name: Run Plugin API Test Suite (live)
# runs-on: penpot-runner-02
# container:
# image: penpotapp/devenv:latest
#
# env:
# PENPOT_BASE_URL: ${{ github.event.inputs.base_url }}
# E2E_LOGIN_EMAIL: ${{ secrets.E2E_LOGIN_EMAIL }}
# E2E_LOGIN_PASSWORD: ${{ secrets.E2E_LOGIN_PASSWORD }}
#
# steps:
# - uses: actions/checkout@v6
#
# - name: Setup Node
# uses: actions/setup-node@v6
# with:
# node-version-file: .nvmrc
#
# - name: Install deps
# working-directory: ./plugins
# run: |
# corepack enable;
# corepack install;
# pnpm install;
#
# - name: Install Playwright Chromium
# working-directory: ./plugins
# run: pnpm --filter plugin-api-test-suite exec playwright install --with-deps chromium
#
# - name: Generate API surface
# working-directory: ./plugins
# run: pnpm --filter plugin-api-test-suite run gen:api
#
# # Note: requires a running Penpot instance reachable at PENPOT_BASE_URL.
# - name: Run API test suite
# working-directory: ./plugins
# run: pnpm --filter plugin-api-test-suite run test:ci

View File

@ -40,19 +40,13 @@ jobs:
steps:
- uses: actions/checkout@v6
- name: Setup Node
id: setup-node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Install deps
working-directory: ./plugins
shell: bash
run: |
corepack enable;
corepack install;
pnpm install;
pnpm install -r;
- name: Run Lint
working-directory: ./plugins

3
.gitignore vendored
View File

@ -13,6 +13,8 @@
.nyc_output
.rebel_readline_history
.repl
opencode.json
.opencode/package-lock.json
/*.jpg
/*.md
!CHANGES.md
@ -30,7 +32,6 @@
/.clj-kondo/.cache
/_dump
/notes
/.opencode/package-lock.json
/plans
/prompts
/playground/

2
.nvmrc
View File

@ -1 +1 @@
v22.22.0
v24.18.0

View File

@ -1,28 +1,145 @@
---
name: commiter
description: Git commit assistant
mode: all
mode: subagent
permission:
read: allow
glob: allow
grep: allow
list: allow
edit: deny
webfetch: deny
websearch: deny
task: deny
skill: deny
lsp: deny
todowrite: deny
question: allow
external_directory: deny
bash:
# Broad read-side: any non-write git query
"git status*": allow
"git log*": allow
"git diff*": allow
"git show*": allow
"git rev-parse*": allow
"git branch*": allow
"git remote -v*": allow
"git config --get*": allow
# Commit flow: staged, explicit paths only. `git commit*` (no space)
# also covers `git commit --amend`. `git add -*` overrides the allow
# below to block flag-driven bulk adds (`-A`, `--all`, `-u`, ...).
"git add *": allow
"git commit*": allow
"git add -*": deny
# Read-only filesystem helpers used in the commit flow
"cat *": allow
"head *": allow
"tail *": allow
"wc *": allow
"date *": allow
# Dangerous: deny outright
"rm *": deny
"rmdir *": deny
"mv *": deny
"cp *": deny
"dd *": deny
"chmod *": deny
"chown *": deny
"sudo *": deny
"git push*": deny
"git clean*": deny
"git reset*": deny
"git checkout*": deny
"git restore*": deny
"git config --global*": deny
"curl *": deny
"wget *": deny
"ssh *": deny
"scp *": deny
"eval *": deny
# Risky-but-sometimes-needed: ask the user
"git stash*": ask
"git rebase*": ask
"git merge*": ask
"git tag*": ask
"git fetch*": ask
"git pull*": ask
# Note: `git config <anything-other-than-(--get|--global)>` falls
# through to the `*` catch-all below and is asked.
# Safety net
"*": ask
---
## 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.
You are the Penpot commit assistant. You produce git commits that follow the
repository's commit conventions exactly: an emoji-prefixed imperative
subject, a body that explains the why, and the required trailers. You do
not implement features, review code, or push branches — you commit.
## Requirements
## Required Reading
* Override your internal commit rules when the user explicitly requests
something that conflicts with them.
* Read `.serena/memories/workflows/creating-commits.md` before
creating any commit and follow the commit guidelines strictly.
* Keep the description (commit body) with maximum line length of 80
characters. Use manual line breaks to wrap text before it exceeds
this limit.
* 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.
Before drafting any commit, read `.serena/memories/workflow/creating-commits.md`
end-to-end. It is the canonical source for the emoji menu, subject/body
limits, and trailer format. The summary in this file does not replace it.
## Pre-commit Workflow
1. Run `git status` to inspect the working tree. If there are unstaged or
untracked changes that are unrelated to the user's request, STOP and ask
the user how to handle them. Do not silently include unrelated work in
the commit.
2. Run `git diff --staged` (or `git diff` for unstaged changes) and review
the content. If you see secrets (API keys, tokens, passwords, private
keys, `.env` values), debug prints, or anything that does not match the
user's stated intent, STOP and tell the user before committing.
3. Pick the commit emoji from the menu in
`mem:workflow/creating-commits`. If none of the listed emojis fit, use
`:paperclip:` (other) and explain in the body why.
4. Draft the commit message (see format below), then run
`git commit -s -m "<subject>" -m "<body>"` (or pass the message via
`git commit -s -F -` if the body has unusual characters).
## Commit Message Format
```
:emoji: Subject line (imperative, capitalized, no period, <=70 chars)
Body explaining what changed and why. Wrap at 80 chars. Use manual
line breaks; do not rely on the terminal to wrap.
Co-authored-by: <model-name> <model-name@penpot.app>
```
- Subject: imperative mood, capitalized, no trailing period, max 70 chars.
- Body: wraps at 80 chars. Explain the *why*, not just the *what* — what
was wrong before, what this change does about it, and any non-obvious
trade-offs.
- `Co-authored-by` trailer is mandatory. Replace `<model-name>` with your
own model identifier (e.g. `claude-sonnet-4-6`).
- `Signed-off-by` is added automatically by `git commit -s`, using the
local `git config user.name` / `user.email`.
## Constraints
- Do not push. Pushing is a separate workflow handled by the user (the
agent's permission set also denies `git push*`).
- Do not run `git reset*`, `git checkout*`, `git restore*`, `git clean*`,
or `rm*` — the permission set denies these outright. If staged work
needs to be discarded, ask the user to do it.
- Do not pass `--author`. Author identity comes from the local git
config. Never guess or hallucinate a name or email.
- Do not amend a commit you did not create in this session, unless the
user explicitly asks. `git commit --amend` rewrites history and is
irreversible once pushed.
- Do not bypass pre-commit hooks (`--no-verify`) unless the user
explicitly asks, and call out the deviation in your response.
- If the user asks for something that conflicts with these rules, follow
the user's request and explain the deviation in your response. Do not
silently override the format.

View File

@ -1,25 +0,0 @@
---
name: Engineer
description: Senior Full-Stack Software Engineer
mode: primary
---
## Role
You are a high-autonomy Senior Full-Stack Software Engineer working on Penpot, an
open-source design tool. 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.
## Before Start
**Read `AGENTS.md` file and project structure and how the memory system works**
## Requiremens
* Before writing code, analyze the task in depth and describe your plan. If the task is
complex, break it down into atomic steps.
* Do **not** touch unrelated modules unless the task explicitly requires it.
* Only reference functions, namespaces, or APIs that actually exist in the
codebase. Verify their existence before citing them. If unsure, search first.
* Be concise and autonomous — avoid unnecessary explanations.

View File

@ -1,60 +0,0 @@
---
name: Planner
description: Software architect for planning and analysis only
mode: primary
permission:
edit: ask
---
## 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` file and project structure and how the memory system works and how to
navigate and read relevant information conventions.
* 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-<plan-one-line-title>.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.

View File

@ -1,56 +0,0 @@
---
name: Prompt Assistant
description: Refines and improves prompts for maximum clarity and effectiveness
mode: all
---
## 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 `AGENTS.md` file and project structure and how the memory system works and how to
navigate and read relevant information conventions.
* 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-<prompt-one-line-title>.md for future use.

View File

@ -1,124 +1,39 @@
---
name: create-pr
description: Create or update a GitHub PR following Penpot conventions, with a concise engineer-focused description
description: Create or update a GitHub PR following Penpot conventions.
---
# Pull Request (Create or Update)
# Skill: create-pr
Create or update a GitHub PR with proper title format and a concise description that explains reasoning, not implementation details.
Create or update a GitHub PR. Read and follow:
- `mem:workflow/creating-prs` — title format, description structure, writing principles
- `mem:workflow/creating-commits` — commit type emojis
## When to Use
- Opening a new pull request
- Updating an existing PR's title or description
- The user asks to create or update a PR
- Code changes are ready and committed
- Creating a new PR from a feature branch
- Updating an existing PR's title or description to match conventions
## Conventions
## Prerequisites
All PR conventions (title format, body structure, writing principles, what NOT to include) are documented in `mem:workflow/creating-prs`. This skill covers the procedure only.
- `gh` CLI authenticated (`gh auth status`)
## Workflow A: Creating a New PR
## Commands
### A1. Verify Prerequisites
**Create:**
```bash
git branch --show-current
git log --oneline main..HEAD
gh pr create --repo penpot/penpot --title "<TITLE>" --body-file /tmp/pr-body.md
```
### A2. Check if Branch is Pushed
**Update:**
```bash
BRANCH=$(git branch --show-current)
if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then
echo "Branch is pushed, proceeding with PR creation"
else
echo "ERROR: Branch '$BRANCH' is not pushed to remote. Please push the branch first."
exit 1
fi
gh pr edit <NUMBER> --repo penpot/penpot --title "<TITLE>" --body-file /tmp/pr-body.md
```
**If the branch is not pushed, STOP here and ask the user to push it. The LLM does not have push permissions.**
### A3. Create PR Body
Write the body to `/tmp/pr-body.md` using the template from `mem:workflow/creating-prs` (Description Body section):
**Verify:**
```bash
cat > /tmp/pr-body.md << 'EOF'
**Note:** This PR was created with AI assistance as part of the Penpot MCP self-improvement initiative.
## What
<one paragraph: the problem or feature, user-facing impact>
## Why
<root cause or motivation, why this change was necessary>
## How
<high-level approach, key technical decisions>
EOF
gh pr view <NUMBER> --repo penpot/penpot --json title,body
```
### A4. Create the PR
```bash
gh pr create --base main --project "Main" --title "<title>" --body-file /tmp/pr-body.md
```
---
## Workflow B: Updating an Existing PR
Use this when a PR already exists and you need to change its title or description (or both).
### B1. Find the PR Number
```bash
BRANCH=$(git branch --show-current)
gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number'
```
If the result is empty, there is no open PR for this branch — use Workflow A instead.
If multiple PRs match, pick the first one (typically there should only be one).
### B2. Determine What to Update
- **Title only:** use `--title "<new title>"`
- **Description (body) only:** write to `/tmp/pr-body.md` and use `--body-file /tmp/pr-body.md`
- **Both title and description:** supply both flags
### B3. Prepare Description (if updating)
Use the same template from `mem:workflow/creating-prs`:
```bash
cat > /tmp/pr-body.md << 'EOF'
**Note:** This PR was created with AI assistance as part of the Penpot MCP self-improvement initiative.
## What
<one paragraph: the problem or feature, user-facing impact>
## Why
<root cause or motivation, why this change was necessary>
## How
<high-level approach, key technical decisions>
EOF
```
### B4. Update the PR
```bash
gh pr edit <NUMBER> --title "<title>" --body-file /tmp/pr-body.md
```
Omit either flag if only one field is being updated.
**The LLM has permissions to edit PRs. No push required.**

View File

@ -0,0 +1,135 @@
---
name: planner
description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan (Context, Affected modules, Approach, Risks, Testing). Always output to the user; additionally save to plans/YYYY-MM-DD-<title>.md only when the calling agent has write permission.
---
# Planner
Read-only senior software architect role for Penpot. Produces structured
implementation plans that engineers or other agents can execute. Never writes
or modifies code.
## When to Use
- The user asks for a plan, design, or analysis of a feature or bug.
- The user wants to understand which parts of the codebase a task will touch.
- The user needs a step-by-step implementation plan with file paths, function
names, and test strategy.
- The user asks "how would I implement X?" or "what's involved in fixing Y?".
- The user is about to start non-trivial work and wants a bite-sized task
breakdown (DRY, YAGNI, TDD, frequent commits).
Do **not** use this skill to actually implement anything — it is read-only.
## 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 or
modify 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, tests,
docs they might need to check, and how to verify it. Give them the whole plan
as bite-sized tasks. DRY. YAGNI. TDD. Frequent commits.
Assume the implementer is a skilled developer, but knows almost nothing about
our toolset or problem domain. Assume they don't know good test design very
well.
Do **not** suggest commit messages or commit names anywhere in your plans or
responses — committing is the developer's responsibility.
## Required Reading Before Planning
Before drafting any plan, work through the project's own guidance:
1. Read `AGENTS.md` (root) for the project-level rules.
2. Read `.serena/memories/critical-info.md` (or the equivalent entry point) to
identify which modules are affected.
3. Read each affected module's core memory, e.g. `mem:frontend/core`,
`mem:backend/core`, `mem:common/core`, `mem:exporter/core`,
`mem:render-wasm/core`. Follow `mem:` references deeper as needed.
4. For frontend/backend work, check the relevant section's notes on lint,
format, and test commands so the plan can include them.
Skipping this step is the #1 cause of incorrect or incomplete plans.
## Requirements
- Analyze the codebase architecture and identify affected modules.
- Read `AGENTS.md` and the memory system conventions before drafting.
- Break down complex features or bugs into atomic, actionable steps.
- Propose solutions with clear rationale, trade-offs, and sequencing.
- Identify risks, edge cases, performance implications, and breaking changes.
- Apply DRY and KISS principles to the proposed implementation.
- Define a testing strategy aligned with each affected module's tooling.
## Constraints
- You are **analysis-only** — never create, edit, or delete source code.
- The only file write you may attempt is the plan itself, and only when the
calling agent has write permission (see "Plan Output"). If the write is
denied, deliver the plan in the response and move on.
- 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`, `bat`).
- Your output is a structured plan or analysis, ready for handoff to an
engineer agent or developer.
## Plan Output
The plan is always delivered in the response so the user sees it regardless
of which agent is running the skill.
Persistence is a **separate, best-effort step** that only runs when the
calling agent has `edit` write permission:
- **Has write permission** (e.g. `build`, `general`, `engineer`): in addition
to the in-response plan, save the plan to:
```
plans/YYYY-MM-DD-<plan-one-line-title>.md
```
Use today's date in the user's local timezone. The `<plan-one-line-title>`
slug is lowercase, hyphen-separated, and a short summary of the task
(e.g. `add-batch-get-profiles-for-file-comments`). Create the `plans/`
directory if it does not exist.
- **No write permission** (e.g. the built-in `plan` agent, which denies
`edit`): do not attempt to write the file — the write tool will be
rejected. Just deliver the plan in the response. The user can copy it into
`plans/...` manually if they want it persisted.
If the user explicitly provides a target file path, use that path instead of
the default `plans/YYYY-MM-DD-<slug>.md` (still subject to write permission).
How to detect write permission: try the write. If it is denied, treat the
plan as response-only and proceed — do not retry, do not ask the user, and do
not mention the failed write in the response.
## Output Format
Structure the plan as:
1. **Context** — What is the problem or feature request? Why is it needed?
2. **Affected modules** — Which parts of the codebase are involved? Reference
module paths and any `mem:` memories that were consulted.
3. **Approach** — Step-by-step implementation plan with file paths, function
names, and code shape where applicable. Group steps into atomic, ordered
tasks.
4. **Risks & considerations** — Edge cases, performance implications,
breaking changes, migration concerns, security implications.
5. **Testing strategy** — How to verify the implementation works correctly:
which test commands to run per module, what cases to cover, manual
verification steps, lint/format checks.
Each step in **Approach** should be small enough to be reviewed and committed
independently. Cite exact file paths (`path/to/file.ext:line` when useful) so
the implementer can navigate directly.
When the plan is purely analytical (e.g. a code review or feasibility study
with no implementation), skip the **Approach** section and lead with
**Findings** instead, keeping the rest of the structure.

View File

@ -0,0 +1,114 @@
---
name: refine-prompt
description: Refine and improve a user-supplied prompt for maximum clarity and effectiveness using prompt-engineering best practices and Penpot project context. Outputs a rewritten prompt (and brief rationale); never executes the prompt.
---
# Refine Prompt
Expert prompt-engineering pass on a user-supplied prompt. Takes a draft prompt
and returns a clearer, more effective, well-structured version — ready to be
used with any AI model. Never executes the prompt itself.
## When to Use
- The user shares a prompt and asks to improve, refine, polish, or rewrite it.
- The user asks "make this prompt better" or "can you clean this up?".
- The user wants to add structure, constraints, examples, or output format to
a vague prompt.
- The user wants a prompt adapted for a specific target model, audience, or
task type.
Do **not** use this skill to actually answer the prompt or do the task — it
only rewrites the prompt.
## 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.
You do **not** execute tasks. You do **not** write code. You only design and
refine prompts.
## Required Reading Before Refining
Before rewriting, internalize the project context the prompt will likely run
against:
1. Read `AGENTS.md` (root) for the project-level rules and conventions.
2. Read `.serena/memories/critical-info.md` (or the equivalent entry point) to
understand the module layout (`frontend`, `backend`, `common`,
`render-wasm`, `exporter`, `mcp`, `plugins`, `library`).
3. Skim the relevant module's core memory (`mem:frontend/core`,
`mem:backend/core`, etc.) when the prompt targets a specific module — this
lets you inject precise vocabulary, file conventions, and test commands
into the refined prompt.
This step matters most when the user is preparing a prompt *about* the
Penpot codebase. For generic prompts, focus on prompt-engineering principles
and only weave in Penpot context when it is clearly relevant.
## Requirements
- 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. Prefer to ask 14 questions at once
rather than one at a time.
- Rewrite the prompt using prompt-engineering best practices (see below).
- Preserve the user's original intent — do not change the underlying task.
- When the user provides Penpot project context, weave in the relevant
conventions, module paths, and tooling.
## 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."
- **Role framing**: When helpful, give the model a clear role
("You are a senior backend engineer...").
- **Tool awareness**: When the prompt targets an agentic model, mention
relevant tools (`grep`, `glob`, `read`, `bash`, etc.) so the model uses the
right surface.
## 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.
- If the user provides Penpot project context, prefer Penpot-specific
vocabulary over generic terms (e.g. name actual modules and `mem:`
references instead of "the codebase").
## Output Format
Deliver the result in the response as two clearly separated blocks:
1. **Refined prompt** — a single fenced code block (markdown ```) containing
the rewritten prompt, ready to copy and use.
2. **What changed (brief)** — a short bulleted list of the most important
changes you made and why (37 bullets max). Skip the rationale if the
changes are trivial.
If you asked clarifying questions, list them in a separate **Clarifying
questions** section above the refined prompt and stop — do not produce a
refined prompt until the user answers. If the user explicitly told you to
proceed without questions (e.g. "just rewrite it"), make reasonable
assumptions and note them under **Assumptions made** in the rationale block.
No file persistence — the refined prompt lives entirely in the response.

View File

@ -92,6 +92,10 @@ IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory.
* **Linting:** `pnpm run lint` from the repository root.
* **Formatting:** `pnpm run check-fmt`. Use `pnpm run fmt` to fix. Avoid unrelated whitespace diffs.
**Before linting:** if delimiter errors are suspected (after LLM edits), run
`tools/paren-repair.bb` on the affected files first. Delimiter errors produce
misleading linter/compiler output. See `mem:tools/paren-repair`.
## Testing
IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory.

View File

@ -48,7 +48,7 @@ Components, variants, and debugging:
Text and tests:
- Shared text data conversion, DraftJS compatibility, modern text content, and derived position data: `mem:common/text-subtleties`.
- Common test commands, helper conventions, production-path test mutations, and runtime coverage choices: `mem:common/test-setup`.
- Common test commands, helper conventions, production-path test mutations, and runtime coverage choices: `mem:common/testing`.
## Areas without focused memories

View File

@ -1,24 +1,27 @@
# Common Module Test Setup
# Common Testing and Verification
`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
## Unit tests
Common tests live under `common/test/common_tests/` and use `clojure.test`.
They are CLJC and run on both JVM and JS.
From `common/`:
- Full JVM test run: `clojure -M:dev:test`
- Full JS test run: `pnpm run test:quiet`
- Focus a JVM test namespace: `clojure -M:dev:test --focus common-tests.logic.variants-switch-test`
- Focus a JVM test var: `clojure -M:dev:test --focus common-tests.logic.variants-switch-test/test-basic-switch`
- Focus a JS test namespace: `pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test`
- Focus a JS test var: `pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute`
- Quiet logging during a JS run: append `--log-level warn` (or `trace|debug|info|warn|error`)
- Build JS test target only: `pnpm run build:test`
- After `pnpm run build:test`, direct compiled runner: `node target/tests/test.js --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute --log-level warn`
- Watch tests: `pnpm run watch:test`
```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.
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
@ -45,4 +48,4 @@ For geometry-sensitive tests, read `mem:common/geometry-invariants` before posit
## Debugging
Use `mem:common/component-debugging-recipes` for shape-tree dumps, undo/change inspection, and temporary live instrumentation recipes.
Use `mem:common/component-debugging-recipes` for shape-tree dumps, undo/change inspection, and temporary live instrumentation recipes.

View File

@ -14,6 +14,11 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
- Issues are also managed on Taiga. Read issues using the `read_taiga_issue` tool.
- Before writing code, analyze the task in depth and describe your plan. If the task is complex, break it down into atomic steps.
*After making changes, run the applicable lint and format checks for the affected module before considering the work done (per example `mem:backend/core` or `mem:frontend/core`).
- Align `let` binding values: when a `let` form has multiple bindings spanning
several lines, align the value forms to the same column with spaces.
- If you introduce delimiter errors (mismatched parens/brackets) in Clojure/CLJS files,
fix them with `tools/paren-repair.bb` BEFORE running lint/format checks.
See `mem:tools/paren-repair` for usage.
- Never run anything that destroys data without explicit permission, including `drop-devenv`, `docker compose down -v`, `docker volume rm ...`. The user's real work lives in the volumes of the shared infra.
# Project modules
@ -39,6 +44,9 @@ module. You can read it from `mem:<MODULE>/core`
When working on devenv startup, compose layout, instance config (`defaults.env`),
tmux session lifecycle, MinIO provisioning, or anything in `manage.sh`'s
`*-devenv` commands, read `mem:devenv/core`.
- `tools/` contains standalone dev utilities: `nrepl-eval.mjs` (backend REPL eval),
`paren-repair.bb` (delimiter-error fixer, see `mem:tools/paren-repair`), and
`taiga.py` / `gh.py` (issue management helpers).
- `experiments/` contains standalone experimental HTML/JS/scripts; treat it as non-core unless the user explicitly asks about it.
- `sample_media/` contains sample image/icon media and config used as fixtures/demo material; do not infer app behavior from it.

View File

@ -26,6 +26,11 @@ From `frontend/`:
- Format fix: `pnpm run fmt`, or targeted `fmt:clj` / `fmt:js` / `fmt:scss`.
- Translation formatting after i18n edits: `pnpm run translations`.
**Before linting:** if delimiter errors are suspected (after LLM edits, or
lint/compiler reports syntax errors), run `tools/paren-repair.bb` on the
affected files first. Delimiter errors produce misleading linter output.
See `mem:tools/paren-repair`.
## Focused memory routing
UI and packages:

View File

@ -10,6 +10,12 @@ You have access to two tools for finding errors in Clojure source code (which yo
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.
When delimiter errors are detected (typically from lint or compiler output),
fix the affected files with `tools/paren-repair.bb`. The `clj_check_parentheses`
MCP tool can also pinpoint the error location when available, but it is not
required — standard build errors are usually enough.
See `mem:tools/paren-repair`.
## Runtime patching with `set!`
Some frontend vars are deliberately mutable escape hatches for runtime instrumentation or circular-dependency patching.

View File

@ -0,0 +1,29 @@
# Paren-Repair
`tools/paren-repair.bb` fixes mismatched parentheses, brackets, and braces in
Clojure/ClojureScript files, then reformats them with cljfmt.
## When to use
- After LLM edits introduce broken delimiters — proactively run it on files
you just touched.
- When lint (clj-kondo), the Clojure compiler, or shadow-cljs report syntax
errors mentioning mismatched/unclosed delimiters, reader errors, or
unexpected EOF.
- Before running lint/format checks — delimiter errors make linter output
misleading. Fix them first, then lint.
## How to use
```bash
# File mode (in-place fix + format)
bb tools/paren-repair.bb path/to/file.clj
# Pipe mode (stdin → fixed code to stdout)
echo '(def x 1' | bb tools/paren-repair.bb
# Help
bb tools/paren-repair.bb --help
```
`bb` must be invoked from the repo root so the path `tools/paren-repair.bb` resolves.

View File

@ -15,7 +15,7 @@ automatically pull the identity from the local git config `user.name` and `user.
Body explaining what changed and why.
Co-authored-by: model-name <model-name@penpot.app>
AI-assisted-by: model-name
```
## Commit Type Emojis

View File

@ -1,12 +1,35 @@
# CHANGELOG
## 2.18.0
### :bug: Bugs fixed
- Fix MCP integration hanging when the Penpot tab is backgrounded or frozen by the browser [#10323](https://github.com/penpot/penpot/issues/10323) (PR: [#10392](https://github.com/penpot/penpot/pull/10392))
- Fix synced component copy not reflowing children after spacing token update [#9892](https://github.com/penpot/penpot/issues/9892)
- Fix spacebar activating pan mode while typing a comment (by @Krishcode264) [#10285](https://github.com/penpot/penpot/issues/10285) (PR: [#10287](https://github.com/penpot/penpot/pull/10287))
- Fix plugin API rejecting negative letterSpacing values (by @filipsajdak) [#9780](https://github.com/penpot/penpot/issues/9780) (PR: [#10257](https://github.com/penpot/penpot/pull/10257))
- Fix plugin API addTheme calls failing with the signature shown in the high-level overview [#10074](https://github.com/penpot/penpot/issues/10074) (PR: [#10359](https://github.com/penpot/penpot/pull/10359))
- Fix empty text shape not being deleted on editor exit [#10540](https://github.com/penpot/penpot/issues/10540) (PR: [#10541](https://github.com/penpot/penpot/pull/10541))
- Fix broken token pills showing wrong default state when not selected [#10524](https://github.com/penpot/penpot/issues/10524) (PR: [#10535](https://github.com/penpot/penpot/pull/10535))
- Replace hyphens with bullets in subscription benefits list [#10547](https://github.com/penpot/penpot/issues/10547) (PR: [#10523](https://github.com/penpot/penpot/pull/10523))
- Fix Chinese (zh-CN) translation showing wrong label for Intersection in board path menu (by @sawirricardo) [#10346](https://github.com/penpot/penpot/issues/10346) (PR: [#10381](https://github.com/penpot/penpot/pull/10381))
### :sparkles: New features & Enhancements
- Group toolbar drawing tools into shape and free-draw flyouts [#9316](https://github.com/penpot/penpot/issues/9316) (PR: [#9480](https://github.com/penpot/penpot/pull/9480), [#10354](https://github.com/penpot/penpot/pull/10354))
- Add outline stroke to Paths [#9961](https://github.com/penpot/penpot/issues/9961) (PR: [#8677](https://github.com/penpot/penpot/pull/8677))
- Make throwValidationErrors default to true for v2 manifest plugins [#10401](https://github.com/penpot/penpot/issues/10401) (PR: [#10433](https://github.com/penpot/penpot/pull/10433))
- Add dedicated Line and Arrow drawing tools (by @davidv399) [#9145](https://github.com/penpot/penpot/issues/9145) (PR: [#9146](https://github.com/penpot/penpot/pull/9146))
- Refactor wasm rulers and UI state [#10116](https://github.com/penpot/penpot/issues/10116) (PR: [#10461](https://github.com/penpot/penpot/pull/10461))
- Improve team invitations modal in the dashboard [#10484](https://github.com/penpot/penpot/issues/10484) (PR: [#10459](https://github.com/penpot/penpot/pull/10459))
## 2.17.0 (Unreleased)
### :boom: Breaking changes & Deprecations
### :rocket: Epics and highlights
- Render prototype viewer with WASM (Skia) engine instead of SVG [#10037](https://github.com/penpot/penpot/issues/10037) (PR: [#10038](https://github.com/penpot/penpot/pull/10038), [#10314](https://github.com/penpot/penpot/pull/10314))
- Render prototype viewer with WASM (Skia) engine instead of SVG [#10037](https://github.com/penpot/penpot/issues/10037) (PR: [#10038](https://github.com/penpot/penpot/pull/10038))
### :sparkles: New features & Enhancements
@ -40,6 +63,7 @@
- Add configurable resource limits to ImageMagick image processing [#10223](https://github.com/penpot/penpot/issues/10223) (PR: [#10240](https://github.com/penpot/penpot/pull/10240))
- Add resource limits to font processing child processes [#10234](https://github.com/penpot/penpot/issues/10234) (PR: [#10274](https://github.com/penpot/penpot/pull/10274))
- Add color variants and positioning to selection size badge (by @bittoby) [#10258](https://github.com/penpot/penpot/issues/10258) (PR: [#9210](https://github.com/penpot/penpot/pull/9210))
- Use hard reload for render engine switching in the workspace menu [#10441](https://github.com/penpot/penpot/issues/10441) (PR: [#10444](https://github.com/penpot/penpot/pull/10444))
### :bug: Bugs fixed
@ -108,7 +132,6 @@
- Fix export presets not being saved in view mode inspect panel [#9971](https://github.com/penpot/penpot/issues/9971) (PR: [#9972](https://github.com/penpot/penpot/pull/9972))
- Fix cropped outer stroke of rotated board in view mode [#9978](https://github.com/penpot/penpot/issues/9978) (PR: [#9979](https://github.com/penpot/penpot/pull/9979))
- Fix re-registration with same email showing verification prompt when email verification is disabled [#9998](https://github.com/penpot/penpot/issues/9998) (PR: [#9999](https://github.com/penpot/penpot/pull/9999))
- Fix mask with children not applying blur correctly [#10004](https://github.com/penpot/penpot/issues/10004) (PR: [#10028](https://github.com/penpot/penpot/pull/10028))
- Fix toggle color library visibility from color picker shortcut button [#10036](https://github.com/penpot/penpot/issues/10036) (PR: [#10129](https://github.com/penpot/penpot/pull/10129))
- Improve image rendering performance by making snapshots async [#10045](https://github.com/penpot/penpot/issues/10045) (PR: [#10150](https://github.com/penpot/penpot/pull/10150))
- Fix text layers wrapping to two lines after copy-pasting between files until edited [#10052](https://github.com/penpot/penpot/issues/10052) (PR: [#10081](https://github.com/penpot/penpot/pull/10081))
@ -123,9 +146,38 @@
- Fix guide hover feedback sticking or stopping after interaction [#10321](https://github.com/penpot/penpot/issues/10321) (PR: [#10333](https://github.com/penpot/penpot/pull/10333))
- Fix rulers visible in thumbnail rendering [#10328](https://github.com/penpot/penpot/issues/10328)
- Fix page switch blur disappearing early [#10332](https://github.com/penpot/penpot/issues/10332) (PR: [#10337](https://github.com/penpot/penpot/pull/10337))
- Fix WebGL italic font mismatch when custom font variants are mapped [#10060](https://github.com/penpot/penpot/issues/10060) (PR: [#10122](https://github.com/penpot/penpot/pull/10122))
- Fix open overlay board mispositioned with repeated image artifacts in viewer WebGL [#10180](https://github.com/penpot/penpot/issues/10180) (PR: [#10191](https://github.com/penpot/penpot/pull/10191))
- Fix color picker preview when browser has non-100% zoom level [#10265](https://github.com/penpot/penpot/issues/10265) (PR: [#10305](https://github.com/penpot/penpot/pull/10305))
- Fix negative margins no longer working [#10001](https://github.com/penpot/penpot/issues/10001) (PR: [#10353](https://github.com/penpot/penpot/pull/10353))
- Fix undo in properties panel reversing each drag step instead of the entire drag when adjusting numeric inputs [#10066](https://github.com/penpot/penpot/issues/10066) (PR: [#10193](https://github.com/penpot/penpot/pull/10193))
- Fix Plugin API reference token creation and token application failing when the target token set is inactive [#10070](https://github.com/penpot/penpot/issues/10070), [#10071](https://github.com/penpot/penpot/issues/10071) (PR: [#10297](https://github.com/penpot/penpot/pull/10297))
- Fix Plugin API rejecting numeric token values with a generic validation error [#10073](https://github.com/penpot/penpot/issues/10073) (PR: [#10270](https://github.com/penpot/penpot/pull/10270))
- Fix Plugin API whole-page export failing with a generic HTTP error [#10076](https://github.com/penpot/penpot/issues/10076) (PR: [#10148](https://github.com/penpot/penpot/pull/10148))
- Fix Plugin API applyToken failing on padding properties [#10077](https://github.com/penpot/penpot/issues/10077) (PR: [#10087](https://github.com/penpot/penpot/pull/10087))
- Fix Plugin API crash when instancing or detaching variants [#10099](https://github.com/penpot/penpot/issues/10099) (PR: [#10140](https://github.com/penpot/penpot/pull/10140))
- Fix black square appearing on first view mode launch [#10188](https://github.com/penpot/penpot/issues/10188) (PR: [#10210](https://github.com/penpot/penpot/pull/10210))
- Fix Plugin API openPage() not switching active page within the same execution [#10195](https://github.com/penpot/penpot/issues/10195) (PR: [#10085](https://github.com/penpot/penpot/pull/10085))
- Fix Plugin API appending shapes to the active page instead of the specified .root [#10196](https://github.com/penpot/penpot/issues/10196) (PR: [#10271](https://github.com/penpot/penpot/pull/10271))
- Fix Plugin API createPage() not making the new page active synchronously [#10197](https://github.com/penpot/penpot/issues/10197) (PR: [#10085](https://github.com/penpot/penpot/pull/10085))
- Fix Plugin API Flow.startingBoard returning null after createFlow [#10203](https://github.com/penpot/penpot/issues/10203) (PR: [#10244](https://github.com/penpot/penpot/pull/10244))
- Fix Plugin API page.flows returning null instead of empty array when page has no flows [#10204](https://github.com/penpot/penpot/issues/10204) (PR: [#10246](https://github.com/penpot/penpot/pull/10246))
- Fix Plugin API auto-creating a stray 'Flow 1' when wiring interactions [#10205](https://github.com/penpot/penpot/issues/10205) (PR: [#10231](https://github.com/penpot/penpot/pull/10231))
- Fix Plugin API createText throwing error when called with an empty string [#10206](https://github.com/penpot/penpot/issues/10206) (PR: [#10219](https://github.com/penpot/penpot/pull/10219))
- Fix export rendering auto-width text with a substituted fallback font [#10208](https://github.com/penpot/penpot/issues/10208) (PR: [#10238](https://github.com/penpot/penpot/pull/10238))
- Expose 'Fix when scrolling' (sticky) constraint in the Plugin API [#10209](https://github.com/penpot/penpot/issues/10209) (PR: [#10218](https://github.com/penpot/penpot/pull/10218))
- Fix long typography token name in remap modal [#10301](https://github.com/penpot/penpot/issues/10301) (PR: [#10356](https://github.com/penpot/penpot/pull/10356))
- Fix 'Go to your Penpot' link on error page redirecting to the same team instead of user home [#10324](https://github.com/penpot/penpot/issues/10324) (PR: [#10322](https://github.com/penpot/penpot/pull/10322))
- Fix premature WASM view-interaction end during pan/zoom pause [#10424](https://github.com/penpot/penpot/issues/10424) (PR: [#10425](https://github.com/penpot/penpot/pull/10425))
- Fix emojis without fill but with outer stroke not being rendered [#10473](https://github.com/penpot/penpot/issues/10473) (PR: [#10519](https://github.com/penpot/penpot/pull/10519))
- Fix texts with lots of emojis not being rendered across multiple tiles [#10474](https://github.com/penpot/penpot/issues/10474) (PR: [#10504](https://github.com/penpot/penpot/pull/10504))
- Fix artifacts in texts with inner strokes [#10476](https://github.com/penpot/penpot/issues/10476) (PR: [#10509](https://github.com/penpot/penpot/pull/10509))
## 2.16.2
### :bug: Bugs fixed
- Fix error 500 when submitting the contact form [#10178](https://github.com/penpot/penpot/issues/10178) (PR: [#10419](https://github.com/penpot/penpot/pull/10419))
- Fix text editor modifying content and detaching applied typography tokens [#10389](https://github.com/penpot/penpot/issues/10389) (PR: [#10402](https://github.com/penpot/penpot/pull/10402))
## 2.16.1
@ -283,6 +335,8 @@
- Fix publishing or unpublishing file as library failing with unexpected state found error [#10094](https://github.com/penpot/penpot/issues/10094) (PR: [#10093](https://github.com/penpot/penpot/pull/10093))
- Fix team invitation failing when email address contains consecutive dots in domain [#10097](https://github.com/penpot/penpot/issues/10097) (PR: [#10096](https://github.com/penpot/penpot/pull/10096))
- Add detailed error messages for unspecified import errors [#9759](https://github.com/penpot/penpot/issues/9759) (PR: [#9886](https://github.com/penpot/penpot/pull/9886))
- Fix mask with children not applying blur correctly [#10004](https://github.com/penpot/penpot/issues/10004) (PR: [#10028](https://github.com/penpot/penpot/pull/10028))
- Fix WebGL italic font mismatch when custom font variants are mapped [#10060](https://github.com/penpot/penpot/issues/10060) (PR: [#10122](https://github.com/penpot/penpot/pull/10122))
## 2.15.4

View File

@ -6,7 +6,7 @@
org.clojure/clojure {:mvn/version "1.12.5"}
org.clojure/tools.namespace {:mvn/version "1.5.1"}
com.github.luben/zstd-jni {:mvn/version "1.5.7-10"}
com.github.luben/zstd-jni {:mvn/version "1.5.7-11"}
io.prometheus/simpleclient {:mvn/version "0.16.0"}
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"}
@ -39,7 +39,7 @@
metosin/reitit-core {:mvn/version "0.10.1"}
nrepl/nrepl {:mvn/version "1.7.0"}
org.postgresql/postgresql {:mvn/version "42.7.11"}
org.postgresql/postgresql {:mvn/version "42.7.12"}
org.xerial/sqlite-jdbc {:mvn/version "3.53.2.0"}
com.zaxxer/HikariCP {:mvn/version "7.0.2"}
@ -63,14 +63,16 @@
;; Pretty Print specs
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
software.amazon.awssdk/s3 {:mvn/version "2.46.7"}
software.amazon.awssdk/sts {:mvn/version "2.46.7"}}
software.amazon.awssdk/s3 {:mvn/version "2.46.18"}
software.amazon.awssdk/sts {:mvn/version "2.46.18"}}
:paths ["src" "resources" "target/classes"]
:aliases
{:dev
{:extra-deps
{com.bhauman/rebel-readline {:mvn/version "0.1.7"}
{:jvm-opts ["--sun-misc-unsafe-memory-access=allow"
"--enable-native-access=ALL-UNNAMED"]
:extra-deps
{com.bhauman/rebel-readline {:mvn/version "0.1.11"}
clojure-humanize/clojure-humanize {:mvn/version "0.2.2"}
org.clojure/data.csv {:mvn/version "1.1.1"}
com.clojure-goes-fast/clj-async-profiler {:mvn/version "2.0.0-beta1"}
@ -84,9 +86,7 @@
:test
{:main-opts ["-m" "kaocha.runner"]
:jvm-opts ["-Dlog4j2.configurationFile=log4j2-devenv-repl.xml"
"--sun-misc-unsafe-memory-access=allow"
"--enable-native-access=ALL-UNNAMED"]
:jvm-opts ["-Dlog4j2.configurationFile=log4j2-devenv-repl.xml"]
:extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"}}}
:outdated

View File

@ -4,19 +4,19 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"
},
"dependencies": {
"luxon": "^3.4.4",
"sax": "^1.4.1"
"sax": "^1.6.0"
},
"devDependencies": {
"nodemon": "^3.1.2",
"nodemon": "^3.1.14",
"source-map-support": "^0.5.21",
"ws": "^8.17.0"
"ws": "^8.21.0"
},
"scripts": {
"lint": "clj-kondo --parallel --lint ../common/src src/",

84
backend/pnpm-lock.yaml generated
View File

@ -12,18 +12,18 @@ importers:
specifier: ^3.4.4
version: 3.7.2
sax:
specifier: ^1.4.1
version: 1.4.3
specifier: ^1.6.0
version: 1.6.0
devDependencies:
nodemon:
specifier: ^3.1.2
version: 3.1.11
specifier: ^3.1.14
version: 3.1.14
source-map-support:
specifier: ^0.5.21
version: 0.5.21
ws:
specifier: ^8.17.0
version: 8.18.3
specifier: ^8.21.0
version: 8.21.0
packages:
@ -31,15 +31,17 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
balanced-match@4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
brace-expansion@5.0.7:
resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
engines: {node: 18 || 20 || >=22}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
@ -52,9 +54,6 @@ packages:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@ -104,14 +103,15 @@ packages:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
nodemon@3.1.11:
resolution: {integrity: sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==}
nodemon@3.1.14:
resolution: {integrity: sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==}
engines: {node: '>=10'}
hasBin: true
@ -119,8 +119,8 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
picomatch@2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
picomatch@2.3.2:
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
pstree.remy@1.1.8:
@ -130,11 +130,12 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
sax@1.4.3:
resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==}
sax@1.6.0:
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
engines: {node: '>=11.0.0'}
semver@7.7.3:
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
hasBin: true
@ -164,8 +165,8 @@ packages:
undefsafe@2.0.5:
resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==}
ws@8.18.3:
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
ws@8.21.0:
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@ -181,16 +182,15 @@ snapshots:
anymatch@3.1.3:
dependencies:
normalize-path: 3.0.0
picomatch: 2.3.1
picomatch: 2.3.2
balanced-match@1.0.2: {}
balanced-match@4.0.4: {}
binary-extensions@2.3.0: {}
brace-expansion@1.1.12:
brace-expansion@5.0.7:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
balanced-match: 4.0.4
braces@3.0.3:
dependencies:
@ -210,8 +210,6 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
concat-map@0.0.1: {}
debug@4.4.3(supports-color@5.5.0):
dependencies:
ms: 2.1.3
@ -247,20 +245,20 @@ snapshots:
luxon@3.7.2: {}
minimatch@3.1.2:
minimatch@10.2.5:
dependencies:
brace-expansion: 1.1.12
brace-expansion: 5.0.7
ms@2.1.3: {}
nodemon@3.1.11:
nodemon@3.1.14:
dependencies:
chokidar: 3.6.0
debug: 4.4.3(supports-color@5.5.0)
ignore-by-default: 1.0.1
minimatch: 3.1.2
minimatch: 10.2.5
pstree.remy: 1.1.8
semver: 7.7.3
semver: 7.8.5
simple-update-notifier: 2.0.0
supports-color: 5.5.0
touch: 3.1.1
@ -268,21 +266,21 @@ snapshots:
normalize-path@3.0.0: {}
picomatch@2.3.1: {}
picomatch@2.3.2: {}
pstree.remy@1.1.8: {}
readdirp@3.6.0:
dependencies:
picomatch: 2.3.1
picomatch: 2.3.2
sax@1.4.3: {}
sax@1.6.0: {}
semver@7.7.3: {}
semver@7.8.5: {}
simple-update-notifier@2.0.0:
dependencies:
semver: 7.7.3
semver: 7.8.5
source-map-support@0.5.21:
dependencies:
@ -303,4 +301,4 @@ snapshots:
undefsafe@2.0.5: {}
ws@8.18.3: {}
ws@8.21.0: {}

View File

@ -195,21 +195,39 @@
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="20" height="20" style="display:inline-block;vertical-align:middle;">
<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="{% if organization.logo %}{{organization.logo}}{% else %}{{organization.avatar-bg-url}}{% endif %}"
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">
background="{% if organization.logo %}{{organization.logo}}{% else %}{{organization.avatar-bg-url}}{% endif %}"
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">
{% if organization.initials %}{{organization.initials}}{% endif %}
</td>
</tr>
</table>
<span style="display:inline-block; vertical-align: middle;padding-left:5px;height:20px;line-height: 20px;">
{{ organization.name|abbreviate:50 }}
<span
style="display:inline-block; vertical-align: middle;padding-left:5px;height:20px;line-height: 20px;">
{{ organization.name|abbreviate:50 }}
</span>
</div>
</td>
</tr>
{% if organization.sso-active %}
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
"{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its
teams and files goes
through your organization's identity provider. If you can't get in, your account probably isn't
in the directory yet.
To get access, contact the organization owner.
</div>
</td>
</tr>
{% endif %}
<tr>
<td align="center" vertical-align="middle"
style="font-size:0px;padding:10px 25px;word-break:break-word;">

View File

@ -1,6 +1,11 @@
Hello!
{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization.name|abbreviate:25 }}”.
{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization.name|abbreviate:50 }}”.
{% if organization.sso-active %}
"{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its teams and files goes
through your organization's identity provider. If you can't get in, your account probably isn't in the directory yet. To get access, contact the organization owner.
{% endif %}
Accept invitation using this link:

View File

@ -186,10 +186,26 @@
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”{% if organization %}
part of the organization “{{ organization|abbreviate:25 }}”{% endif %}.</div>
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”{% if
organization %}
part of the organization “{{ organization.name|abbreviate:50 }}”{% endif %}.</div>
</td>
</tr>
{% if organization.sso-active %}
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
"{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to
its
teams and files goes
through your organization's identity provider. If you can't get in, your account probably isn't
in the directory yet.
To get access, contact the organization owner.
</div>
</td>
</tr>
{% endif %}
<tr>
<td align="center" vertical-align="middle"
style="font-size:0px;padding:10px 25px;word-break:break-word;">
@ -241,4 +257,4 @@
</div>
</body>
</html>
</html>

View File

@ -1,6 +1,11 @@
Hello!
{{invited-by|abbreviate:25}} has invited you to join the team "{{ team|abbreviate:25 }}"{% if organization %}, part of the organization "{{ organization|abbreviate:25 }}"{% endif %}.
{{invited-by|abbreviate:25}} has invited you to join the team "{{ team|abbreviate:25 }}"{% if organization %}, part of the organization "{{ organization.name|abbreviate:50 }}"{% endif %}.
{% if organization.sso-active %}
"{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its teams and files goes
through your organization's identity provider. If you can't get in, your account probably isn't in the directory yet. To get access, contact the organization owner.
{% endif %}
Accept invitation using this link:

View File

@ -0,0 +1,223 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>
</title>
<!--[if !mso]><!-- -->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Source%20Sans%20Pro" rel="stylesheet" type="text/css">
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Source%20Sans%20Pro);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
.mj-column-px-425 {
width: 425px !important;
max-width: 425px;
}
}
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
<body style="background-color:#E5E5E5;">
<div style="background-color:#E5E5E5;">
<!--[if mso | IE]>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:97px;">
<img height="32" src="{{ public-uri }}/images/email/logo-penpot.svg"
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
width="97" />
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
Hi{% if user-name %} {{ user-name|abbreviate:25 }}{% endif %},
</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
"{{ organization-name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its
teams and files goes through your organization's identity provider. If you can't get in, your
account probably isn't in the directory yet. To get access, contact the organization owner.
</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
The Penpot team.</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
{% include "app/email/includes/footer.html" %}
</div>
</body>
</html>

View File

@ -0,0 +1 @@
“{{ organization-name|abbreviate:25 }}” has set up single sign-on (SSO) in Penpot

View File

@ -0,0 +1,7 @@
Hello!
"{{ organization-name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its teams and files goes
through your organization's identity provider. If you can't get in, your account probably isn't in the directory yet. To get access, contact the organization owner.
The Penpot team.

View File

@ -761,6 +761,16 @@
;; ORG SSO HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- non-blank-uri
[value]
(when-not (str/blank? value) value))
(defn org-sso-discovery-uri
"Return the OIDC discovery URI from an org SSO config, preferring :issuer."
[sso]
(or (non-blank-uri (:issuer sso))
(non-blank-uri (:base-url sso))))
(defn prepare-org-sso-provider
"Build an OIDC provider map dynamically from the Nitrate org SSO config.
Uses OIDC discovery via :base-url (or :issuer as fallback) when
@ -770,12 +780,96 @@
{:type "oidc"
:client-id client-id
:client-secret client-secret
:base-uri (some-> (or base-url issuer)
:base-uri (some-> (or (non-blank-uri base-url)
(non-blank-uri issuer))
(str/rtrim "/")
(str "/"))
:scopes (into default-oidc-scopes (or scopes #{}))
:skip-ssrf-check? true}))
(defn build-org-sso-auth-redirect-uri
"Build the OIDC authorization redirect URI for an organization SSO config.
Raises if the config is incomplete or OIDC discovery fails."
[cfg sso & {:keys [dest-url organization-id provider]}]
(let [organization-id (or organization-id (:organization-id sso))
issuer (org-sso-discovery-uri sso)
dest-url (or dest-url (str (cf/get :public-uri)))]
(when-not issuer
(ex/raise :type :validation
:code :invalid-sso-config
:hint "missing issuer or base-url"))
(let [oidc-provider (or provider (prepare-org-sso-provider cfg sso))
state-token (tokens/generate cfg {:iss "oidc"
:dest-url dest-url
:organization-id organization-id
:issuer issuer
:exp (ct/in-future "4h")})]
(build-auth-redirect-uri oidc-provider state-token))))
(def ^:private probe-auth-code "penpot-sso-config-probe")
(defn- decode-token-error-response
[body]
(when (and (string? body) (pos? (count body)))
(try
(json/decode body)
(catch Throwable _ nil))))
(defn- token-endpoint-error
[response]
(some-> response :body decode-token-error-response :error d/name))
(defn- token-endpoint-error-description
[response]
(some-> response :body decode-token-error-response :error-description))
(defn- token-endpoint-valid-client-error?
"Token endpoint rejected the dummy auth code but accepted the client credentials."
[response]
(= "invalid_grant" (token-endpoint-error response)))
(defn- token-endpoint-invalid-client-error?
"Token endpoint rejected the client credentials."
[{:keys [status] :as response}]
(let [error (token-endpoint-error response)
description (str/lower (or (token-endpoint-error-description response) ""))]
(or (= status 401)
(#{"invalid_client" "unauthorized_client"} error)
(and (= error "access_denied")
(str/includes? description "unauthorized")))))
(defn- probe-org-sso-client-credentials
"Probe the token endpoint with a dummy authorization code.
Valid client credentials are expected to answer with `invalid_grant`."
[cfg provider]
(let [params {:client_id (:client-id provider)
:client_secret (:client-secret provider)
:code probe-auth-code
:grant_type "authorization_code"
:redirect_uri (build-redirect-uri)}
req {:method :post
:headers {"content-type" "application/x-www-form-urlencoded"
"accept" "application/json"}
:uri (:token-uri provider)
:body (u/map->query-string params)}
response (http/req cfg req {:skip-ssrf-check? (:skip-ssrf-check? provider)})]
(cond
(token-endpoint-valid-client-error? response) true
(token-endpoint-invalid-client-error? response) false
:else false)))
(defn is-organization-sso-config-valid?
"Return true when the SSO config can be discovered, can build a login URL,
and the client credentials are accepted by the token endpoint."
[cfg sso]
(try
(if (org-sso-discovery-uri sso)
(let [provider (prepare-org-sso-provider cfg sso)]
(and (build-org-sso-auth-redirect-uri cfg sso :provider provider)
(probe-org-sso-client-credentials cfg provider)))
false)
(catch Throwable _ false)))
(defn- auth-handler
[cfg {:keys [params] :as request}]
(let [provider (resolve-provider cfg params)

View File

@ -419,10 +419,19 @@
:id ::change-email
:schema schema:change-email))
(def ^:private schema:organization-data
[:map
[:name ::sm/text]
[:initials {:optional true} [:maybe :string]]
[:logo {:optional true} [:maybe ::sm/uri]]
[:avatar-bg-url {:optional true} [:maybe ::sm/uri]]
[:sso-active {:optional true} [:maybe ::sm/boolean]]])
(def ^:private schema:invite-to-team
[:map
[:invited-by ::sm/text]
[:team ::sm/text]
[:organization {:optional true} [:maybe schema:organization-data]]
[:token ::sm/text]])
(def invite-to-team
@ -431,13 +440,6 @@
:id ::invite-to-team
:schema schema:invite-to-team))
(def ^:private schema:organization-data
[:map
[:name ::sm/text]
[:initials [:maybe :string]]
[:logo [:maybe ::sm/uri]]
[:avatar-bg-url [:maybe ::sm/uri]]])
(def ^:private schema:invite-to-org
[:map
[:invited-by ::sm/text]
@ -451,7 +453,16 @@
:id ::invite-to-org
:schema schema:invite-to-org))
(def ^:private schema:organization-setup-sso
[:map
[:user-name {:optional true} [:maybe ::sm/text]]
[:organization-name ::sm/text]])
(def organization-setup-sso
"Email when an organization set up SSO"
(template-factory
:id ::organization-setup-sso
:schema schema:organization-setup-sso))
(def ^:private schema:renewal-notice
[:map

View File

@ -88,7 +88,8 @@
#{:session-id
:password
:old-password
:token})
:token
:client-secret})
(defn extract-utm-params
"Extracts additional data from params and namespace them under

View File

@ -14,23 +14,36 @@
[app.common.schema :as sm]
[app.common.schema.generators :as sg]
[app.common.time :as ct]
[app.common.types.organization :as cto]
[app.common.types.organization :as cto
:refer [schema:nitrate-sso]]
[app.common.uri :as u]
[app.config :as cf]
[app.http.client :as http]
[app.http.session :as session]
[app.rpc :as-alias rpc]
[app.setup :as-alias setup]
[app.util.cache :as cache]
[clojure.core :as c]
[clojure.string :as str]
[integrant.core :as ig]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- join-path-segments
"Build a single relative path from Nitrate URI segments, normalizing slashes."
[segments]
(let [path (->> segments (map str) (str/join "/"))]
(->> (str/split path #"/")
(remove str/blank?)
(str/join "/"))))
(defn- join-base-uri
"Join path segments to a base URI."
[base-uri & segments]
(apply u/join (u/ensure-path-slash base-uri) segments))
(u/join (u/ensure-path-slash base-uri)
(join-path-segments segments)))
(defn- generate-nitrate-uri
"Joins relative path segments to the Nitrate backend URI.
@ -266,7 +279,6 @@
profile-id)
schema:profile-org params))
(defn- get-org-summary-api
[cfg {:keys [organization-id] :as params}]
(request-to-nitrate cfg :get
@ -418,17 +430,6 @@
[:permissions [:map-of :keyword :string]]]
params))
(def ^:private schema:nitrate-sso
[:map
[:organization-id ::sm/uuid]
[:active [:maybe :boolean]]
[:provider [:maybe :string]]
[:client-id [:maybe :string]]
[:base-url [:maybe :string]]
[:client-secret [:maybe :string]]
[:issuer [:maybe :string]]
[:scopes [:maybe [::sm/set ::sm/text]]]])
(defn- get-org-sso-api
"Fetches the SSO configuration for an organization from Nitrate."
[cfg {:keys [organization-id] :as params}]
@ -496,6 +497,47 @@
;; UTILS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defonce ^:private team-org-owner-cache
;; Short TTL: permission checks run on the read path, so we avoid an
;; HTTP call to nitrate per check. The org owner of a team rarely
;; changes, and stale entries only grant read access for a few seconds.
(cache/create :expire "30s" :max-size 2048))
(defn- nitrate-client?
"True when `cfg` is a config map carrying the nitrate client (i.e. not
a raw db connection/pool passed by an internal caller)."
[cfg]
(and (map? cfg) (some? (get cfg ::client))))
(def ^:private cache-miss ::no-org-owner)
(defn- get-team-org-owner-id
"Returns the organization owner-id for `team-id`, or nil. Cached
briefly, including negative results (teams with no organization) so
repeated unauthorized probes don't each hit nitrate."
[cfg team-id]
(let [owner-id (cache/get team-org-owner-cache team-id
(fn [team-id]
(let [team-with-org (call cfg :get-team-org {:team-id team-id})]
(or (get-in team-with-org [:organization :owner-id])
cache-miss))))]
(when-not (= owner-id cache-miss)
owner-id)))
(defn organization-owner-of-team?
"True if `profile-id` is the owner of the organization that owns
`team-id`. Used to grant non-member org owners read-only access to the
teams of their organizations. `cfg` must be a config map with the
nitrate client; raw db connections/pools yield false so internal
callers are unaffected. Returns false when the :nitrate flag is off."
[cfg profile-id team-id]
(boolean
(when (and (contains? cf/flags :nitrate)
(nitrate-client? cfg)
(some? team-id)
(some? profile-id))
(= profile-id (get-team-org-owner-id cfg team-id)))))
(defn sso-session-authorized?
"Fetches the org-SSO config for the given organization or team and checks
whether the HTTP request has a valid session entry for it. Returns a map

View File

@ -74,8 +74,8 @@
::doc/changes [["2.12" "Remove version parameter, only one version is supported"]]
::webhooks/event? true
::sm/params schema:export-binfile}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
(files/check-read-permissions! pool profile-id file-id)
[cfg {:keys [::rpc/profile-id file-id] :as params}]
(files/check-read-permissions! cfg profile-id file-id)
(sse/response (partial export-binfile cfg params)))
;; --- Command: import-binfile

View File

@ -230,8 +230,8 @@
{::doc/added "1.15"
::sm/params schema:get-comment-threads}
[cfg {:keys [::rpc/profile-id file-id share-id] :as params}]
(db/run! cfg (fn [{:keys [::db/conn]}]
(files/check-comment-permissions! conn profile-id file-id share-id)
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(get-comment-threads conn profile-id file-id))))
(defn- get-comment-threads-sql
@ -328,8 +328,8 @@
{::doc/added "1.15"
::sm/params schema:get-comment-thread}
[cfg {:keys [::rpc/profile-id file-id id share-id] :as params}]
(db/run! cfg (fn [{:keys [::db/conn]}]
(files/check-comment-permissions! conn profile-id file-id share-id)
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(some-> (db/exec-one! conn [sql:get-comment-thread profile-id file-id id])
(decode-row)))))
@ -347,9 +347,9 @@
{::doc/added "1.15"
::sm/params schema:get-comments}
[cfg {:keys [::rpc/profile-id thread-id share-id]}]
(db/run! cfg (fn [{:keys [::db/conn]}]
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(let [{:keys [file-id]} (get-comment-thread conn thread-id)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(files/check-comment-permissions! cfg profile-id file-id share-id)
(get-comments conn thread-id)))))
(def sql:get-comments
@ -406,8 +406,8 @@
::doc/changes ["1.15" "Imported from queries and renamed."]
::sm/params schema:get-profiles-for-file-comments}
[cfg {:keys [::rpc/profile-id file-id share-id]}]
(db/run! cfg (fn [{:keys [::db/conn]}]
(files/check-comment-permissions! conn profile-id file-id share-id)
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(get-file-comments-users conn file-id profile-id))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@ -534,9 +534,9 @@
{::doc/added "1.15"
::sm/params schema:update-comment-thread-status
::db/transaction true}
[{:keys [::db/conn]} {:keys [::rpc/profile-id id share-id]}]
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id share-id]}]
(let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(files/check-comment-permissions! cfg profile-id file-id share-id)
(upsert-comment-thread-status! conn profile-id id)))
;; --- COMMAND: Update Comment Thread
@ -552,9 +552,9 @@
{::doc/added "1.15"
::sm/params schema:update-comment-thread
::db/transaction true}
[{:keys [::db/conn]} {:keys [::rpc/profile-id id is-resolved share-id]}]
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id is-resolved share-id]}]
(let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(files/check-comment-permissions! cfg profile-id file-id share-id)
(db/update! conn :comment-thread
{:is-resolved is-resolved}
{:id id})
@ -582,7 +582,7 @@
{:keys [team-id project-id] :as file}
(get-file cfg file-id page-id)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(files/check-comment-permissions! cfg profile-id file-id share-id)
(quotes/check! cfg {::quotes/id ::quotes/comments-per-file
::quotes/profile-id profile-id
@ -653,7 +653,7 @@
{:keys [file-id page-id] :as thread}
(get-comment-thread conn thread-id ::sql/for-update true)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(files/check-comment-permissions! cfg profile-id file-id share-id)
;; Don't allow edit comments to not owners
(when-not (= owner-id profile-id)
@ -690,9 +690,9 @@
{::doc/added "1.15"
::sm/params schema:delete-comment-thread
::db/transaction true}
[{:keys [::db/conn]} {:keys [::rpc/profile-id id share-id]}]
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id share-id]}]
(let [{:keys [owner-id file-id] :as thread} (get-comment-thread conn id ::sql/for-update true)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(files/check-comment-permissions! cfg profile-id file-id share-id)
(when-not (= owner-id profile-id)
(ex/raise :type :validation
:code :not-allowed))
@ -713,14 +713,14 @@
{::doc/added "1.15"
::sm/params schema:delete-comment
::db/transaction true}
[{:keys [::db/conn]} {:keys [::rpc/profile-id id share-id]}]
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id share-id]}]
(let [{:keys [owner-id thread-id] :as comment}
(get-comment conn id ::sql/for-update true)
{:keys [file-id]}
(get-comment-thread conn thread-id)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(files/check-comment-permissions! cfg profile-id file-id share-id)
(when-not (= owner-id profile-id)
(ex/raise :type :validation
:code :not-allowed))
@ -743,9 +743,9 @@
{::doc/added "1.15"
::sm/params schema:update-comment-thread-position
::db/transaction true}
[{:keys [::db/conn]} {:keys [::rpc/profile-id ::rpc/request-at id position frame-id share-id]}]
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at id position frame-id share-id]}]
(let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(files/check-comment-permissions! cfg profile-id file-id share-id)
(db/update! conn :comment-thread
{:modified-at request-at
:position (db/pgpoint position)
@ -767,9 +767,9 @@
{::doc/added "1.15"
::sm/params schema:update-comment-thread-frame
::db/transaction true}
[{:keys [::db/conn]} {:keys [::rpc/profile-id ::rpc/request-at id frame-id share-id]}]
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at id frame-id share-id]}]
(let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(files/check-comment-permissions! cfg profile-id file-id share-id)
(db/update! conn :comment-thread
{:modified-at request-at
:frame-id frame-id}

View File

@ -54,7 +54,6 @@
(eml/send! {::eml/conn pool
::eml/factory eml/user-feedback
:from (cf/get :smtp-default-from)
:to destination
:reply-to (:email profile)
:email (:email profile)

View File

@ -84,10 +84,10 @@
(perms/make-edition-predicate-fn bfc/get-file-permissions))
(def has-read-permissions?
(perms/make-read-predicate-fn bfc/get-file-permissions))
(perms/make-read-predicate-fn perms/get-file-read-permissions))
(def has-comment-permissions?
(perms/make-comment-predicate-fn bfc/get-file-permissions))
(perms/make-comment-predicate-fn perms/get-file-read-permissions))
(def check-edition-permissions!
(perms/make-check-fn has-edit-permissions?))
@ -99,8 +99,8 @@
;; explicit comment permissions through the share-id
(defn check-comment-permissions!
[conn profile-id file-id share-id]
(let [perms (bfc/get-file-permissions conn profile-id file-id share-id)
[cfg profile-id file-id share-id]
(let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)
can-read (has-read-permissions? perms)
can-comment (has-comment-permissions? perms)]
(when-not (or can-read can-comment)
@ -152,7 +152,7 @@
(defn- get-minimal-file-with-perms
[cfg {:keys [:id ::rpc/profile-id]}]
(let [mfile (get-minimal-file cfg id)
perms (bfc/get-file-permissions cfg profile-id id)]
perms (perms/get-file-read-permissions cfg profile-id id)]
(assoc mfile :permissions perms)))
(defn get-file-etag
@ -171,7 +171,7 @@
::sm/params schema:get-file
::sm/result schema:file-with-permissions
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id project-id] :as params}]
[cfg {:keys [::rpc/profile-id id project-id] :as params}]
;; The COND middleware makes initial request for a file and
;; permissions when the incoming request comes with an
;; ETAG. When ETAG does not matches, the request is resolved
@ -179,10 +179,10 @@
;; will be already prefetched and we just reuse them instead
;; of making an additional database queries.
(let [perms (or (:permissions (::cond/object params))
(bfc/get-file-permissions conn profile-id id))]
(perms/get-file-read-permissions cfg profile-id id))]
(check-read-permissions! perms)
(let [team (teams/get-team conn
(let [team (teams/get-team cfg
:profile-id profile-id
:project-id project-id
:file-id id)
@ -242,7 +242,7 @@
::sm/result schema:file-fragment}
[cfg {:keys [::rpc/profile-id file-id fragment-id share-id]}]
(db/run! cfg (fn [cfg]
(let [perms (bfc/get-file-permissions cfg profile-id file-id share-id)]
(let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)]
(check-read-permissions! perms)
(-> (get-file-fragment cfg file-id fragment-id)
(rph/with-http-cache long-cache-duration))))))
@ -286,7 +286,7 @@
::sm/params schema:get-project-files
::sm/result schema:files}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id]}]
(projects/check-read-permissions! pool profile-id project-id)
(projects/check-read-permissions! cfg profile-id project-id)
(get-project-files pool project-id))
;; --- COMMAND QUERY: has-file-libraries
@ -304,7 +304,7 @@
::sm/result ::sm/boolean}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}]
(dm/with-open [conn (db/open pool)]
(check-read-permissions! pool profile-id file-id)
(check-read-permissions! cfg profile-id file-id)
(get-has-file-libraries conn file-id)))
(def ^:private sql:has-file-libraries
@ -337,7 +337,7 @@
::sm/result ::sm/int}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}]
(dm/with-open [conn (db/open pool)]
(check-read-permissions! pool profile-id file-id)
(check-read-permissions! cfg profile-id file-id)
(get-library-usage conn file-id)))
(def ^:private sql:get-library-usage
@ -387,7 +387,7 @@
:code :params-validation
:hint "page-id is required when object-id is provided"))
(let [perms (bfc/get-file-permissions conn profile-id file-id share-id)
(let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)
file (bfc/get-file cfg file-id :read-only? true)
proj (db/get conn :project {:id (:project-id file)})
@ -438,8 +438,8 @@
::sm/params schema:get-page}
[cfg {:keys [::rpc/profile-id file-id share-id] :as params}]
(db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}]
(check-read-permissions! conn profile-id file-id share-id)
(fn [cfg]
(check-read-permissions! cfg profile-id file-id share-id)
(get-page cfg (assoc params :profile-id profile-id)))))
;; --- COMMAND QUERY: get-team-shared-files
@ -562,7 +562,7 @@
(defn- get-team-shared-files
[{:keys [::db/conn] :as cfg} {:keys [team-id profile-id]}]
(teams/check-read-permissions! conn profile-id team-id)
(teams/check-read-permissions! cfg profile-id team-id)
(let [process-row
(fn [{:keys [id library-file-ids]}]
@ -675,8 +675,8 @@
::sm/params schema:get-file-stats
::sm/result schema:get-file-stats-result
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id]}]
(check-read-permissions! conn profile-id id)
[cfg {:keys [::rpc/profile-id id]}]
(check-read-permissions! cfg profile-id id)
(get-file-stats cfg id))
@ -719,7 +719,7 @@
::sm/params schema:get-library-file-references}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
(dm/with-open [conn (db/open pool)]
(check-read-permissions! conn profile-id file-id)
(check-read-permissions! cfg profile-id file-id)
(get-library-file-references conn file-id)))
;; --- COMMAND QUERY: get-team-recent-files
@ -763,7 +763,7 @@
::sm/params schema:get-team-recent-files}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
(dm/with-open [conn (db/open pool)]
(teams/check-read-permissions! conn profile-id team-id)
(teams/check-read-permissions! cfg profile-id team-id)
(get-team-recent-files conn team-id)))
@ -808,8 +808,8 @@
{::doc/added "2.12"
::sm/params schema:get-team-deleted-files}
[cfg {:keys [::rpc/profile-id team-id]}]
(db/run! cfg (fn [{:keys [::db/conn]}]
(teams/check-read-permissions! conn profile-id team-id)
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(teams/check-read-permissions! cfg profile-id team-id)
(get-team-deleted-files conn team-id))))
;; --- COMMAND QUERY: get-file-info

View File

@ -22,6 +22,7 @@
[app.rpc.commands.files :as files]
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc]
[app.rpc.permissions :as perms]
[app.rpc.quotes :as quotes]
[app.util.services :as sv]))
@ -33,8 +34,8 @@
{::doc/added "1.20"
::sm/params schema:get-file-snapshots}
[cfg {:keys [::rpc/profile-id file-id] :as params}]
(db/run! cfg (fn [{:keys [::db/conn]}]
(files/check-read-permissions! conn profile-id file-id)
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-read-permissions! cfg profile-id file-id)
(fsnap/get-visible-snapshots conn file-id))))
;; --- COMMAND QUERY: get-file-snapshot
@ -52,8 +53,8 @@
::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)]
[cfg {:keys [::rpc/profile-id file-id id] :as params}]
(let [perms (perms/get-file-read-permissions cfg profile-id file-id)]
(files/check-read-permissions! perms)
(let [snapshot (fsnap/get-snapshot cfg file-id id)]
(when-not snapshot

View File

@ -85,7 +85,7 @@
::sm/result [:map-of [:string {:max 250}] [:string {:max 250}]]}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id tag] :as params}]
(dm/with-open [conn (db/open pool)]
(files/check-read-permissions! conn profile-id file-id)
(files/check-read-permissions! cfg profile-id file-id)
(if tag
(get-object-thumbnails-by-tag conn file-id tag)
(get-object-thumbnails conn file-id))))
@ -197,9 +197,9 @@
::sm/params schema:get-file-data-for-thumbnail
::sm/result schema:partial-file}
[cfg {:keys [::rpc/profile-id file-id strip-frames-with-thumbnails] :as params}]
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-read-permissions! conn profile-id file-id)
(let [team (teams/get-team conn
(db/run! cfg (fn [cfg]
(files/check-read-permissions! cfg profile-id file-id)
(let [team (teams/get-team cfg
:profile-id profile-id
:file-id file-id)
file (bfc/get-file cfg file-id

View File

@ -6,7 +6,6 @@
(ns app.rpc.commands.fonts
(:require
[app.binfile.common :as bfc]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.logging :as l]
@ -30,6 +29,7 @@
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph]
[app.rpc.permissions :as perms]
[app.rpc.quotes :as quotes]
[app.storage :as sto]
[app.storage.tmp :as tmp]
@ -71,14 +71,14 @@
(cond
(uuid? team-id)
(do
(teams/check-read-permissions! conn profile-id team-id)
(teams/check-read-permissions! cfg profile-id team-id)
(db/query conn :team-font-variant
{:team-id team-id
:deleted-at nil}))
(uuid? project-id)
(let [project (db/get-by-id conn :project project-id {:columns [:id :team-id]})]
(projects/check-read-permissions! conn profile-id project-id)
(projects/check-read-permissions! cfg profile-id project-id)
(db/query conn :team-font-variant
{:team-id (:team-id project)
:deleted-at nil}))
@ -86,7 +86,7 @@
(uuid? file-id)
(let [file (db/get-by-id conn :file file-id {:columns [:id :project-id]})
project (db/get-by-id conn :project (:project-id file) {:columns [:id :team-id]})
perms (bfc/get-file-permissions conn profile-id file-id share-id)]
perms (perms/get-file-read-permissions cfg profile-id file-id share-id)]
(files/check-read-permissions! perms)
(db/query conn :team-font-variant
{:team-id (:team-id project)
@ -400,7 +400,7 @@
::sm/params schema:download-font}
[{:keys [::sto/storage ::db/pool] :as cfg} {:keys [::rpc/profile-id id]}]
(let [variant (db/get pool :team-font-variant {:id id})]
(teams/check-read-permissions! pool profile-id (:team-id variant))
(teams/check-read-permissions! cfg profile-id (:team-id variant))
;; Try to get the best available font format (prefer TTF for broader compatibility).
(let [media-id (or (:ttf-file-id variant)
@ -432,7 +432,7 @@
(ex/raise :type :not-found
:code :object-not-found))
(teams/check-read-permissions! pool profile-id (:team-id (first variants)))
(teams/check-read-permissions! cfg profile-id (:team-id (first variants)))
(let [tempfile (tmp/tempfile :suffix ".zip")
ffamily (-> variants first :font-family)]

View File

@ -176,7 +176,7 @@
;; profile-id is present; it can be ommited if this function is
;; called from SREPL helpers where no profile is available
(when (uuid? profile-id)
(teams/check-read-permissions! conn profile-id team-id))
(teams/check-read-permissions! cfg profile-id team-id))
(binding [bfc/*state* (volatile! {:index {team-id (uuid/next)}})]
(let [projs (bfc/get-team-projects cfg team-id)

View File

@ -21,8 +21,9 @@
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph]
[app.rpc.nitrate.emails-helper :as neh]
[app.rpc.nitrate.organization-helper :as noh]
[app.rpc.notifications :as notifications]
[app.tokens :as tokens]
[app.util.services :as sv]))
@ -394,12 +395,6 @@
(notifications/notify-team-change cfg {:id team-id :organization {:name organization-name}} "dashboard.team-no-longer-belong-org")
nil)
(def ^:private sql:get-team-invitation-emails
"SELECT email_to
FROM team_invitation
WHERE team_id = ?
AND valid_until > now()")
(def ^:private sql:delete-team-external-invitations
"DELETE FROM team_invitation
WHERE team_id = ?
@ -421,8 +416,7 @@
allows-anybody (nitrate-perms/allowed? :add-anybody-to-team {:org-perms org-perms})]
(if allows-anybody
{:allows-anybody true :external-emails []}
(let [invitation-emails (db/exec! conn [sql:get-team-invitation-emails team-id])
emails (map :email-to invitation-emails)]
(let [emails (map :email (noh/get-team-invitation-emails conn team-id))]
(if (empty? emails)
{:allows-anybody false :external-emails []}
(let [emails-array (db/create-array conn "text" (vec emails))
@ -451,7 +445,8 @@
(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})
(let [org-member-ids-before (into #{} (nitrate/call cfg :get-org-members {:organization-id organization-id}))
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
@ -487,26 +482,32 @@
:profile-id profile-id})
(ex/raise :type :validation
:code :not-allowed
:hint "You are not allowed to add teams in this organization")))
: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
:when (not= member-id profile-id)]
(teams/initialize-user-in-nitrate-org cfg member-id organization-id)))
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})
new-member-ids (->> team-members
(map :profile-id)
(remove #{profile-id})
(remove org-member-ids-before))]
(doseq [member-id new-member-ids]
(teams/initialize-user-in-nitrate-org cfg member-id organization-id)))
;; Api call to nitrate
(let [team (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false})]
;; Api call to nitrate
(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 "dashboard.team-belong-org"))
;; Notify connected users
(notifications/notify-team-change cfg team "dashboard.team-belong-org"))
;; Delete pending invitations for users who are not members of the target organization
(let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)]
(when (and (not allows-anybody) (seq external-emails))
(let [conn (::db/conn cfg)
emails-array (db/create-array conn "text" external-emails)]
(db/exec! conn [sql:delete-team-external-invitations team-id emails-array]))))
;; Delete pending invitations for users who are not members of the target organization
(let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)]
(when (and (not allows-anybody) (seq external-emails))
(let [conn (::db/conn cfg)
emails-array (db/create-array conn "text" external-emails)]
(db/exec! conn [sql:delete-team-external-invitations team-id emails-array])))))
;; Send warnings via email if the org has sso
(neh/send-organization-setup-sso-emails-for-team!
cfg organization-id team-id org-member-ids-before)))
nil)
@ -645,17 +646,11 @@
{:keys [authorized sso]} (nitrate/sso-session-authorized? cfg organization-id team-id request)]
(if authorized
{:authorized true}
(if-let [issuer (or (:issuer sso) (:base-url sso))]
(let [oidc-provider (oidc/prepare-org-sso-provider cfg sso)
org-id (or organization-id (:organization-id sso))
state-token (tokens/generate cfg {:iss "oidc"
:dest-url url
:organization-id org-id
:issuer issuer
:exp (ct/in-future "4h")})
redirect-uri (oidc/build-auth-redirect-uri oidc-provider state-token)]
{:authorized false
:redirect-uri redirect-uri})
(if (oidc/org-sso-discovery-uri sso)
{:authorized false
:redirect-uri (oidc/build-org-sso-auth-redirect-uri cfg sso
:dest-url url
:organization-id organization-id)}
{:authorized false
:redirect-uri nil})))
{:authorized true}))

View File

@ -56,11 +56,16 @@
:can-edit (or is-owner is-admin can-edit)
:can-read true})))
(defn- get-read-permissions
[cfg profile-id project-id]
(or (get-permissions cfg profile-id project-id)
(perms/get-organization-owner-permissions cfg profile-id :project-id project-id)))
(def has-edit-permissions?
(perms/make-edition-predicate-fn get-permissions))
(def has-read-permissions?
(perms/make-read-predicate-fn get-permissions))
(perms/make-read-predicate-fn get-read-permissions))
(def check-edition-permissions!
(perms/make-check-fn has-edit-permissions?))
@ -159,10 +164,10 @@
{::doc/added "1.18"
::rpc/id-type :project
::sm/params schema:get-project}
[{:keys [::db/pool]} {:keys [::rpc/profile-id id]}]
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id]}]
(dm/with-open [conn (db/open pool)]
(let [project (db/get-by-id conn :project id)]
(check-read-permissions! conn profile-id id)
(check-read-permissions! cfg profile-id id)
project)))
@ -230,8 +235,8 @@
::webhooks/batch-key (webhooks/key-fn ::rpc/profile-id :id)
::webhooks/event? true
::db/transaction true}
[{:keys [::db/conn]} {:keys [::rpc/profile-id id team-id is-pinned] :as params}]
(check-read-permissions! conn profile-id id)
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id team-id is-pinned] :as params}]
(check-read-permissions! cfg profile-id id)
(db/exec-one! conn [sql:update-project-pin team-id id profile-id is-pinned is-pinned])
nil)

View File

@ -60,6 +60,11 @@
:can-edit (or is-owner is-admin can-edit)
:can-read true})))
(defn get-read-permissions
[cfg profile-id team-id]
(or (get-permissions cfg profile-id team-id)
(perms/get-organization-owner-permissions cfg profile-id :team-id team-id)))
(def has-admin-permissions?
(perms/make-admin-predicate-fn get-permissions))
@ -67,7 +72,7 @@
(perms/make-edition-predicate-fn get-permissions))
(def has-read-permissions?
(perms/make-read-predicate-fn get-permissions))
(perms/make-read-predicate-fn get-read-permissions))
(def check-admin-permissions!
(perms/make-check-fn has-admin-permissions?))
@ -180,7 +185,6 @@
sql (if (contains? cf/flags :subscriptions)
sql:get-teams-with-permissions-and-subscription
sql:get-teams-with-permissions)]
(->> (db/exec! conn [sql (:default-team-id profile) profile-id])
(into [] xform:process-teams))))
@ -238,19 +242,34 @@
{::doc/added "1.17"
::rpc/id-type :team
::sm/params schema:get-team}
[{:keys [::db/pool]} {:keys [::rpc/profile-id id file-id]}]
(get-team pool :profile-id profile-id :team-id id :file-id file-id))
[cfg {:keys [::rpc/profile-id id file-id] :as params}]
(let [team (get-team cfg :profile-id profile-id :team-id id :file-id file-id)]
(if (contains? cf/flags :nitrate)
(nitrate/add-org-info-to-team cfg team params)
team)))
(defn- get-org-owner-viewer-team
"When `profile-id` is a non-member owner of the organization that owns
the requested team, returns the team shaped with viewer permissions;
otherwise nil. `cfg` must carry the nitrate client."
[cfg profile-id default-team-id params]
(when-let [team-id (perms/resolve-team-id cfg params)]
(when (nitrate/organization-owner-of-team? cfg profile-id team-id)
(when-let [team (db/get* cfg :team {:id team-id})]
(when-not (db/is-row-deleted? team)
(-> team
(decode-row)
(merge perms/viewer-role-flags)
(assoc :is-default (= team-id default-team-id))
(process-permissions)))))))
(defn get-team
[conn & {:keys [profile-id team-id project-id file-id] :as params}]
[cfg & {:keys [profile-id team-id project-id file-id] :as params}]
(assert (uuid? profile-id) "profile-id is mandatory")
(assert (or (db/connection? conn)
(db/pool? conn))
"connection or pool is mandatory")
(let [{:keys [default-team-id] :as profile}
(profile/get-profile conn profile-id)
(profile/get-profile cfg profile-id)
sql
(if (contains? cf/flags :subscriptions)
@ -262,14 +281,14 @@
(some? team-id)
(let [sql (str "WITH teams AS (" sql ") "
"SELECT * FROM teams WHERE id=?")]
(db/exec-one! conn [sql default-team-id profile-id team-id]))
(db/exec-one! cfg [sql default-team-id profile-id team-id]))
(some? project-id)
(let [sql (str "WITH teams AS (" sql ") "
"SELECT t.* FROM teams AS t "
" JOIN project AS p ON (p.team_id = t.id) "
" WHERE p.id=?")]
(db/exec-one! conn [sql default-team-id profile-id project-id]))
(db/exec-one! cfg [sql default-team-id profile-id project-id]))
(some? file-id)
(let [sql (str "WITH teams AS (" sql ") "
@ -277,17 +296,18 @@
" JOIN project AS p ON (p.team_id = t.id) "
" JOIN file AS f ON (f.project_id = p.id) "
" WHERE f.id=?")]
(db/exec-one! conn [sql default-team-id profile-id file-id]))
(db/exec-one! cfg [sql default-team-id profile-id file-id]))
:else
(throw (IllegalArgumentException. "invalid arguments")))]
(when-not result
(ex/raise :type :not-found
:code :team-does-not-exist))
(-> result
(decode-row)
(process-permissions))))
(if result
(-> result
(decode-row)
(process-permissions))
(or (get-org-owner-viewer-team cfg profile-id default-team-id params)
(ex/raise :type :not-found
:code :team-does-not-exist)))))
;; --- Query: Team Members
@ -316,7 +336,7 @@
::sm/params schema:get-team-memebrs}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
(dm/with-open [conn (db/open pool)]
(check-read-permissions! conn profile-id team-id)
(check-read-permissions! cfg profile-id team-id)
(get-team-members conn team-id)))
;; --- Query: Team Users
@ -342,10 +362,10 @@
(dm/with-open [conn (db/open pool)]
(if team-id
(do
(check-read-permissions! conn profile-id team-id)
(check-read-permissions! cfg profile-id team-id)
(get-users conn team-id))
(let [{team-id :id} (get-team-for-file conn file-id)]
(check-read-permissions! conn profile-id team-id)
(check-read-permissions! cfg profile-id team-id)
(get-users conn team-id)))))
;; This is a similar query to team members but can contain more data
@ -432,7 +452,7 @@
::sm/params schema:get-team-stats}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
(dm/with-open [conn (db/open pool)]
(check-read-permissions! conn profile-id team-id)
(check-read-permissions! cfg profile-id team-id)
(get-team-stats conn team-id)))
(def sql:team-stats
@ -468,7 +488,7 @@
::sm/params schema:get-team-invitations}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
(dm/with-open [conn (db/open pool)]
(check-read-permissions! conn profile-id team-id)
(check-read-permissions! cfg profile-id team-id)
(get-team-invitations conn team-id)))

View File

@ -94,7 +94,9 @@
[:id ::sm/uuid]
[:name :string]
[:initials [:maybe :string]]
[:logo ::sm/uri]]]
[:logo ::sm/uri]
[:avatar-bg-url [:maybe ::sm/uri]]
[:sso-active [:maybe ::sm/boolean]]]]
[:profile
[:map
[:id ::sm/uuid]
@ -246,7 +248,7 @@
:to email
:invited-by (:fullname profile)
:team (:name team)
:organization (dm/get-in team [:organization :name])
:organization (:organization team)
:token itoken
:extra-data ptoken})))
@ -539,7 +541,7 @@
::doc/module :teams
::sm/params schema:get-team-invitation-token}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id email] :as params}]
(teams/check-read-permissions! pool profile-id team-id)
(teams/check-read-permissions! cfg profile-id team-id)
(let [email (profile/clean-email email)
invit (-> (db/get pool :team-invitation
{:team-id team-id

View File

@ -16,6 +16,7 @@
[app.rpc.commands.teams :as teams]
[app.rpc.cond :as-alias cond]
[app.rpc.doc :as-alias doc]
[app.rpc.permissions :as perms]
[app.util.services :as sv]
[cuerdas.core :as str]))
@ -125,8 +126,8 @@
::sm/params schema:get-view-only-bundle}
[system {:keys [::rpc/profile-id file-id share-id] :as params}]
(db/run! system
(fn [{:keys [::db/conn] :as system}]
(let [perms (bfc/get-file-permissions conn profile-id file-id share-id)
(fn [system]
(let [perms (perms/get-file-read-permissions system profile-id file-id share-id)
params (-> params
(assoc ::perms perms)
(assoc :profile-id profile-id))]
@ -139,5 +140,3 @@
:hint "object not found"))
(get-view-only-bundle system params)))))

View File

@ -172,6 +172,6 @@
::sm/params schema:get-webhooks}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
(dm/with-open [conn (db/open pool)]
(check-read-permissions! conn profile-id team-id)
(check-read-permissions! cfg profile-id team-id)
(->> (db/exec! conn [sql:get-webhooks team-id])
(mapv decode-row))))

View File

@ -8,11 +8,13 @@
"Internal Nitrate HTTP RPC API. Provides authenticated access to
organization management and token validation endpoints."
(:require
[app.auth :as aauth]
[app.auth.oidc :as oidc]
[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 schema:organization-with-avatar]]
[app.common.types.organization :refer [schema:team-with-organization schema:organization-with-avatar schema:nitrate-sso]]
[app.common.types.profile :refer [schema:profile, schema:basic-profile]]
[app.common.types.team :refer [schema:team]]
[app.config :as cf]
@ -23,12 +25,15 @@
[app.media :as media]
[app.nitrate :as nitrate]
[app.rpc :as rpc]
[app.rpc.commands.auth :as auth]
[app.rpc.commands.files :as files]
[app.rpc.commands.nitrate :as cnit]
[app.rpc.commands.profile :as profile]
[app.rpc.commands.teams :as teams]
[app.rpc.commands.teams-invitations :as ti]
[app.rpc.doc :as doc]
[app.rpc.nitrate.emails-helper :as neh]
[app.rpc.nitrate.organization-helper :as noh]
[app.rpc.notifications :as notifications]
[app.storage :as sto]
[app.util.services :as sv]
@ -485,23 +490,6 @@ RETURNING id, deleted_at;")
;; 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.id AS profile_id,
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]])
@ -521,20 +509,16 @@ LEFT JOIN profile AS p
"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}
::sm/result schema:get-org-invitations-result
::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)
(filter uuid?)
(into []))]
(let [team-ids (noh/get-org-team-ids cfg organization-id)]
(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)))))))))))
(->> (noh/get-org-invitations conn organization-id team-ids)
(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
@ -552,14 +536,11 @@ LEFT JOIN profile AS p
(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}
::sm/params schema:delete-org-invitations-params
::rpc/auth false}
[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 []))]
(let [clean-email (profile/clean-email email)
team-ids (noh/get-org-team-ids cfg organization-id)]
(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]))))
@ -585,9 +566,7 @@ LEFT JOIN profile AS p
::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))]
(let [team-ids (noh/get-org-team-ids cfg organization-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]))))
@ -825,18 +804,170 @@ LEFT JOIN profile AS p
nil))
;; ---- API: notify-org-sso-change
;; ---- API: get-teams-detail
(def ^:private sql:get-teams-detail
"SELECT
t.id,
t.name,
t.photo_id,
t.created_at,
(SELECT MAX(p2.modified_at)
FROM project AS p2
WHERE p2.team_id = t.id
AND p2.deleted_at IS NULL
AND p2.is_default IS FALSE) AS last_activity_at,
owner_tpr.profile_id AS owner_profile_id,
owner_p.fullname AS owner_name,
owner_p.photo_id AS owner_photo_id,
(SELECT COUNT(*)
FROM project AS p3
WHERE p3.team_id = t.id
AND p3.deleted_at IS NULL
AND p3.is_default IS FALSE) AS num_projects,
(SELECT COUNT(*)
FROM file AS f
JOIN project AS p4 ON p4.id = f.project_id
WHERE p4.team_id = t.id
AND f.deleted_at IS NULL
AND p4.deleted_at IS NULL) AS num_files,
(SELECT COUNT(*)
FROM team_profile_rel AS tpr
WHERE tpr.team_id = t.id) AS num_members
FROM team AS t
LEFT JOIN team_profile_rel AS owner_tpr
ON owner_tpr.team_id = t.id AND owner_tpr.is_owner IS TRUE
LEFT JOIN profile AS owner_p
ON owner_p.id = owner_tpr.profile_id
WHERE t.id = ANY(?)
AND t.deleted_at IS NULL
AND t.is_default IS FALSE
ORDER BY last_activity_at DESC NULLS LAST")
(def ^:private schema:get-teams-detail-params
[:map
[:organization-id ::sm/uuid]])
(def ^:private schema:get-teams-detail-result
[:vector
[:map
[:id ::sm/uuid]
[:name ::sm/text]
[:photo-url {:optional true} ::sm/uri]
[:created-at ::sm/inst]
[:last-activity-at {:optional true} [:maybe ::sm/inst]]
[:owner-profile-id {:optional true} [:maybe ::sm/uuid]]
[:owner-name {:optional true} [:maybe ::sm/text]]
[:owner-photo-url {:optional true} ::sm/uri]
[:num-projects ::sm/int]
[:num-files ::sm/int]
[:num-members ::sm/int]]])
(sv/defmethod ::get-teams-detail
"Get detailed information for all non-deleted teams in an organization,
including owner info and project/file/member counts."
{::doc/added "2.20"
::sm/params schema:get-teams-detail-params
::sm/result schema:get-teams-detail-result
::rpc/auth false}
[cfg {:keys [organization-id]}]
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
team-ids (into [] (comp d/xf:map-id (filter uuid?)) (:teams org-summary))]
(if (empty? team-ids)
[]
(db/run! cfg
(fn [{:keys [::db/conn]}]
(let [ids-array (db/create-array conn "uuid" team-ids)]
(->> (db/exec! conn [sql:get-teams-detail ids-array])
(mapv (fn [{:keys [photo-id owner-photo-id] :as row}]
(cond-> (dissoc row :photo-id :owner-photo-id)
photo-id (assoc :photo-url (files/resolve-public-uri photo-id))
owner-photo-id (assoc :owner-photo-url (files/resolve-public-uri owner-photo-id))))))))))))
;; ---- API: check-organization-sso
(def ^:private schema:check-organization-sso-result
[:map
[:valid ::sm/boolean]])
(sv/defmethod ::check-organization-sso
"Validate an organization SSO configuration by generating a login redirect URL.
Nitrate calls this while configuring SSO to verify client credentials and OIDC
discovery before saving the settings."
{::doc/added "2.20"
::sm/params schema:nitrate-sso
::sm/result schema:check-organization-sso-result
::rpc/auth false}
[cfg params]
{:valid (oidc/is-organization-sso-config-valid? cfg params)})
;; ---- API: notify-org-sso-change
(sv/defmethod ::notify-org-sso-change
"Nitrate notifies that an organization sso values have changed"
{::doc/added "2.19"
::sm/params [:map
[:organization-id ::sm/uuid]
[:updated-props ::sm/boolean]]
[:updated-props ::sm/boolean]
[:became-active ::sm/boolean]]
::rpc/auth false}
[{:keys [::db/pool] :as cfg} {:keys [organization-id updated-props]}]
[{:keys [::db/pool] :as cfg} {:keys [organization-id updated-props became-active]}]
(when updated-props
(rpc/invalidate-org-sso-cache-by-org! organization-id)
(session/clear-org-sso-sessions! pool organization-id))
(notifications/notify-organization-change-sso cfg organization-id)
(when became-active
(neh/send-organization-setup-sso-emails! cfg organization-id))
nil)
;; ---- API: bulk-create-profiles
(def ^:private schema:bulk-create-profiles-params
[:map
[:password [::sm/word-string {:max 500}]]
[:emails [:vector ::sm/email]]])
(def ^:private schema:bulk-create-profiles-result
[:map
[:created [:vector ::sm/email]]
[:skipped [:vector ::sm/email]]])
(defn- create-active-profile!
"Create a single already-active profile (email pre-verified, onboarding
skipped) plus its default team. Returns nil; existence checks happen in the
caller so duplicates are skipped instead of aborting the whole batch."
[cfg email password]
(let [fullname (-> (str/split email "@") first)]
(->> {:email email
:fullname fullname
:password password
:is-active true
:props {:onboarding-viewed true}}
(auth/create-profile cfg)
(auth/create-profile-rels cfg))
nil))
(sv/defmethod ::bulk-create-profiles
"Create multiple already-active profiles that share a single password. The
created users skip email verification and onboarding. Emails that already
belong to an existing profile are skipped. Intended for the Nitrate admin
bulk-creation screen; access is gated by the shared key and, in Nitrate, an
email allow-list."
{::doc/added "2.19"
::sm/params schema:bulk-create-profiles-params
::sm/result schema:bulk-create-profiles-result
::rpc/auth false}
[cfg {:keys [password emails]}]
(let [derived (aauth/derive-password password)]
(db/tx-run!
cfg
(fn [{:keys [::db/conn] :as cfg}]
(reduce
(fn [acc email]
(let [email (eml/clean email)]
(if (profile/get-profile-by-email conn email)
(update acc :skipped conj email)
(do
(create-active-profile! cfg email derived)
(update acc :created conj email)))))
{:created [] :skipped []}
emails)))))

View File

@ -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 app.rpc.nitrate.emails-helper
"Helpers for organization SSO notification emails triggered by Nitrate integration."
(:require
[app.common.data :as d]
[app.config :as cf]
[app.db :as db]
[app.email :as eml]
[app.nitrate :as nitrate]
[app.rpc.commands.teams :as teams]
[app.rpc.nitrate.organization-helper :as neh]
[cuerdas.core :as str]))
(def ^:private sql:get-profile-emails-by-ids
"SELECT email
FROM profile
WHERE id = ANY(?)
AND deleted_at IS NULL")
(def ^:private sql:get-profiles-by-emails
"SELECT id, email, fullname, is_muted
FROM profile
WHERE email = ANY(?)
AND deleted_at IS NULL")
(defn- org-sso-active?
"Return whether SSO is enabled for the organization."
[cfg organization-id]
(when (contains? cf/flags :nitrate)
(true? (:active (nitrate/call cfg :get-org-sso {:organization-id organization-id})))))
(def ^:private xf:map-email (map :email))
(defn- recipients-by-emails
"Build `{:email :user-name :profile}` maps for a deduplicated email list."
[conn emails]
(let [profiles (if (seq emails)
(let [emails-array (db/create-array conn "text" emails)]
(db/exec! conn [sql:get-profiles-by-emails emails-array]))
[])
profile-by-email (d/index-by (comp str/lower :email) profiles)]
(map (fn [email]
(let [profile (get profile-by-email (str/lower email))]
{:email email
:user-name (:fullname profile)
:profile profile}))
emails)))
(defn- send-organization-setup-sso-email!
"Send the organization SSO setup email to a single recipient, when allowed."
[conn organization-name {:keys [email user-name profile]}]
(when (or (nil? profile)
(eml/allow-send-emails? conn profile))
(eml/send! {::eml/conn conn
::eml/factory eml/organization-setup-sso
:public-uri (cf/get :public-uri)
:to email
:user-name user-name
:organization-name organization-name})))
(defn- get-org-sso-notify-recipients
"Unique org members and pending org/team invitees for SSO activation emails."
[conn cfg organization-id org-summary]
(let [member-ids (nitrate/call cfg :get-org-members {:organization-id organization-id})
team-ids (neh/get-org-team-ids org-summary)
member-emails (if (seq member-ids)
(let [ids-array (db/create-array conn "uuid" member-ids)]
(into #{} (map :email (db/exec! conn [sql:get-profile-emails-by-ids ids-array]))))
#{})
invite-emails (into #{} (map :email
(neh/get-org-invitations conn organization-id team-ids)))
emails (into #{} (concat member-emails invite-emails))]
(recipients-by-emails conn emails)))
(defn- get-team-sso-notify-recipients
"Team members who are not in `org-member-ids`, plus pending team invitations."
[conn team-id org-member-ids]
(let [team-members (->> (teams/get-team-members conn team-id)
(remove #(contains? org-member-ids (:id %))))
invitations (neh/get-team-invitation-emails conn team-id)]
(->> (sequence xf:map-email (concat team-members invitations))
(recipients-by-emails conn))))
(defn send-organization-setup-sso-emails!
"Notify all org members and pending org/team invitees that SSO is active."
[cfg organization-id]
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})]
(db/tx-run! cfg
(fn [{:keys [::db/conn]}]
(doseq [recipient (get-org-sso-notify-recipients conn cfg organization-id org-summary)]
(send-organization-setup-sso-email! conn (:name org-summary) recipient))))))
(defn send-organization-setup-sso-emails-for-team!
"Notify team members who are not in `org-member-ids-before` and pending team invitees."
[cfg organization-id team-id org-member-ids-before]
(when (org-sso-active? cfg organization-id)
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})]
(db/tx-run! cfg
(fn [{:keys [::db/conn]}]
(doseq [recipient (get-team-sso-notify-recipients conn team-id org-member-ids-before)]
(send-organization-setup-sso-email! conn (:name org-summary) recipient)))))))

View File

@ -0,0 +1,60 @@
;; 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.rpc.nitrate.organization-helper
"Shared Nitrate organization query helpers."
(:require
[app.db :as db]
[app.nitrate :as nitrate]))
(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.id AS profile_id,
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 sql:get-team-invitation-emails
"SELECT DISTINCT ON (email_to)
ti.email_to AS email
FROM team_invitation AS ti
WHERE ti.team_id = ?
AND ti.valid_until >= now()
ORDER BY ti.email_to, ti.valid_until DESC, ti.created_at DESC;")
(defn get-org-team-ids
"Return team ids for an organization.
Accepts either `cfg` and `organization-id` (fetches the org summary from
Nitrate) or an already-resolved org summary map."
([cfg organization-id]
(get-org-team-ids (nitrate/call cfg :get-org-summary {:organization-id organization-id})))
([org-summary]
(->> (:teams org-summary)
(map :id)
(filter uuid?)
(vec))))
(defn get-org-invitations
"Fetch valid org-level and team-level invitations for an organization."
[conn organization-id team-ids]
(let [ids-array (db/create-array conn "uuid" team-ids)]
(db/exec! conn [sql:get-org-invitations organization-id ids-array])))
(defn get-team-invitation-emails
"Return distinct valid team invitation recipient emails."
[conn team-id]
(db/exec! conn [sql:get-team-invitation-emails team-id]))

View File

@ -7,8 +7,11 @@
(ns app.rpc.permissions
"A permission checking helper factories."
(:require
[app.binfile.common :as bfc]
[app.common.exceptions :as ex]
[app.common.schema :as sm]))
[app.common.schema :as sm]
[app.db :as db]
[app.nitrate :as nitrate]))
(def schema:permissions
[:map {:title "Permissions"}
@ -89,3 +92,64 @@
(ex/raise :type :not-found
:code :object-not-found
:hint "not found"))))
;; --- Organization owner (Nitrate) viewer access
;;
;; Read-permission helpers that augment normal Penpot membership with
;; Nitrate organization-owner viewer access. Edit/admin permission
;; providers intentionally stay membership-only.
(def viewer-role-flags
"Role flags granted to a non-member organization owner: read-only.
Shared so callers that build full team/file rows shape permissions the
same way the permission lookups do."
{:is-owner false
:is-admin false
:can-edit false})
(def ^:private sql:get-team-id-for-project
"SELECT team_id FROM project WHERE id = ?")
(def ^:private sql:get-team-id-for-file
"SELECT p.team_id
FROM file AS f
JOIN project AS p ON (p.id = f.project_id)
WHERE f.id = ?")
(defn get-team-id-for-project
[cfg project-id]
(some-> (db/exec-one! cfg [sql:get-team-id-for-project project-id])
(:team-id)))
(defn get-team-id-for-file
[cfg file-id]
(some-> (db/exec-one! cfg [sql:get-team-id-for-file file-id])
(:team-id)))
(defn resolve-team-id
[cfg {:keys [team-id project-id file-id]}]
(cond
(some? team-id) team-id
(some? project-id) (get-team-id-for-project cfg project-id)
(some? file-id) (get-team-id-for-file cfg file-id)))
(defn get-organization-owner-permissions
"When `profile-id` is a non-member owner of the organization that owns
the team/project/file referenced by `params`, returns read-only viewer
permissions; otherwise nil."
[cfg profile-id & {:as params}]
(when-let [team-id (resolve-team-id cfg params)]
(when (nitrate/organization-owner-of-team? cfg profile-id team-id)
(assoc viewer-role-flags
:can-read true
:type :membership
:is-logged (some? profile-id)))))
(defn get-file-read-permissions
([cfg profile-id file-id]
(or (bfc/get-file-permissions cfg profile-id file-id)
(get-organization-owner-permissions cfg profile-id :file-id file-id)))
([cfg profile-id file-id share-id]
(or (bfc/get-file-permissions cfg profile-id file-id share-id)
(get-organization-owner-permissions cfg profile-id :file-id file-id))))

View File

@ -53,3 +53,20 @@
;; 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)))))
(t/deftest token-endpoint-errors-detect-valid-client-credentials
(let [response {:status 403
:body "{\"error\":\"invalid_grant\",\"error_description\":\"Invalid authorization code\"}"}]
(t/is (#'oidc/token-endpoint-valid-client-error? response))
(t/is (not (#'oidc/token-endpoint-invalid-client-error? response)))))
(t/deftest token-endpoint-errors-detect-invalid-client-credentials
(t/is (#'oidc/token-endpoint-invalid-client-error?
{:status 401
:body "{\"error\":\"access_denied\",\"error_description\":\"Unauthorized\"}"}))
(t/is (#'oidc/token-endpoint-invalid-client-error?
{:status 400
:body "{\"error\":\"invalid_client\"}"}))
(t/is (not (#'oidc/token-endpoint-valid-client-error?
{:status 400
:body "{\"error\":\"invalid_client\"}"}))))

View File

@ -6,10 +6,12 @@
(ns backend-tests.email-sending-test
(:require
[app.config :as cf]
[app.db :as db]
[app.email :as emails]
[backend-tests.helpers :as th]
[clojure.test :as t]
[cuerdas.core :as str]
[promesa.core :as p]))
(t/use-fixtures :once th/state-init)
@ -23,3 +25,67 @@
(t/is (contains? result :to))
#_(t/is (contains? result :reply-to))
(t/is (map? (:body result)))))
(def ^:private sso-notice-snippet
"has set up single sign-on (SSO) in Penpot")
(defn- email-text-body
[result]
(get-in result [:body "text/plain"]))
(defn- invite-email-params
[organization]
{:to "invitee@example.com"
:public-uri (cf/get :public-uri)
:invited-by "Owner User"
:user-name "Invitee User"
:token "test-token"
:organization organization})
(t/deftest invite-to-org-includes-sso-notice-when-active
(let [result (emails/render emails/invite-to-org
(invite-email-params {:name "Acme Inc"
:sso-active true}))]
(t/is (str/includes? (email-text-body result) sso-notice-snippet))
(t/is (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet))))
(t/deftest invite-to-org-omits-sso-notice-when-inactive
(let [result (emails/render emails/invite-to-org
(invite-email-params {:name "Acme Inc"
:sso-active false}))]
(t/is (not (str/includes? (email-text-body result) sso-notice-snippet)))
(t/is (not (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet)))))
(t/deftest invite-to-team-includes-sso-notice-when-active
(let [result (emails/render emails/invite-to-team
{:to "invitee@example.com"
:public-uri (cf/get :public-uri)
:invited-by "Owner User"
:team "Design Team"
:token "test-token"
:organization {:name "Acme Inc"
:sso-active true}})]
(t/is (str/includes? (email-text-body result) sso-notice-snippet))
(t/is (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet))))
(t/deftest invite-to-team-omits-sso-notice-when-inactive
(let [result (emails/render emails/invite-to-team
{:to "invitee@example.com"
:public-uri (cf/get :public-uri)
:invited-by "Owner User"
:team "Design Team"
:token "test-token"
:organization {:name "Acme Inc"
:sso-active false}})]
(t/is (not (str/includes? (email-text-body result) sso-notice-snippet)))
(t/is (not (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet)))))
(t/deftest invite-to-team-omits-sso-notice-without-organization
(let [result (emails/render emails/invite-to-team
{:to "invitee@example.com"
:public-uri (cf/get :public-uri)
:invited-by "Owner User"
:team "Design Team"
:token "test-token"})]
(t/is (not (str/includes? (email-text-body result) sso-notice-snippet)))
(t/is (not (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet)))))

View File

@ -6,11 +6,13 @@
(ns backend-tests.rpc-management-nitrate-test
(:require
[app.auth.oidc :as oidc]
[app.common.data :as d]
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as-alias db]
[app.email :as eml]
[app.msgbus :as mbus]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
@ -1198,3 +1200,118 @@
(t/is (some? rel1)))
(let [rel2 (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id user)})]
(t/is (some? rel2)))))
(t/deftest notify-org-sso-change-sends-setup-sso-email-once-per-recipient
(let [owner (th/create-profile* 1 {:is-active true :fullname "Owner"})
member (th/create-profile* 2 {:is-active true
:fullname "Member"
:email "member@example.com"})
invited (th/create-profile* 3 {:is-active true
:fullname "Invited User"
:email "invited@example.com"})
org-id (uuid/random)
org-name "Acme Inc"
team (th/create-team* 1 {:profile-id (:id owner)})
org-summary {:id org-id
:name org-name
:teams [{:id (:id team)}]}
sent (atom [])
params {::th/type :notify-org-sso-change
:organization-id org-id
:updated-props false
:became-active true}]
;; Member also has a pending invitation: should still receive only one email.
(th/db-insert! :team-invitation
{:id (uuid/random)
:org-id org-id
:team-id nil
:email-to (:email member)
:created-by (:id owner)
:role "editor"
:valid-until (ct/in-future "24h")})
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team)
:org-id nil
:email-to (:email invited)
:created-by (:id owner)
:role "editor"
:valid-until (ct/in-future "48h")})
;; Invite without an existing profile.
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team)
:org-id nil
:email-to "external@example.com"
:created-by (:id owner)
:role "editor"
:valid-until (ct/in-future "72h")})
(with-redefs [nitrate/call (fn [_cfg method _params]
(case method
:get-org-members [(:id owner) (:id member)]
:get-org-summary org-summary
nil))
eml/send! (fn [params] (swap! sent conj params))]
(management-command-with-nitrate! params))
(let [emails (->> @sent (map :to) set)]
(t/is (= 4 (count @sent)))
(t/is (= #{"member@example.com"
(:email owner)
"invited@example.com"
"external@example.com"}
emails))
(doseq [email-params @sent]
(t/is (= org-name (:organization-name email-params)))
(t/is (= eml/organization-setup-sso (::eml/factory email-params)))))))
(t/deftest notify-org-sso-change-skips-email-when-not-active
(let [sent (atom [])
params {::th/type :notify-org-sso-change
:organization-id (uuid/random)
:updated-props false
:became-active false}]
(with-redefs [eml/send! (fn [params] (swap! sent conj params))]
(management-command-with-nitrate! params))
(t/is (empty? @sent))))
(t/deftest check-organization-sso-returns-valid-true
(let [org-id (uuid/random)
out (with-redefs [oidc/is-organization-sso-config-valid? (constantly true)]
(management-command-with-nitrate!
{::th/type :check-organization-sso
:organization-id org-id
:client-id "test-client"
:client-secret "test-secret"
:base-url "https://idp.example.com"}))]
(t/is (th/success? out))
(t/is (true? (-> out :result :valid)))))
(t/deftest check-organization-sso-returns-valid-false-on-invalid-config
(let [out (management-command-with-nitrate!
{::th/type :check-organization-sso
:organization-id (uuid/random)
:client-id "test-client"
:client-secret "test-secret"})]
(t/is (th/success? out))
(t/is (false? (-> out :result :valid)))))
(t/deftest check-organization-sso-uses-issuer-when-base-url-is-blank
(let [org-id (uuid/random)
out (with-redefs [oidc/is-organization-sso-config-valid?
(fn [_cfg sso]
(and (= "test-client" (:client-id sso))
(= "https://idp.example.com/" (:issuer sso))))]
(management-command-with-nitrate!
{::th/type :check-organization-sso
:organization-id org-id
:client-id "test-client"
:client-secret "test-secret"
:base-url ""
:issuer "https://idp.example.com/"}))]
(t/is (th/success? out))
(t/is (true? (-> out :result :valid)))))

View File

@ -6,12 +6,15 @@
(ns backend-tests.rpc-nitrate-test
(:require
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as-alias db]
[app.email :as eml]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.rpc.commands.nitrate]
[app.rpc.commands.teams :as teams]
[backend-tests.helpers :as th]
[clojure.test :as t]
[cuerdas.core :as str]))
@ -815,3 +818,118 @@
(t/is (not (th/success? out)))
(t/is (= :validation (th/ex-type (:error out))))
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
(defn- add-team-to-org-nitrate-mock
[{:keys [org-id org-summary org-perms owner-id team-id sso-active?]}]
(fn [_cfg method params]
(case method
:get-org-membership (if (= (:profile-id params) owner-id)
{:is-member true :organization-id org-id}
{:is-member false :organization-id org-id})
:get-org-members [owner-id]
:get-team-org {:organization nil}
:get-org-permissions org-perms
:set-team-org {:id team-id}
:get-org-sso {:active sso-active?}
:get-org-summary (assoc org-summary :teams [{:id team-id}])
:add-profile-to-org {:is-member true}
nil)))
(t/deftest add-team-to-organization-sends-sso-emails-to-new-members-and-invitees
(let [owner (th/create-profile* 301 {:is-active true
:fullname "Owner"
:email "owner301@example.com"})
member (th/create-profile* 302 {:is-active true
:fullname "Member"
:email "member302@example.com"})
team (th/create-team* 301 {:profile-id (:id owner)})
_ (th/create-team-role* {:team-id (:id team)
:profile-id (:id member)
:role :editor})
org-id (uuid/random)
org-name "SSO Org"
org-summary {:id org-id
:name org-name
:owner-id (:id owner)
:teams []}
org-perms {:owner-id (:id owner)
:permissions {:create-teams "any"
:move-teams "always"
:new-team-members "members"}}
sent (atom [])]
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team)
:org-id nil
:email-to "external301@example.com"
:created-by (:id owner)
:role "editor"
:valid-until (ct/in-future "48h")})
(with-redefs [cf/flags (conj cf/flags :nitrate)
nitrate/call (add-team-to-org-nitrate-mock
{:org-id org-id
:org-summary org-summary
:org-perms org-perms
:owner-id (:id owner)
:team-id (:id team)
:sso-active? true})
teams/initialize-user-in-nitrate-org (fn [& _] nil)
eml/send! (fn [params] (swap! sent conj params))]
(let [out (th/command! {::th/type :add-team-to-organization
::rpc/profile-id (:id owner)
:team-id (:id team)
:organization-id org-id})]
(t/is (th/success? out))))
(let [emails (->> @sent (map :to) set)]
(t/is (= 2 (count @sent)))
(t/is (= #{"member302@example.com" "external301@example.com"} emails))
(doseq [email-params @sent]
(t/is (= org-name (:organization-name email-params)))
(t/is (= eml/organization-setup-sso (::eml/factory email-params)))))))
(t/deftest add-team-to-organization-skips-sso-emails-when-sso-inactive
(let [owner (th/create-profile* 303 {:is-active true :email "owner303@example.com"})
member (th/create-profile* 304 {:is-active true :email "member304@example.com"})
team (th/create-team* 303 {:profile-id (:id owner)})
_ (th/create-team-role* {:team-id (:id team)
:profile-id (:id member)
:role :editor})
org-id (uuid/random)
org-summary {:id org-id
:name "No SSO Org"
:owner-id (:id owner)
:teams []}
org-perms {:owner-id (:id owner)
:permissions {:create-teams "any"
:move-teams "always"
:new-team-members "members"}}
sent (atom [])]
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team)
:org-id nil
:email-to "external303@example.com"
:created-by (:id owner)
:role "editor"
:valid-until (ct/in-future "48h")})
(with-redefs [cf/flags (conj cf/flags :nitrate)
nitrate/call (add-team-to-org-nitrate-mock
{:org-id org-id
:org-summary org-summary
:org-perms org-perms
:owner-id (:id owner)
:team-id (:id team)
:sso-active? false})
teams/initialize-user-in-nitrate-org (fn [& _] nil)
eml/send! (fn [params] (swap! sent conj params))]
(let [out (th/command! {::th/type :add-team-to-organization
::rpc/profile-id (:id owner)
:team-id (:id team)
:organization-id org-id})]
(t/is (th/success? out))
(t/is (empty? @sent))))))

View File

@ -0,0 +1,150 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.rpc-org-owner-permissions-test
(:require
[app.common.uuid :as uuid]
[app.config :as cf]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[backend-tests.helpers :as th]
[clojure.test :as t]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(defn- org-data
[org-id owner-id]
{:id org-id
:name "Acme"
:slug "acme"
:owner-id owner-id
:avatar-bg-url "http://example.com/avatar.png"
:permissions {}})
(defn- with-org-owner-access
[{:keys [org-owner-id org-id team-id]} f]
(with-redefs [cf/flags (conj cf/flags :nitrate)
nitrate/organization-owner-of-team?
(fn [_cfg profile-id candidate-team-id]
(and (= org-owner-id profile-id)
(= team-id candidate-team-id)))
nitrate/call
(fn [_cfg method params]
(case method
:get-owned-orgs
[{:id org-id
:name "Acme"
:owner-id org-owner-id
:teams [{:id team-id :is-your-penpot false}]}]
:get-team-org
(if (= team-id (:team-id params))
{:id team-id
:is-your-penpot false
:organization (org-data org-id org-owner-id)}
{:id (:team-id params)
:is-your-penpot false
:organization nil})))]
(f)))
(t/deftest org-owner-access-disabled-without-nitrate-flag
(let [team-owner (th/create-profile* 1)
org-owner (th/create-profile* 2)
target-team (th/create-team* 1 {:profile-id (:id team-owner)})]
(let [out (th/command! {::th/type :get-projects
::rpc/profile-id (:id org-owner)
:team-id (:id target-team)})
error (:error out)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found)))))
(t/deftest non-member-org-owner-gets-viewer-access-to-org-team
(let [team-owner (th/create-profile* 1)
org-owner (th/create-profile* 2)
target-team (th/create-team* 1 {:profile-id (:id team-owner)})
project (th/create-project* 1 {:profile-id (:id team-owner)
:team-id (:id target-team)})
file (th/create-file* 1 {:profile-id (:id team-owner)
:project-id (:id project)})
org-id (uuid/next)]
(with-org-owner-access {:org-owner-id (:id org-owner)
:org-id org-id
:team-id (:id target-team)}
(fn []
;; The team is not listed for a non-member, even though the org
;; owner can access it directly.
(let [out (th/command! {::th/type :get-teams
::rpc/profile-id (:id org-owner)})]
(t/is (nil? (:error out)))
(t/is (not-any? #(= (:id target-team) (:id %)) (:result out))))
(let [out (th/command! {::th/type :get-team
::rpc/profile-id (:id org-owner)
:id (:id target-team)})
team (:result out)]
(t/is (nil? (:error out)))
(t/is (= (:id target-team) (:id team)))
(t/is (false? (get-in team [:permissions :is-owner])))
(t/is (false? (get-in team [:permissions :is-admin])))
(t/is (false? (get-in team [:permissions :can-edit])))
(t/is (= org-id (get-in team [:organization :id])))
(t/is (= "Acme" (get-in team [:organization :name]))))
(let [out (th/command! {::th/type :get-team-members
::rpc/profile-id (:id org-owner)
:team-id (:id target-team)})
members (:result out)]
(t/is (nil? (:error out)))
(t/is (some #(= (:id team-owner) (:id %)) members))
(t/is (not-any? #(= (:id org-owner) (:id %)) members)))
(let [out (th/command! {::th/type :get-projects
::rpc/profile-id (:id org-owner)
:team-id (:id target-team)})]
(t/is (nil? (:error out)))
(t/is (some #(= (:id project) (:id %)) (:result out))))
(let [out (th/command! {::th/type :get-file
::rpc/profile-id (:id org-owner)
:id (:id file)})]
(t/is (nil? (:error out)))
(t/is (= (:id file) (get-in out [:result :id])))
(t/is (false? (get-in out [:result :permissions :can-edit]))))
(let [out (th/command! {::th/type :rename-project
::rpc/profile-id (:id org-owner)
:id (:id project)
:name "Nope"})
error (:error out)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found)))))))
(t/deftest org-owner-member-keeps-team-role
(let [team-owner (th/create-profile* 1)
org-owner (th/create-profile* 2)
target-team (th/create-team* 1 {:profile-id (:id team-owner)})
org-id (uuid/next)]
(th/create-team-role* {:team-id (:id target-team)
:profile-id (:id org-owner)
:role :editor})
(with-org-owner-access {:org-owner-id (:id org-owner)
:org-id org-id
:team-id (:id target-team)}
(fn []
(let [out (th/command! {::th/type :get-team
::rpc/profile-id (:id org-owner)
:id (:id target-team)})
team (:result out)]
(t/is (nil? (:error out)))
(t/is (false? (get-in team [:permissions :is-owner])))
(t/is (false? (get-in team [:permissions :is-admin])))
(t/is (true? (get-in team [:permissions :can-edit]))))))))

View File

@ -20,7 +20,13 @@
selmer/selmer {:mvn/version "1.13.4"}
criterium/criterium {:mvn/version "0.4.6"}
metosin/jsonista {:mvn/version "1.0.0"}
metosin/jsonista {:mvn/version "1.0.0"
:exclusions [com.fasterxml.jackson.core/jackson-core
com.fasterxml.jackson.core/jackson-databind]}
com.fasterxml.jackson.core/jackson-core {:mvn/version "2.22.0"}
com.fasterxml.jackson.core/jackson-databind {:mvn/version "2.22.0"}
metosin/malli {:mvn/version "0.19.1"}
expound/expound {:mvn/version "0.9.0"}
@ -58,7 +64,7 @@
{org.clojure/tools.namespace {:mvn/version "1.5.1"}
thheller/shadow-cljs {:mvn/version "3.2.0"}
com.clojure-goes-fast/clj-async-profiler {:mvn/version "2.0.0-beta1"}
com.bhauman/rebel-readline {:mvn/version "0.1.5"}
com.bhauman/rebel-readline {:mvn/version "0.1.11"}
criterium/criterium {:mvn/version "0.4.6"}
mockery/mockery {:mvn/version "0.1.4"}}
:extra-paths ["test" "dev"]}

View File

@ -13,7 +13,7 @@
"devDependencies": {
"concurrently": "^10.0.3",
"nodemon": "^3.1.14",
"prettier": "3.8.4",
"prettier": "3.9.4",
"source-map-support": "^0.5.21",
"ws": "^8.21.0"
},

10
common/pnpm-lock.yaml generated
View File

@ -19,8 +19,8 @@ importers:
specifier: ^3.1.14
version: 3.1.14
prettier:
specifier: 3.8.4
version: 3.8.4
specifier: 3.9.4
version: 3.9.4
source-map-support:
specifier: ^0.5.21
version: 0.5.21
@ -161,8 +161,8 @@ packages:
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
prettier@3.8.4:
resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==}
prettier@3.9.4:
resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==}
engines: {node: '>=14'}
hasBin: true
@ -378,7 +378,7 @@ snapshots:
picomatch@2.3.2: {}
prettier@3.8.4: {}
prettier@3.9.4: {}
pstree.remy@1.1.8: {}

View File

@ -355,11 +355,15 @@
(defn has-point?
[shape point]
(if (or ^boolean (cfh/path-shape? shape)
^boolean (cfh/bool-shape? shape)
^boolean (cfh/circle-shape? shape))
(slow-has-point? shape point)
(fast-has-point? shape point)))
(let [rotation (dm/get-prop shape :rotation)]
;; Rotated shapes don't match their axis-aligned box, so use the polygon test.
(if (or ^boolean (cfh/path-shape? shape)
^boolean (cfh/bool-shape? shape)
^boolean (cfh/circle-shape? shape)
(and (some? rotation)
(not ^boolean (mth/almost-zero? rotation))))
(slow-has-point? shape point)
(fast-has-point? shape point))))
(defn rect-contains-shape?
[rect shape]

View File

@ -249,7 +249,7 @@
(defn is-variant-container?
"Check if this shape is a variant container"
[shape]
(:is-variant-container shape))
(boolean (:is-variant-container shape)))
(defn set-touched-group
[touched group]

View File

@ -61,4 +61,16 @@
[:name ::sm/text]
[:initials [:maybe :string]]
[:logo [:maybe ::sm/uri]]
[:avatar-bg-url [:maybe ::sm/uri]]])
[:avatar-bg-url [:maybe ::sm/uri]]
[:sso-active {:optional true} [:maybe :boolean]]])
(def schema:nitrate-sso
[:map {:title "NitrateOrganizationSso"}
[:organization-id ::sm/uuid]
[:active {:optional true} [:maybe :boolean]]
[:provider {:optional true} [:maybe :string]]
[:client-id {:optional true} [:maybe :string]]
[:base-url {:optional true} [:maybe :string]]
[:client-secret {:optional true} [:maybe :string]]
[:issuer {:optional true} [:maybe :string]]
[:scopes {:optional true} [:maybe [::sm/set ::sm/text]]]])

View File

@ -30,6 +30,7 @@
(def schema:registry-entry
[:map
[:plugin-id :string]
[:version {:optional true} :int]
[:name :string]
[:description {:optional true} :string]
[:host :string]

View File

@ -139,8 +139,8 @@
[:map {:title "OpenOverlayInteraction"}
[:action-type [:= :open-overlay]]
[:event-type [::sm/one-of event-types]]
[:overlay-position ::gpt/point]
[:overlay-pos-type [::sm/one-of overlay-positioning-types]]
[:overlay-position {:optional true} ::gpt/point]
[:overlay-pos-type {:optional true} [::sm/one-of overlay-positioning-types]]
[:destination {:optional true} [:maybe ::sm/uuid]]
[:close-click-outside {:optional true} :boolean]
[:background-overlay {:optional true} :boolean]
@ -151,8 +151,8 @@
[:map {:title "ToggleOverlayInteraction"}
[:action-type [:= :toggle-overlay]]
[:event-type [::sm/one-of event-types]]
[:overlay-position ::gpt/point]
[:overlay-pos-type [::sm/one-of overlay-positioning-types]]
[:overlay-position {:optional true} ::gpt/point]
[:overlay-pos-type {:optional true} [::sm/one-of overlay-positioning-types]]
[:destination {:optional true} [:maybe ::sm/uuid]]
[:close-click-outside {:optional true} :boolean]
[:background-overlay {:optional true} :boolean]

View File

@ -78,6 +78,11 @@
text-transform-attrs
text-fills))
(def text-span-attrs
"Inline text span attrs. Line-height is paragraph-level in the DOM editor;
it may still be stored redundantly on span nodes."
(vec (remove #{:line-height} text-node-attrs)))
(defn text-node-attr?
[attr]
(d/index-of text-node-attrs attr))
@ -317,9 +322,16 @@
"Given two content text structures, conformed by maps and vectors,
compare them, and returns a set with the attributes that have changed.
This is independent of the text structure, so if the structure changes
but the attributes are the same, it will return an empty set."
but the attributes are the same, it will return an empty set.
Line-height on text nodes is ignored: it is a paragraph-level attribute
and may be stored redundantly on spans (e.g. after token apply)."
[a b]
(let [diff-attrs (compare-text-content a b
(let [strip-span-line-height
#(transform-nodes is-text-node? (fn [node] (dissoc node :line-height)) %)
a (strip-span-line-height a)
b (strip-span-line-height b)
diff-attrs (compare-text-content a b
{:text-cb identity
:attribute-cb (fn [acc attr] (conj acc attr))})]
(if-not (contains? diff-attrs :text-content-structure)

View File

@ -8,6 +8,8 @@
(:require
[app.common.features :as ffeat]
[app.common.files.changes :as ch]
[app.common.files.changes-builder :as pcb]
[app.common.geom.point :as gpt]
[app.common.schema :as sm]
[app.common.schema.generators :as sg]
[app.common.schema.test :as smt]
@ -736,6 +738,55 @@
{:num 1000})))
(t/deftest set-comment-thread-position
(let [file-id (uuid/custom 2 2)
page-id (uuid/custom 1 1)
thread-id (uuid/custom 3 1)
frame-id (uuid/custom 4 1)
data (make-file-data file-id page-id)]
(t/testing "stores position and frame-id"
(let [change {:type :set-comment-thread-position
:page-id page-id
:comment-thread-id thread-id
:frame-id frame-id
:position (gpt/point 10 20)}
res (ch/process-changes data [change])]
(t/is (= {:frame-id frame-id :position (gpt/point 10 20)}
(get-in res [:pages-index page-id :comment-thread-positions thread-id])))))
(t/testing "removes the position when frame-id and position are nil"
(let [data (ch/process-changes data [{:type :set-comment-thread-position
:page-id page-id
:comment-thread-id thread-id
:frame-id frame-id
:position (gpt/point 10 20)}])
res (ch/process-changes data [{:type :set-comment-thread-position
:page-id page-id
:comment-thread-id thread-id
:frame-id nil
:position nil}])]
(t/is (nil? (get-in res [:pages-index page-id :comment-thread-positions thread-id])))))
(t/testing "builder round-trips the position through undo and redo"
(let [data (ch/process-changes data [{:type :set-comment-thread-position
:page-id page-id
:comment-thread-id thread-id
:frame-id frame-id
:position (gpt/point 10 20)}])
page (get-in data [:pages-index page-id])
changes (-> (pcb/empty-changes)
(pcb/with-page page)
(pcb/set-comment-thread-position {:id thread-id
:frame-id frame-id
:position (gpt/point 100 200)}))
redone (ch/process-changes data (:redo-changes changes))
undone (ch/process-changes redone (:undo-changes changes))]
(t/is (= (gpt/point 100 200)
(get-in redone [:pages-index page-id :comment-thread-positions thread-id :position])))
(t/is (= (gpt/point 10 20)
(get-in undone [:pages-index page-id :comment-thread-positions thread-id :position])))))))
(t/deftest set-plugin-data-json-encode-decode
(let [schema ch/schema:set-plugin-data-change
encode (sm/encoder schema (sm/json-transformer))

View File

@ -254,3 +254,19 @@
shape {:points points}]
(t/is (true? (gint/slow-has-point? shape (pt 50 25))))
(t/is (false? (gint/slow-has-point? shape (pt 150 25)))))))
(t/deftest has-point-rotated-test
;; Diamond (a square rotated 45º); its axis-aligned x/y/width/height box does
;; not match the rotated polygon.
(let [points [(pt 50 0) (pt 100 50) (pt 50 100) (pt 0 50)]
shape {:x 20 :y 20 :width 60 :height 60 :rotation 45 :points points}]
(t/testing "point inside the polygon but outside the box is contained"
(t/is (true? (gint/has-point? shape (pt 50 5)))))
(t/testing "point inside the box but outside the polygon is not contained"
(t/is (false? (gint/has-point? shape (pt 22 22)))))))
(t/deftest has-point-axis-aligned-test
(let [shape {:x 10 :y 20 :width 100 :height 50 :rotation 0}]
(t/testing "unrotated shape uses the axis-aligned box"
(t/is (true? (gint/has-point? shape (pt 50 40))))
(t/is (false? (gint/has-point? shape (pt 200 40)))))))

View File

@ -76,8 +76,31 @@
(assoc-in content-base [:children 0 :children 0 :children 0 :font-family] "Arial"))
(def content-changed-line-height
(assoc-in content-base [:children 0 :children 0 :line-height] "1.5"))
(def content-redundant-span-line-height
(assoc-in content-base [:children 0 :children 0 :children 0 :line-height] "1.5"))
;; Token apply may store line-height on paragraph and spans; after a DOM
;; round-trip spans no longer carry it (paragraph-level in the editor).
(def content-token-like-line-height
(-> content-base
(assoc-in [:children 0 :children 0 :line-height] 1.4)
(assoc-in [:children 0 :children 0 :children 0 :line-height] 1.4)))
(def content-after-editor-roundtrip
(update-in content-token-like-line-height
[:children 0 :children 0 :children 0]
dissoc :line-height))
;; from_dom used to merge default nil typography refs on import.
(def content-explicit-nil-typography-refs
(-> content-base
(assoc-in [:children 0 :children 0 :typography-ref-id] nil)
(assoc-in [:children 0 :children 0 :typography-ref-file] nil)
(assoc-in [:children 0 :children 0 :children 0 :typography-ref-id] nil)
(assoc-in [:children 0 :children 0 :children 0 :typography-ref-file] nil)))
(def content-changed-letter-spacing
(assoc-in content-base [:children 0 :children 0 :children 0 :letter-spacing] "2"))
@ -185,6 +208,10 @@
;; Other text-node-attr categories
attrs-font-family (cttx/get-diff-attrs content-base content-changed-font-family)
attrs-line-height (cttx/get-diff-attrs content-base content-changed-line-height)
attrs-span-line-height (cttx/get-diff-attrs content-base content-redundant-span-line-height)
attrs-roundtrip-line-height (cttx/get-diff-attrs content-token-like-line-height
content-after-editor-roundtrip)
attrs-nil-typography-refs (cttx/get-diff-attrs content-base content-explicit-nil-typography-refs)
attrs-letter-spacing (cttx/get-diff-attrs content-base content-changed-letter-spacing)
attrs-text-decoration (cttx/get-diff-attrs content-base content-changed-text-decoration)
attrs-text-transform (cttx/get-diff-attrs content-base content-changed-text-transform)
@ -215,6 +242,9 @@
;; Each text-node-attr category reports correct attr key
(t/is (= #{:font-family} attrs-font-family))
(t/is (= #{:line-height} attrs-line-height))
(t/is (= #{} attrs-span-line-height))
(t/is (= #{} attrs-roundtrip-line-height))
(t/is (= #{} attrs-nil-typography-refs))
(t/is (= #{:letter-spacing} attrs-letter-spacing))
(t/is (= #{:text-decoration} attrs-text-decoration))
(t/is (= #{:text-transform} attrs-text-transform))

View File

@ -32,7 +32,7 @@ RUN set -ex; \
FROM base AS setup-node
ENV NODE_VERSION=v24.16.0 \
ENV NODE_VERSION=v24.18.0 \
PATH=/opt/node/bin:$PATH
RUN set -eux; \
@ -60,6 +60,33 @@ RUN set -eux; \
corepack enable; \
rm -rf /tmp/nodejs.tar.gz;
################################################################################
## OPENCODE SETUP
################################################################################
FROM base AS setup-opencode
ENV OPENCODE_VERSION=1.17.13
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
BINARY_URL="https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-linux-arm64.tar.gz"; \
;; \
amd64|x86_64) \
BINARY_URL="https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-linux-x64.tar.gz"; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; exit 1; \
;; \
esac; \
curl -fsSL ${BINARY_URL} -o /tmp/opencode.tar.gz; \
mkdir -p /tmp/opencode; \
tar -xzf /tmp/opencode.tar.gz -C /tmp/opencode; \
chmod +x /tmp/opencode/opencode; \
rm -f /tmp/opencode.tar.gz;
################################################################################
## CADDYSERVER SETUP
@ -67,7 +94,7 @@ RUN set -eux; \
FROM base AS setup-caddy
ENV CADDY_VERSION=2.11.2
ENV CADDY_VERSION=2.11.4
RUN set -eux; \
ARCH="$(dpkg --print-architecture)"; \
@ -99,7 +126,7 @@ RUN set -eux; \
FROM base AS setup-jvm
# https://clojure.org/releases/tools
ENV CLOJURE_VERSION=1.12.4.1618
ENV CLOJURE_VERSION=1.12.5.1654
RUN set -eux; \
ARCH="$(dpkg --print-architecture)"; \
@ -181,12 +208,12 @@ RUN set -eux; \
FROM base AS setup-utils
ENV CLJKONDO_VERSION=2026.04.15 \
BABASHKA_VERSION=1.12.208 \
ENV CLJKONDO_VERSION=2026.05.25 \
BABASHKA_VERSION=1.12.218 \
CLJFMT_VERSION=0.16.4 \
PIXI_VERSION=0.67.2 \
GITHUB_CLI_VERSION=2.91.0 \
K6_VERSION=2.0.0 \
GITHUB_CLI_VERSION=2.96.0 \
UV_VERSION=0.11.9 \
UV_TOOL_DIR=/opt/uv/tools \
UV_TOOL_BIN_DIR=/opt/utils/bin \
@ -494,6 +521,7 @@ COPY --from=setup-rust /opt/cargo /opt/cargo
COPY --from=setup-rust /opt/rustup /opt/rustup
COPY --from=setup-rust /opt/emsdk /opt/emsdk
COPY --from=setup-caddy /usr/bin/caddy /usr/bin/caddy
COPY --from=setup-opencode /tmp/opencode/opencode /opt/utils/bin/opencode
COPY files/nginx.conf /etc/nginx/nginx.conf
COPY files/nginx-mime.types /etc/nginx/mime.types

View File

@ -23,3 +23,11 @@ alias lsf='ls -h *(.)'
if [ -f "$HOME/.bashrc.local" ]; then
. "$HOME/.bashrc.local"
fi
# pnpm
export PNPM_HOME="/home/penpot/.local/share/pnpm"
case ":$PATH:" in
*":$PNPM_HOME:"*) ;;
*) export PATH="$PNPM_HOME:$PATH" ;;
esac
# pnpm end

View File

@ -3,7 +3,7 @@ LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
NODE_VERSION=v24.16.0 \
NODE_VERSION=v24.18.0 \
DEBIAN_FRONTEND=noninteractive \
PATH=/opt/node/bin:/opt/imagick/bin:$PATH \
PLAYWRIGHT_BROWSERS_PATH=/opt/penpot/browsers

View File

@ -3,7 +3,7 @@ LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
NODE_VERSION=v22.21.1 \
NODE_VERSION=v24.18.0 \
DEBIAN_FRONTEND=noninteractive \
PATH=/opt/node/bin:$PATH \
PENPOT_MCP_SERVER_HOST=0.0.0.0

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -27,17 +27,17 @@
"@11ty/eleventy": "^3.1.6",
"@11ty/eleventy-navigation": "^1.0.5",
"@11ty/eleventy-plugin-rss": "^3.0.0",
"@11ty/eleventy-plugin-syntaxhighlight": "^5.0.0",
"@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2",
"@tigersway/eleventy-plugin-ancestry": "^1.0.3",
"@types/markdown-it": "14.1.2",
"elasticlunr": "^0.9.5",
"eleventy-plugin-metagen": "^1.8.3",
"eleventy-plugin-metagen": "^1.8.4",
"eleventy-plugin-nesting-toc": "^1.3.0",
"eleventy-plugin-youtube-embed": "^1.10.2",
"luxon": "^3.4.4",
"markdown-it": "^14.1.0",
"markdown-it-anchor": "^9.0.1",
"eleventy-plugin-youtube-embed": "^1.13.2",
"luxon": "^3.7.2",
"markdown-it": "^14.2.0",
"markdown-it-anchor": "^9.2.0",
"markdown-it-plantuml": "^1.4.1"
},
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620"
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b"
}

28
docs/pnpm-lock.yaml generated
View File

@ -18,7 +18,7 @@ importers:
specifier: ^3.0.0
version: 3.0.0
'@11ty/eleventy-plugin-syntaxhighlight':
specifier: ^5.0.0
specifier: ^5.0.2
version: 5.0.2
'@tigersway/eleventy-plugin-ancestry':
specifier: ^1.0.3
@ -30,22 +30,22 @@ importers:
specifier: ^0.9.5
version: 0.9.5
eleventy-plugin-metagen:
specifier: ^1.8.3
specifier: ^1.8.4
version: 1.8.4
eleventy-plugin-nesting-toc:
specifier: ^1.3.0
version: 1.3.0
eleventy-plugin-youtube-embed:
specifier: ^1.10.2
specifier: ^1.13.2
version: 1.13.2
luxon:
specifier: ^3.4.4
specifier: ^3.7.2
version: 3.7.2
markdown-it:
specifier: ^14.1.0
specifier: ^14.2.0
version: 14.2.0
markdown-it-anchor:
specifier: ^9.0.1
specifier: ^9.2.0
version: 9.2.0(@types/markdown-it@14.1.2)(markdown-it@14.2.0)
markdown-it-plantuml:
specifier: ^1.4.1
@ -436,8 +436,8 @@ packages:
resolution: {integrity: sha512-gXkz5+KN7HrG0Q5UGqSMO2qB9AsbEeyLP54kF1YrMsIxmu+g4BdB7rflReZTSTZGpfj8wywu6pfPBCylPIzGQA==}
engines: {node: '>=6.0'}
js-yaml@3.14.2:
resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==}
js-yaml@3.15.0:
resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==}
hasBin: true
js-yaml@4.2.0:
@ -692,8 +692,8 @@ packages:
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
undici@7.27.2:
resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==}
undici@7.28.0:
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
engines: {node: '>=20.18.1'}
unpipe@1.0.0:
@ -943,7 +943,7 @@ snapshots:
parse5: 7.3.0
parse5-htmlparser2-tree-adapter: 7.1.0
parse5-parser-stream: 7.1.2
undici: 7.27.2
undici: 7.28.0
whatwg-mimetype: 4.0.0
chokidar@3.6.0:
@ -1126,7 +1126,7 @@ snapshots:
gray-matter@4.0.3:
dependencies:
js-yaml: 3.14.2
js-yaml: 3.15.0
kind-of: 6.0.3
section-matter: 1.0.0
strip-bom-string: 1.0.0
@ -1188,7 +1188,7 @@ snapshots:
iso-639-1@3.1.5: {}
js-yaml@3.14.2:
js-yaml@3.15.0:
dependencies:
argparse: 1.0.10
esprima: 4.0.1
@ -1410,7 +1410,7 @@ snapshots:
uc.micro@2.1.0: {}
undici@7.27.2: {}
undici@7.28.0: {}
unpipe@1.0.0: {}

View File

@ -0,0 +1,3 @@
minimumReleaseAgeExclude:
- undici@7.28.0
- js-yaml@3.15.0

View File

@ -31,6 +31,7 @@ desc: Learn how to create, manage and apply Penpot Design Tokens using W3C DTCG
<li><strong>Description:</strong> You can also choose to add a description to your token.</li>
</ul>
<p>Once you have named the token and assigned it a value, click <strong>Save</strong> to store the token and start referencing it.</p>
<p>Token value fields suggest existing tokens as you type. Pick a suggestion to insert a reference, or keep typing a custom value.</p>
<h2 id="design-tokens-aliases">Referencing tokens into values (aliases)</h2>
<p>When assigning a value to a token, you can reference existing tokens - these are called aliases at the <a href="https://www.designtokens.org/glossary/" target="_blank">DTCG Glossary</a>.</p>
@ -111,7 +112,7 @@ desc: Learn how to create, manage and apply Penpot Design Tokens using W3C DTCG
<p>This ensures the spacing is at least 8, even if the base token is smaller.</p>
<h2 id="design-tokens-edit">Editing a token</h2>
<p>Tokens can be edited by right-clicking the token and selecting <strong>Edit token</strong>. This will allow you to change the tokens name, value and description. Once the changes are made, click <strong>Save</strong>.</p>
<p>Tokens can be edited by right-clicking the token and selecting <strong>Edit token</strong>. This will allow you to change the tokens name, value and description. Value fields offer the same token suggestions as when creating a token. Once the changes are made, click <strong>Save</strong>.</p>
<figure>
<img src="/img/design-tokens/05-tokens-edit.webp" alt="Tokens edit" />
</figure>
@ -141,12 +142,15 @@ desc: Learn how to create, manage and apply Penpot Design Tokens using W3C DTCG
<li><strong>Size and position:</strong> width, height, x, y</li>
<li><strong>Rotation and border radius</strong></li>
<li><strong>Layout spacing:</strong> gaps, paddings, margins, min and max widths and heights</li>
<li><strong>Typography:</strong> letter spacing and line height</li>
<li><strong>Typography:</strong> letter spacing, line height, and composite typography tokens on text layers</li>
<li><strong>Stroke width</strong></li>
<li><strong>Shadows:</strong> x, y, blur, spread</li>
<li><strong>Blur</strong> amount</li>
</ul>
<p>Click the token control on a field to open a list of applicable tokens from your active sets and themes. Select a token to apply it; a token pill shows the bound token. Use the pill menu to detach the token when you need a custom value again.</p>
<p>On text layers, the Typography section shows a composite typography token row when one is applied. Open the token list from the section header to pick or switch tokens without going to the Tokens panel. Hover a token name to see its resolved values (font family, size, weight, and the rest).</p>
<p>When you multi-select text layers that share typography tokens, the Typography section shows the applied token. If the selection mixes different tokens, detach them from there or apply a new one to the whole selection.</p>
<p>Typography options in the dropdown list show resolved values on hover, so you can tell styles apart before applying them.</p>
<p class="advice">When a numeric field has a token applied, drag-to-change is disabled for that field. Edit the token or detach it first.</p>
<figure>
<img src="/img/design-tokens/41-design-tab-token-dropdown.webp" alt="Token list on a numeric field in the Design sidebar">
@ -528,6 +532,7 @@ ExtraBold Italic
<h4 id="apply-typography-token">Apply a Typography token</h4>
<p>A <strong>Typography composite token</strong> can be applied to a full text layer to set all typography properties at once. This lets you manage complete text styles using a single token instead of combining multiple individual ones.</p>
<p>Apply it from the Tokens panel, or from the Typography section in the Design sidebar when a text layer is selected.</p>
<p>When applying a Typography composite token to a layer, any previously applied <em>Typography composite token</em> or <em>style</em> will be detached. The same happens in reverse. Only one of them can be active at a time.</p>
<h3 id="design-tokens-shadow">Shadow</h3>

View File

@ -57,7 +57,7 @@ desc: Style your designs with Penpot's options! Learn about color fills, gradien
<li><strong>Color type</strong> - Solid, gradient, or image.</li>
<li><strong>Sliders</strong> - Easily manage settings like brightness, saturation or opacity.</li>
<li><strong>Values</strong> - Set precise color values. Available formats depend on the selected color model.</li>
<li><strong>Libraries</strong> - Switch between recent colors and libraries.</li>
<li><strong>Libraries</strong> - Switch between recent colors and libraries. Color tokens in a library can be shown as a grid or a list. Use the list view when you need to scan names or work with long token lists.</li>
<li><strong>Color palette</strong> - A quick launcher of the palette with the selected library.</li>
</ol>
@ -116,6 +116,7 @@ desc: Style your designs with Penpot's options! Learn about color fills, gradien
<li><strong>Style</strong> - solid, dotted, dashed, mixed</li>
<li><strong>Visibility</strong> - show or hide the stroke without removing it</li>
</ul>
<p>When the style is set to <strong>dashed</strong>, two extra fields appear to control the <strong>dash</strong> and <strong>gap</strong> length in pixels.</p>
<p>You can add as many strokes as you want to the same layer.</p>
<figure>
<img alt="Multiple strokes" src="/img/styling/stroke-multiple.webp"/>

View File

@ -461,8 +461,18 @@ You can choose to edit individual nodes or create new ones. Press <kbd>Esc</kbd>
</ul>
<h3 id="blur">Blur</h3>
<p>You can set a blur for each and every layer at Penpot.</p>
<p><strong></strong>Applying a lot and/or big values for blurs can affect Penpots performance as it requires a lot from the browser.</p>
<p>You can add blur effects to layers from the Design sidebar. Penpot supports two types:</p>
<ul>
<li><strong>Layer blur</strong> blurs the layer content itself.</li>
<li><strong>Background blur</strong> blurs whatever sits behind the layer. Use it for frosted-glass or depth effects. Background blur requires the <a href="/user-guide/first-steps/troubleshooting-webgl/">WebGL renderer</a> to be enabled.</li>
</ul>
<p class="advice">Background blur is not included in exported files yet (PNG, SVG, PDF, and others). The exporter still uses the legacy SVG renderer, so the effect only shows in the workspace and View mode. Export support is a current priority and will land in an upcoming release.</p>
<figure>
<img src="/img/styling/background-blur.webp" alt="Colorful shapes behind a frosted panel with background blur applied" />
<figcaption>Background blur affects the layers behind the shape, not the shape itself.</figcaption>
</figure>
<p>Click <strong>+</strong> in the Blur section to add an effect. You can apply both types on the same layer. Use the type selector to switch between them, the eye icon to show or hide an effect without removing it, and the value field to set the blur amount.</p>
<p class="advice">Applying many or large blur values can affect Penpots performance, as it requires a lot from the browser.</p>
<figure>
<video title="Apply blur to a layer" muted="" playsinline="" controls="" width="100%" poster="/img/styling/blur.webp" height="auto">
<source src="/img/styling/blur.mp4" type="video/mp4">

View File

@ -42,6 +42,10 @@ desc: This Penpot user guide explains how to prototype interactions, connect boa
<strong>10)</strong> Flow indicator and launcher
</p>
<h2 id="interaction-settings">Interaction settings</h2>
<p>When a layer with interactions is selected, the Prototype sidebar lists every connection attached to it. From there you can change the trigger, action, destination, animation, and other options.</p>
<p>Destination dropdowns include a search field. Type to filter boards on the current page when your file has many screens.</p>
<h2 id="interaction-triggers">Interaction triggers</h2>
<figure>
<img src="/img/prototype/prototype-trigger.webp" alt="Prototype trigger options">

View File

@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
"packageManager": "pnpm@11.5.3+sha512.7ac1c919341c213a34dc0d02afb7143c5c26ac26ee8c4782deea821b8ac64d2134a081fd8941dae6e29bbb48f58dfc2b7fbceeccc07cb2f09d219d342a4969ed",
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"
@ -18,7 +18,7 @@
"generic-pool": "^3.9.0",
"inflation": "^2.1.0",
"ioredis": "^5.11.1",
"playwright": "^1.61.0",
"playwright": "^1.61.1",
"raw-body": "^3.0.2",
"source-map-support": "^0.5.21",
"undici": "^8.5.0",

View File

@ -35,8 +35,8 @@ importers:
specifier: ^5.11.1
version: 5.11.1
playwright:
specifier: ^1.61.0
version: 1.61.0
specifier: ^1.61.1
version: 1.61.1
raw-body:
specifier: ^3.0.2
version: 3.0.2
@ -330,13 +330,13 @@ packages:
resolution: {integrity: sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==}
engines: {node: '>=20.19.0'}
playwright-core@1.61.0:
resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==}
playwright-core@1.61.1:
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.61.0:
resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==}
playwright@1.61.1:
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
engines: {node: '>=18'}
hasBin: true
@ -714,11 +714,11 @@ snapshots:
dependencies:
boolbase: 2.0.0
playwright-core@1.61.0: {}
playwright-core@1.61.1: {}
playwright@1.61.0:
playwright@1.61.1:
dependencies:
playwright-core: 1.61.0
playwright-core: 1.61.1
optionalDependencies:
fsevents: 2.3.2

View File

@ -46,6 +46,9 @@
:dev
{:extra-paths ["dev"]
:jvm-opts ["--sun-misc-unsafe-memory-access=allow"
"--enable-native-access=ALL-UNNAMED"]
:extra-deps
{thheller/shadow-cljs {:mvn/version "3.2.2"}
com.bhauman/rebel-readline {:mvn/version "RELEASE"}

View File

@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
"browserslist": [
"defaults"
],
@ -58,18 +58,18 @@
"@penpot/tokenscript": "link:packages/tokenscript",
"@penpot/ua-parser": "penpot/ua-parser#1.0.0",
"@penpot/ui": "link:packages/ui",
"@playwright/test": "1.61.0",
"@playwright/test": "1.61.1",
"@storybook/addon-docs": "10.4.6",
"@storybook/addon-themes": "10.4.6",
"@storybook/addon-vitest": "10.4.6",
"@storybook/react-vite": "10.4.6",
"@tokens-studio/sd-transforms": "2.0.3",
"@types/node": "^25.9.3",
"@types/node": "^26.1.0",
"@vitest/browser": "4.1.9",
"@vitest/browser-playwright": "^4.1.9",
"@vitest/coverage-v8": "4.1.9",
"@zip.js/zip.js": "2.8.26",
"autoprefixer": "^10.4.27",
"autoprefixer": "^10.5.2",
"compression": "^1.8.1",
"concurrently": "^10.0.3",
"date-fns": "^4.4.0",
@ -80,7 +80,7 @@
"getopts": "^2.3.0",
"gettext-parser": "^9.0.2",
"highlight.js": "^11.10.0",
"js-beautify": "^1.15.4",
"js-beautify": "^2.0.3",
"jsdom": "^29.0.2",
"lodash": "^4.18.1",
"lodash.debounce": "^4.0.8",
@ -92,12 +92,12 @@
"npm-run-all": "^4.1.5",
"opentype.js": "^2.0.0",
"p-limit": "^7.3.0",
"playwright": "1.61.0",
"postcss": "^8.5.15",
"playwright": "1.61.1",
"postcss": "^8.5.16",
"postcss-clean": "^1.2.2",
"postcss-modules": "^6.0.1",
"postcss-modules": "^9.0.0",
"postcss-scss": "^4.0.9",
"prettier": "3.8.4",
"prettier": "3.9.4",
"pretty-time": "^1.1.0",
"prop-types": "^15.8.1",
"randomcolor": "^0.6.2",
@ -114,19 +114,19 @@
"source-map-support": "^0.5.21",
"storybook": "10.4.6",
"style-dictionary": "5.4.4",
"stylelint": "^17.13.0",
"stylelint": "^17.14.0",
"stylelint-config-standard-scss": "^17.0.0",
"stylelint-scss": "^7.2.0",
"stylelint-plugin-logical-css": "^2.1.0",
"stylelint-scss": "^7.2.0",
"svg-sprite": "^2.0.4",
"tdigest": "^0.1.2",
"tinycolor2": "^1.6.0",
"typescript": "^6.0.2",
"vite": "^8.0.16",
"vite": "^8.1.2",
"vitest": "^4.1.9",
"wait-on": "^9.0.4",
"watcher": "^2.3.1",
"workerpool": "^10.0.1",
"workerpool": "^10.0.3",
"xregexp": "^5.1.2"
}
}

View File

@ -4,12 +4,12 @@
"description": "Penpot Draft-JS Wrapper",
"main": "index.js",
"type": "module",
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
"author": "Andrey Antukh",
"license": "MPL-2.0",
"dependencies": {
"draft-js": "penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0",
"immutable": "^5.1.6"
"immutable": "^5.1.9"
},
"peerDependencies": {
"react": ">=0.17.0",

View File

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

View File

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

View File

@ -14,15 +14,15 @@
"build": "vite build"
},
"devDependencies": {
"@babel/core": "^8.0.0",
"@babel/preset-react": "^8.0.0",
"@babel/core": "^8.0.1",
"@babel/preset-react": "^8.0.1",
"@storybook/react": "10.4.6",
"@storybook/react-vite": "10.4.6",
"@testing-library/dom": "10.4.1",
"@testing-library/react": "16.3.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.2",
"@vitejs/plugin-react": "^6.0.3",
"babel-plugin-react-compiler": "^1.0.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-jsx-a11y": "6.10.2",
@ -30,7 +30,7 @@
"eslint-plugin-react-hooks": "7.1.1",
"react-compiler-runtime": "^1.0.0",
"storybook": "10.4.6",
"vite-plugin-dts": "^5.0.2"
"vite-plugin-dts": "^5.0.3"
},
"peerDependencies": {
"react": ">=19.2",

View File

@ -0,0 +1 @@
{"~:total": 2}

View File

@ -389,7 +389,37 @@
"~:fills": [],
"~:flip-x": null,
"~:height": 63.999981880188,
"~:flip-y": null
"~:flip-y": null,
"~:position-data": [
{
"~:y": 281.419982910156,
"~:line-height": "1.2",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:font-size": "72px",
"~:font-weight": "900",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:width": 169.070068359375,
"~:font-variant-id": "regular",
"~:text-decoration": "line-through",
"~:letter-spacing": "0px",
"~:x": 2111,
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:direction": "ltr",
"~:font-family": "sourcesanspro",
"~:height": 93.239990234375,
"~:text": "HOLA"
}
]
}
},
"~u13fc1849-119a-8028-8006-ae134c87eab3": {
@ -758,7 +788,37 @@
"~:fills": [],
"~:flip-x": null,
"~:height": 63.999981880188,
"~:flip-y": null
"~:flip-y": null,
"~:position-data": [
{
"~:y": 281.419982910156,
"~:line-height": "1.2",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:font-size": "72px",
"~:font-weight": "900",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:width": 169.070068359375,
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0px",
"~:x": 2719,
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:direction": "ltr",
"~:font-family": "sourcesanspro",
"~:height": 93.239990234375,
"~:text": "HOLA"
}
]
}
},
"~u13fc1849-119a-8028-8006-ae1409412914": {
@ -1143,7 +1203,37 @@
"~:fills": [],
"~:flip-x": null,
"~:height": 63.999981880188,
"~:flip-y": null
"~:flip-y": null,
"~:position-data": [
{
"~:y": 488.419982910156,
"~:line-height": "1.2",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:font-size": "72px",
"~:font-weight": "900",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:width": 169.070068359375,
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0px",
"~:x": 2707,
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:direction": "ltr",
"~:font-family": "sourcesanspro",
"~:height": 93.239990234375,
"~:text": "HOLA"
}
]
}
},
"~u13fc1849-119a-8028-8006-ae1488974651": {
@ -1293,7 +1383,37 @@
"~:fills": [],
"~:flip-x": null,
"~:height": 63.999981880188,
"~:flip-y": null
"~:flip-y": null,
"~:position-data": [
{
"~:y": 111.419998168945,
"~:line-height": "1.2",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:font-size": "72px",
"~:font-weight": "900",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:width": 169.070068359375,
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0px",
"~:x": 2313,
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:direction": "ltr",
"~:font-family": "sourcesanspro",
"~:height": 93.2399978637695,
"~:text": "HOLA"
}
]
}
},
"~u13fc1849-119a-8028-8006-ae149cc8c2f1": {
@ -1432,7 +1552,32 @@
},
"~:flip-x": null,
"~:height": 63.999981880188,
"~:flip-y": null
"~:flip-y": null,
"~:position-data": [
{
"~:y": 201.419982910156,
"~:line-height": "1.2",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:font-size": "72px",
"~:font-weight": "900",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:width": 169.070068359375,
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0px",
"~:x": 2714,
"~:fills": [],
"~:direction": "ltr",
"~:font-family": "sourcesanspro",
"~:height": 93.239990234375,
"~:text": "HOLA"
}
]
}
},
"~u13fc1849-119a-8028-8006-ae149cc8c2f0": {
@ -1571,7 +1716,32 @@
},
"~:flip-x": null,
"~:height": 63.999981880188,
"~:flip-y": null
"~:flip-y": null,
"~:position-data": [
{
"~:y": 201.419982910156,
"~:line-height": "1.2",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:font-size": "72px",
"~:font-weight": "900",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:width": 169.070068359375,
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0px",
"~:x": 2514,
"~:fills": [],
"~:direction": "ltr",
"~:font-family": "sourcesanspro",
"~:height": 93.239990234375,
"~:text": "HOLA"
}
]
}
},
"~u13fc1849-119a-8028-8006-ae136831d5b7": {
@ -2116,7 +2286,37 @@
"~:fills": [],
"~:flip-x": null,
"~:height": 63.999981880188,
"~:flip-y": null
"~:flip-y": null,
"~:position-data": [
{
"~:y": 281.419982910156,
"~:line-height": "1.2",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:font-size": "72px",
"~:font-weight": "900",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:width": 169.070068359375,
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0px",
"~:x": 2519,
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:direction": "ltr",
"~:font-family": "sourcesanspro",
"~:height": 93.239990234375,
"~:text": "HOLA"
}
]
}
},
"~u13fc1849-119a-8028-8006-ae1409412913": {
@ -2368,7 +2568,37 @@
"~:fills": [],
"~:flip-x": null,
"~:height": 63.999981880188,
"~:flip-y": null
"~:flip-y": null,
"~:position-data": [
{
"~:y": 201.419982910156,
"~:line-height": "1.2",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:font-size": "72px",
"~:font-weight": "900",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:width": 169.070068359375,
"~:font-variant-id": "regular",
"~:text-decoration": "underline",
"~:letter-spacing": "0px",
"~:x": 2106,
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:direction": "ltr",
"~:font-family": "sourcesanspro",
"~:height": 93.239990234375,
"~:text": "HOLA"
}
]
}
},
"~u13fc1849-119a-8028-8006-ae149fbf3112": {
@ -2525,7 +2755,37 @@
"~:fills": [],
"~:flip-x": null,
"~:height": 63.999981880188,
"~:flip-y": null
"~:flip-y": null,
"~:position-data": [
{
"~:y": 281.419982910156,
"~:line-height": "1.2",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:font-size": "72px",
"~:font-weight": "900",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:width": 169.070068359375,
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0px",
"~:x": 2318,
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:direction": "ltr",
"~:font-family": "sourcesanspro",
"~:height": 93.239990234375,
"~:text": "HOLA"
}
]
}
},
"~u13fc1849-119a-8028-8006-ae1409412912": {
@ -4487,7 +4747,44 @@
},
"~:flip-x": null,
"~:height": 63.999981880188,
"~:flip-y": null
"~:flip-y": null,
"~:position-data": [
{
"~:y": 111.419998168945,
"~:line-height": "1.2",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:font-size": "72px",
"~:font-weight": "900",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:width": 169.070068359375,
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0px",
"~:x": 2714,
"~:fills": [
{
"~:fill-opacity": 1,
"~:fill-image": {
"~:id": "~uaa0a383a-7553-808a-8006-ae13a3c575eb",
"~:width": 100,
"~:height": 100,
"~:mtype": "image/jpeg",
"~:keep-aspect-ratio": true,
"~:name": "sample"
}
}
],
"~:direction": "ltr",
"~:font-family": "sourcesanspro",
"~:height": 93.2399978637695,
"~:text": "HOLA"
}
]
}
},
"~u13fc1849-119a-8028-8006-ae136eba4581": {
@ -5550,7 +5847,32 @@
},
"~:flip-x": null,
"~:height": 63.999981880188,
"~:flip-y": null
"~:flip-y": null,
"~:position-data": [
{
"~:y": 201.419982910156,
"~:line-height": "1.2",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:font-size": "72px",
"~:font-weight": "900",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:width": 169.070068359375,
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0px",
"~:x": 2313,
"~:fills": [],
"~:direction": "ltr",
"~:font-family": "sourcesanspro",
"~:height": 93.239990234375,
"~:text": "HOLA"
}
]
}
},
"~u13fc1849-119a-8028-8006-ae148b39a12f": {
@ -5700,7 +6022,37 @@
"~:fills": [],
"~:flip-x": null,
"~:height": 63.999981880188,
"~:flip-y": null
"~:flip-y": null,
"~:position-data": [
{
"~:y": 111.419998168945,
"~:line-height": "1.2",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:font-size": "72px",
"~:font-weight": "900",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:width": 169.070068359375,
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0px",
"~:x": 2514,
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:direction": "ltr",
"~:font-family": "sourcesanspro",
"~:height": 93.2399978637695,
"~:text": "HOLA"
}
]
}
},
"~u13fc1849-119a-8028-8006-ae145f7fe46e": {
@ -6244,7 +6596,37 @@
},
"~:flip-x": null,
"~:height": 63.999981880188,
"~:flip-y": null
"~:flip-y": null,
"~:position-data": [
{
"~:y": 111.419998168945,
"~:line-height": "1.2",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:font-size": "72px",
"~:font-weight": "900",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:width": 169.070068359375,
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0px",
"~:x": 2106,
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:direction": "ltr",
"~:font-family": "sourcesanspro",
"~:height": 93.2399978637695,
"~:text": "HOLA"
}
]
}
}
},
@ -6258,4 +6640,4 @@
"~:base-font-size": "16px"
}
}
}
}

View File

@ -637,7 +637,12 @@
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [],
"~:fills": [
{
"~:fill-color": "#B1B2B5",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": "🔥"
},
@ -654,7 +659,12 @@
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [],
"~:fills": [
{
"~:fill-color": "#B1B2B5",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": "👩🏿\u200d🚀"
},
@ -671,7 +681,12 @@
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [],
"~:fills": [
{
"~:fill-color": "#B1B2B5",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": "👺"
},
@ -688,7 +703,12 @@
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [],
"~:fills": [
{
"~:fill-color": "#B1B2B5",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": "🚀"
}
@ -706,7 +726,12 @@
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [],
"~:fills": [
{
"~:fill-color": "#B1B2B5",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro"
},
{
@ -726,7 +751,12 @@
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [],
"~:fills": [
{
"~:fill-color": "#B1B2B5",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": ""
}
@ -744,7 +774,12 @@
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [],
"~:fills": [
{
"~:fill-color": "#B1B2B5",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro"
}
]
@ -2395,7 +2430,12 @@
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [],
"~:fills": [
{
"~:fill-color": "#B1B2B5",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": "🔥"
},
@ -2412,7 +2452,12 @@
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [],
"~:fills": [
{
"~:fill-color": "#B1B2B5",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": "👩🏿\u200d🚀"
},
@ -2429,7 +2474,12 @@
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [],
"~:fills": [
{
"~:fill-color": "#B1B2B5",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": "👺"
},
@ -2446,7 +2496,12 @@
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [],
"~:fills": [
{
"~:fill-color": "#B1B2B5",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": "🚀"
}
@ -2464,7 +2519,12 @@
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [],
"~:fills": [
{
"~:fill-color": "#B1B2B5",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro"
},
{
@ -2484,7 +2544,12 @@
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [],
"~:fills": [
{
"~:fill-color": "#B1B2B5",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": ""
}
@ -2502,7 +2567,12 @@
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [],
"~:fills": [
{
"~:fill-color": "#B1B2B5",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro"
}
]
@ -3433,7 +3503,12 @@
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [],
"~:fills": [
{
"~:fill-color": "#B1B2B5",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": "🔥"
},
@ -3450,7 +3525,12 @@
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [],
"~:fills": [
{
"~:fill-color": "#B1B2B5",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": "👩🏿\u200d🚀"
},
@ -3467,7 +3547,12 @@
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [],
"~:fills": [
{
"~:fill-color": "#B1B2B5",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": "👺"
},
@ -3484,7 +3569,12 @@
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [],
"~:fills": [
{
"~:fill-color": "#B1B2B5",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": "🚀"
}
@ -3502,7 +3592,12 @@
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [],
"~:fills": [
{
"~:fill-color": "#B1B2B5",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro"
},
{
@ -3522,7 +3617,12 @@
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [],
"~:fills": [
{
"~:fill-color": "#B1B2B5",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": ""
}
@ -3540,7 +3640,12 @@
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [],
"~:fills": [
{
"~:fill-color": "#B1B2B5",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro"
}
]

View File

@ -0,0 +1,372 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"plugins/runtime",
"design-tokens/v1",
"layout/grid",
"styles/v2",
"fdata/pointer-map",
"fdata/objects-map",
"render-wasm/v1",
"components/v2",
"fdata/shape-data-type",
"text-editor-wasm/v1"
]
},
"~:team-id": "~u1091e979-bbec-8194-8005-f7aa420b5660",
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "simple-text",
"~:revn": 7,
"~:modified-at": "~m1749629891313",
"~:vern": 0,
"~:id": "~u3b0d758a-8c9d-8013-8006-52c8337e5c72",
"~:is-shared": false,
"~:migrations": {
"~#ordered-set": [
"legacy-2",
"legacy-3",
"legacy-5",
"legacy-6",
"legacy-7",
"legacy-8",
"legacy-9",
"legacy-10",
"legacy-11",
"legacy-12",
"legacy-13",
"legacy-14",
"legacy-16",
"legacy-17",
"legacy-18",
"legacy-19",
"legacy-25",
"legacy-26",
"legacy-27",
"legacy-28",
"legacy-29",
"legacy-31",
"legacy-32",
"legacy-33",
"legacy-34",
"legacy-36",
"legacy-37",
"legacy-38",
"legacy-39",
"legacy-40",
"legacy-41",
"legacy-42",
"legacy-43",
"legacy-44",
"legacy-45",
"legacy-46",
"legacy-47",
"legacy-48",
"legacy-49",
"legacy-50",
"legacy-51",
"legacy-52",
"legacy-53",
"legacy-54",
"legacy-55",
"legacy-56",
"legacy-57",
"legacy-59",
"legacy-62",
"legacy-65",
"legacy-66",
"legacy-67",
"0001-remove-tokens-from-groups",
"0002-normalize-bool-content",
"0002-clean-shape-interactions",
"0003-fix-root-shape",
"0003-convert-path-content",
"0004-clean-shadow-and-colors",
"0005-deprecate-image-type",
"0006-fix-old-texts-fills",
"0007-clear-invalid-strokes-and-fills-v2",
"0008-fix-library-colors-opacity"
]
},
"~:version": 67,
"~:project-id": "~u1091e979-bbec-8194-8005-f7aa420b8b07",
"~:created-at": "~m1749629823499",
"~:data": {
"~:pages": [
"~u3b0d758a-8c9d-8013-8006-52c8337e5c73"
],
"~:pages-index": {
"~u3b0d758a-8c9d-8013-8006-52c8337e5c73": {
"~:objects": {
"~u00000000-0000-0000-0000-000000000000": {
"~#shape": {
"~:y": 0,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:name": "Root Frame",
"~:width": 0.01,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 0.0,
"~:y": 0.0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.01
}
},
{
"~#point": {
"~:x": 0.0,
"~:y": 0.01
}
}
],
"~:r2": 0,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:r3": 0,
"~:r1": 0,
"~:id": "~u00000000-0000-0000-0000-000000000000",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 0,
"~:proportion": 1.0,
"~:r4": 0,
"~:selrect": {
"~#rect": {
"~:x": 0,
"~:y": 0,
"~:width": 0.01,
"~:height": 0.01,
"~:x1": 0,
"~:y1": 0,
"~:x2": 0.01,
"~:y2": 0.01
}
},
"~:fills": [
{
"~:fill-color": "#FFFFFF",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 0.01,
"~:flip-y": null,
"~:shapes": [
"~u7274a6af-66db-8009-8006-52c837bed25d"
]
}
},
"~u7274a6af-66db-8009-8006-52c837bed25d": {
"~#shape": {
"~:y": 368.000005463652,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:grow-type": "~:auto-width",
"~:content": {
"~:type": "root",
"~:key": "13hr3ftth2o",
"~:children": [
{
"~:type": "paragraph-set",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:font-id": "sourcesanspro",
"~:key": "1qm8gi1rphc",
"~:font-size": "48",
"~:font-weight": "400",
"~:typography-ref-file": null,
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": "Hello "
},
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:font-id": "gfont-sora",
"~:key": "2qm8gi1rphd",
"~:font-size": "48",
"~:font-weight": "400",
"~:typography-ref-file": null,
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "Sora",
"~:text": "World"
}
],
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:key": "r8gahivbg7",
"~:font-size": "48",
"~:font-weight": "400",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:type": "paragraph",
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro"
}
]
}
],
"~:vertical-align": "top"
},
"~:hide-in-viewer": false,
"~:name": "this is a text",
"~:width": 237.0000390021974,
"~:type": "~:text",
"~:points": [
{
"~#point": {
"~:x": 414.9999714372273,
"~:y": 368.000005463652
}
},
{
"~#point": {
"~:x": 652.0000104394247,
"~:y": 368.000005463652
}
},
{
"~#point": {
"~:x": 652.0000104394247,
"~:y": 426.0000039162686
}
},
{
"~#point": {
"~:x": 414.9999714372273,
"~:y": 426.0000039162686
}
}
],
"~:layout-item-h-sizing": "~:fix",
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:layout-item-v-sizing": "~:fix",
"~:id": "~u7274a6af-66db-8009-8006-52c837bed25d",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:x": 414.9999714372274,
"~:selrect": {
"~#rect": {
"~:x": 414.9999714372274,
"~:y": 368.000005463652,
"~:width": 237.0000390021974,
"~:height": 57.99999845261664,
"~:x1": 414.9999714372274,
"~:y1": 368.000005463652,
"~:x2": 652.0000104394248,
"~:y2": 426.0000039162686
}
},
"~:flip-x": null,
"~:height": 57.99999845261664,
"~:flip-y": null
}
}
},
"~:id": "~u3b0d758a-8c9d-8013-8006-52c8337e5c73",
"~:name": "Page 1"
}
},
"~:id": "~u3b0d758a-8c9d-8013-8006-52c8337e5c72",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}

View File

@ -0,0 +1,372 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"plugins/runtime",
"design-tokens/v1",
"layout/grid",
"styles/v2",
"fdata/pointer-map",
"fdata/objects-map",
"render-wasm/v1",
"components/v2",
"fdata/shape-data-type",
"text-editor-wasm/v1"
]
},
"~:team-id": "~u1091e979-bbec-8194-8005-f7aa420b5660",
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "simple-text",
"~:revn": 7,
"~:modified-at": "~m1749629891313",
"~:vern": 0,
"~:id": "~u3b0d758a-8c9d-8013-8006-52c8337e5c72",
"~:is-shared": false,
"~:migrations": {
"~#ordered-set": [
"legacy-2",
"legacy-3",
"legacy-5",
"legacy-6",
"legacy-7",
"legacy-8",
"legacy-9",
"legacy-10",
"legacy-11",
"legacy-12",
"legacy-13",
"legacy-14",
"legacy-16",
"legacy-17",
"legacy-18",
"legacy-19",
"legacy-25",
"legacy-26",
"legacy-27",
"legacy-28",
"legacy-29",
"legacy-31",
"legacy-32",
"legacy-33",
"legacy-34",
"legacy-36",
"legacy-37",
"legacy-38",
"legacy-39",
"legacy-40",
"legacy-41",
"legacy-42",
"legacy-43",
"legacy-44",
"legacy-45",
"legacy-46",
"legacy-47",
"legacy-48",
"legacy-49",
"legacy-50",
"legacy-51",
"legacy-52",
"legacy-53",
"legacy-54",
"legacy-55",
"legacy-56",
"legacy-57",
"legacy-59",
"legacy-62",
"legacy-65",
"legacy-66",
"legacy-67",
"0001-remove-tokens-from-groups",
"0002-normalize-bool-content",
"0002-clean-shape-interactions",
"0003-fix-root-shape",
"0003-convert-path-content",
"0004-clean-shadow-and-colors",
"0005-deprecate-image-type",
"0006-fix-old-texts-fills",
"0007-clear-invalid-strokes-and-fills-v2",
"0008-fix-library-colors-opacity"
]
},
"~:version": 67,
"~:project-id": "~u1091e979-bbec-8194-8005-f7aa420b8b07",
"~:created-at": "~m1749629823499",
"~:data": {
"~:pages": [
"~u3b0d758a-8c9d-8013-8006-52c8337e5c73"
],
"~:pages-index": {
"~u3b0d758a-8c9d-8013-8006-52c8337e5c73": {
"~:objects": {
"~u00000000-0000-0000-0000-000000000000": {
"~#shape": {
"~:y": 0,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:name": "Root Frame",
"~:width": 0.01,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 0.0,
"~:y": 0.0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.01
}
},
{
"~#point": {
"~:x": 0.0,
"~:y": 0.01
}
}
],
"~:r2": 0,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:r3": 0,
"~:r1": 0,
"~:id": "~u00000000-0000-0000-0000-000000000000",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 0,
"~:proportion": 1.0,
"~:r4": 0,
"~:selrect": {
"~#rect": {
"~:x": 0,
"~:y": 0,
"~:width": 0.01,
"~:height": 0.01,
"~:x1": 0,
"~:y1": 0,
"~:x2": 0.01,
"~:y2": 0.01
}
},
"~:fills": [
{
"~:fill-color": "#FFFFFF",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 0.01,
"~:flip-y": null,
"~:shapes": [
"~u7274a6af-66db-8009-8006-52c837bed25d"
]
}
},
"~u7274a6af-66db-8009-8006-52c837bed25d": {
"~#shape": {
"~:y": 368.000005463652,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:grow-type": "~:auto-width",
"~:content": {
"~:type": "root",
"~:key": "13hr3ftth2o",
"~:children": [
{
"~:type": "paragraph-set",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:font-id": "sourcesanspro",
"~:key": "1qm8gi1rphc",
"~:font-size": "48",
"~:font-weight": "400",
"~:typography-ref-file": null,
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": "Hello "
},
{
"~:line-height": "1.2",
"~:font-style": "italic",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:font-id": "sourcesanspro",
"~:key": "2qm8gi1rphd",
"~:font-size": "48",
"~:font-weight": "600",
"~:typography-ref-file": null,
"~:font-variant-id": "600italic",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": "World"
}
],
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:key": "r8gahivbg7",
"~:font-size": "48",
"~:font-weight": "400",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:type": "paragraph",
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro"
}
]
}
],
"~:vertical-align": "top"
},
"~:hide-in-viewer": false,
"~:name": "this is a text",
"~:width": 237.0000390021974,
"~:type": "~:text",
"~:points": [
{
"~#point": {
"~:x": 414.9999714372273,
"~:y": 368.000005463652
}
},
{
"~#point": {
"~:x": 652.0000104394247,
"~:y": 368.000005463652
}
},
{
"~#point": {
"~:x": 652.0000104394247,
"~:y": 426.0000039162686
}
},
{
"~#point": {
"~:x": 414.9999714372273,
"~:y": 426.0000039162686
}
}
],
"~:layout-item-h-sizing": "~:fix",
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:layout-item-v-sizing": "~:fix",
"~:id": "~u7274a6af-66db-8009-8006-52c837bed25d",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:x": 414.9999714372274,
"~:selrect": {
"~#rect": {
"~:x": 414.9999714372274,
"~:y": 368.000005463652,
"~:width": 237.0000390021974,
"~:height": 57.99999845261664,
"~:x1": 414.9999714372274,
"~:y1": 368.000005463652,
"~:x2": 652.0000104394248,
"~:y2": 426.0000039162686
}
},
"~:flip-x": null,
"~:height": 57.99999845261664,
"~:flip-y": null
}
}
},
"~:id": "~u3b0d758a-8c9d-8013-8006-52c8337e5c73",
"~:name": "Page 1"
}
},
"~:id": "~u3b0d758a-8c9d-8013-8006-52c8337e5c72",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}

View File

@ -0,0 +1,382 @@
{
"~:features": {
"~#set": [
"design-tokens/v1",
"layout/grid",
"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": "Tooltip check",
"~:revn": 36,
"~:modified-at": "~m1737542758402",
"~:vern": 0,
"~:id": "~uc7ce0794-0992-8105-8004-38f280443849",
"~:is-shared": false,
"~:version": 60,
"~:project-id": "~u0df61468-6cbf-8067-8005-6b453ce996d0",
"~:created-at": "~m1737536563847",
"~:data": {
"~:pages": [
"~u4530574a-7a0a-807b-8008-0107b2c4628e"
],
"~:pages-index": {
"~u4530574a-7a0a-807b-8008-0107b2c4628e": {
"~:id": "~u4530574a-7a0a-807b-8008-0107b2c4628e",
"~:name": "Page 1",
"~: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]],\"~:page-id\",\"~u4530574a-7a0a-807b-8008-0107b2c4628e\",\"~: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\",[\"~ua7df6ab8-bee5-802f-8008-010d9ff3da26\",\"~uf2256046-b127-808e-8008-411c4cba63d8\",\"~u1de8c150-538b-80b2-8008-40356208fc39\",\"~uf2256046-b127-808e-8008-411c4cba63d9\",\"~u1de8c150-538b-80b2-8008-4036773cc8d0\",\"~u886f2846-a2c5-808d-8008-4115e86119e9\",\"~u1de8c150-538b-80b2-8008-403b5150a59b\",\"~u886f2846-a2c5-808d-8008-4115e86119ea\",\"~u20f9ccb0-68e2-80d9-8008-413fa842a6b5\",\"~u20f9ccb0-68e2-80d9-8008-413fb1f2ef51\"]]]",
"~u1de8c150-538b-80b2-8008-403b5150a59b": "[\"~#shape\",[\"^ \",\"~:y\",419,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Deleted token rect\",\"~:width\",162,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",-147,\"~:y\",419]],[\"^<\",[\"^ \",\"~:x\",15,\"~:y\",419]],[\"^<\",[\"^ \",\"~:x\",15,\"~:y\",565]],[\"^<\",[\"^ \",\"~:x\",-147,\"~:y\",565]]],\"~:r2\",20,\"~: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\",20,\"~:r1\",20,\"~:id\",\"~u1de8c150-538b-80b2-8008-403b5150a59b\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:applied-tokens\",[\"^ \",\"~:fill\",\"blue\",\"^A\",\"deleted\",\"^=\",\"deleted\",\"^@\",\"deleted\",\"~:r4\",\"deleted\"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",-147,\"~:proportion\",1,\"^F\",20,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",-147,\"~:y\",419,\"^8\",162,\"~:height\",146,\"~:x1\",-147,\"~:y1\",419,\"~:x2\",15,\"~:y2\",565]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#0a20bf\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^L\",146,\"~:flip-y\",null]]",
"~u20f9ccb0-68e2-80d9-8008-413fa842a6b5": "[\"~#shape\",[\"^ \",\"~:y\",369,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle with token\",\"~:width\",274.99999999999994,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",-536,\"~:y\",369]],[\"^<\",[\"^ \",\"~:x\",-261.00000000000006,\"~:y\",369]],[\"^<\",[\"^ \",\"~:x\",-261.00000000000006,\"~:y\",565]],[\"^<\",[\"^ \",\"~:x\",-536,\"~:y\",565]]],\"~:r2\",50,\"~: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\",50,\"~:r1\",50,\"~:id\",\"~u20f9ccb0-68e2-80d9-8008-413fa842a6b5\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:applied-tokens\",[\"^ \",\"~:fill\",\"out-ref\",\"^A\",\"base-50\",\"^=\",\"base-50\",\"^@\",\"base-50\",\"~:r4\",\"base-50\"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",-536,\"~:proportion\",1,\"^F\",50,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",-536,\"~:y\",369,\"^8\",274.99999999999994,\"~:height\",196,\"~:x1\",-536,\"~:y1\",369,\"~:x2\",-261.00000000000006,\"~:y2\",565]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#da1fea\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^L\",196,\"~:flip-y\",null]]",
"~u1de8c150-538b-80b2-8008-4036773cc8d0": "[\"~#shape\",[\"^ \",\"~:y\",588,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:content\",[\"^ \",\"~:type\",\"root\",\"~:children\",[[\"^ \",\"^7\",\"paragraph-set\",\"^8\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^8\",[[\"^ \",\"^9\",\"1.2\",\"^:\",\"normal\",\"~:text-transform\",\"none\",\"~:text-align\",\"left\",\"~:font-id\",\"gfont-alegreya-sans-sc\",\"~:font-size\",\"23\",\"~:font-weight\",\"400\",\"~:text-direction\",\"ltr\",\"~:font-variant-id\",\"regular\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#000000\",\"~:fill-opacity\",1]],\"~:font-family\",\"Alegreya Sans SC\",\"~:text\",\"Text with deleted token\"]],\"^;\",\"none\",\"^<\",\"left\",\"^=\",\"gfont-alegreya-sans-sc\",\"~:key\",\"fu3vh\",\"^>\",\"23\",\"^?\",\"400\",\"^@\",\"ltr\",\"^7\",\"paragraph\",\"^A\",\"regular\",\"^B\",\"none\",\"^C\",\"0\",\"^D\",[[\"^ \",\"^E\",\"#000000\",\"^F\",1]],\"^G\",\"Alegreya Sans SC\"]]]]],\"~:hide-in-viewer\",false,\"~:name\",\"Text with deleted token\",\"~:width\",198,\"^7\",\"^H\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",-161,\"~:y\",588]],[\"^N\",[\"^ \",\"~:x\",37,\"~:y\",588]],[\"^N\",[\"^ \",\"~:x\",37,\"~:y\",641]],[\"^N\",[\"^ \",\"~:x\",-161,\"~:y\",641]]],\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~u1de8c150-538b-80b2-8008-4036773cc8d0\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:applied-tokens\",[\"^ \",\"~:typography\",\"deleted-typo\"],\"~:position-data\",[[\"~#rect\",[\"^ \",\"~:y\",613,\"^:\",\"normal\",\"^;\",\"none\",\"^>\",\"23px\",\"^?\",\"400\",\"~:y1\",2,\"^L\",176.203125,\"^B\",\"none\",\"^C\",\"normal\",\"~:x\",-161,\"~:x1\",0,\"~:y2\",25,\"^D\",[[\"^ \",\"^E\",\"#000000\",\"^F\",1]],\"~:x2\",176.203125,\"~:direction\",\"ltr\",\"^G\",\"\\\"Alegreya Sans SC\\\"\",\"~:height\",23,\"^H\",\"Text with deleted \"]],[\"^U\",[\"^ \",\"~:y\",640.59375,\"^:\",\"normal\",\"^;\",\"none\",\"^>\",\"23px\",\"^?\",\"400\",\"^V\",29.59375,\"^L\",56.9375,\"^B\",\"none\",\"^C\",\"normal\",\"~:x\",-161,\"^W\",0,\"^X\",52.59375,\"^D\",[[\"^ \",\"^E\",\"#000000\",\"^F\",1]],\"^Y\",56.9375,\"^Z\",\"ltr\",\"^G\",\"\\\"Alegreya Sans SC\\\"\",\"^[\",23,\"^H\",\"token\"]]],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:x\",-161,\"~:selrect\",[\"^U\",[\"^ \",\"~:x\",-161,\"~:y\",588,\"^L\",198,\"^[\",53,\"^W\",-161,\"^V\",588,\"^Y\",37,\"^X\",641]],\"~:flip-x\",null,\"^[\",53,\"~:flip-y\",null]]",
"~u20f9ccb0-68e2-80d9-8008-413fb1f2ef51": "[\"~#shape\",[\"^ \",\"~:y\",609,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:content\",[\"^ \",\"~:type\",\"root\",\"~:children\",[[\"^ \",\"^7\",\"paragraph-set\",\"^8\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^8\",[[\"^ \",\"^9\",\"1.2\",\"^:\",\"normal\",\"~:text-transform\",\"none\",\"~:text-align\",\"left\",\"~:font-id\",\"gfont-arizonia\",\"~:font-size\",\"23\",\"~:font-weight\",\"400\",\"~:text-direction\",\"ltr\",\"~:font-variant-id\",\"regular\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#da1fea\",\"~:fill-opacity\",1]],\"~:font-family\",\"Arizonia\",\"~:text\",\"Text with token\"]],\"^;\",\"none\",\"^<\",\"left\",\"^=\",\"gfont-arizonia\",\"~:key\",\"fu3vh\",\"^>\",\"23\",\"^?\",\"400\",\"^@\",\"ltr\",\"^7\",\"paragraph\",\"^A\",\"regular\",\"^B\",\"none\",\"^C\",\"0\",\"^D\",[[\"^ \",\"^E\",\"#da1fea\",\"^F\",1]],\"^G\",\"Arizonia\"]]]]],\"~:hide-in-viewer\",false,\"~:name\",\"Text with token\",\"~:width\",198,\"^7\",\"^H\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",-516,\"~:y\",609]],[\"^N\",[\"^ \",\"~:x\",-318,\"~:y\",609]],[\"^N\",[\"^ \",\"~:x\",-318,\"~:y\",662]],[\"^N\",[\"^ \",\"~:x\",-516,\"~:y\",662]]],\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~u20f9ccb0-68e2-80d9-8008-413fb1f2ef51\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:applied-tokens\",[\"^ \",\"~:typography\",\"out-typo\",\"~:fill\",\"out-ref\"],\"~:position-data\",[[\"~#rect\",[\"^ \",\"~:y\",637,\"^:\",\"normal\",\"^;\",\"none\",\"^>\",\"23px\",\"^?\",\"400\",\"~:y1\",-1,\"^L\",117.453125,\"^B\",\"none\",\"^C\",\"normal\",\"~:x\",-516,\"~:x1\",0,\"~:y2\",28,\"^D\",[[\"^ \",\"^E\",\"#da1fea\",\"^F\",1]],\"~:x2\",117.453125,\"~:direction\",\"ltr\",\"^G\",\"Arizonia\",\"~:height\",29,\"^H\",\"Text with token\"]]],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",-516,\"~:selrect\",[\"^V\",[\"^ \",\"~:x\",-516,\"~:y\",609,\"^L\",198,\"^10\",53,\"^X\",-516,\"^W\",609,\"^Z\",-318,\"^Y\",662]],\"~:flip-x\",null,\"^10\",53,\"~:flip-y\",null]]",
"~uf2256046-b127-808e-8008-411c4cba63d8": "[\"~#shape\",[\"^ \",\"~:y\",267,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle with not active reference\",\"~:width\",287,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",390,\"~:y\",267]],[\"^<\",[\"^ \",\"~:x\",677,\"~:y\",267]],[\"^<\",[\"^ \",\"~:x\",677,\"~:y\",579]],[\"^<\",[\"^ \",\"~:x\",390,\"~:y\",579]]],\"~:r2\",50,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u4530574a-7a0a-807b-8008-0107b2c4628e\",\"~:r3\",50,\"~:r1\",50,\"~:id\",\"~uf2256046-b127-808e-8008-411c4cba63d8\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:applied-tokens\",[\"^ \",\"~:fill\",\"in-color\",\"^=\",\"in-br\",\"^A\",\"in-br\",\"^B\",\"in-br\",\"~:r4\",\"in-br\"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",390,\"~:proportion\",1,\"^G\",50,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",390,\"~:y\",267,\"^8\",287,\"~:height\",312,\"~:x1\",390,\"~:y1\",267,\"~:x2\",677,\"~:y2\",579]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#da1fea\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^M\",312,\"~:flip-y\",null]]",
"~uf2256046-b127-808e-8008-411c4cba63d9": "[\"~#shape\",[\"^ \",\"~:y\",602,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:content\",[\"^ \",\"~:type\",\"root\",\"~:children\",[[\"^ \",\"^7\",\"paragraph-set\",\"^8\",[[\"^ \",\"~:line-height\",\"1.2\",\"^8\",[[\"^ \",\"^9\",\"1.2\",\"~:text-transform\",\"none\",\"~:text-align\",\"left\",\"~:font-id\",\"gfont-arizonia\",\"~:font-size\",\"14\",\"~:font-weight\",\"200\",\"~:text-direction\",\"ltr\",\"~:font-variant-id\",\"regular\",\"~:weight\",\"200\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#da1fea\",\"~:fill-opacity\",1]],\"~:font-family\",\"Arizonia\",\"~:text\",\"Text with not active reference\"]],\"^:\",\"none\",\"^;\",\"left\",\"^<\",\"gfont-arizonia\",\"~:key\",\"d22fo\",\"^=\",\"14\",\"^>\",\"200\",\"^?\",\"ltr\",\"^7\",\"paragraph\",\"^@\",\"regular\",\"^A\",\"200\",\"^B\",\"none\",\"^C\",\"0\",\"^D\",[[\"^ \",\"^E\",\"#da1fea\",\"^F\",1]],\"^G\",\"Arizonia\"]]]]],\"~:hide-in-viewer\",false,\"~:name\",\"Text with not active reference\",\"~:width\",139,\"^7\",\"^H\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",479,\"~:y\",602]],[\"^N\",[\"^ \",\"~:x\",618,\"~:y\",602]],[\"^N\",[\"^ \",\"~:x\",618,\"~:y\",630]],[\"^N\",[\"^ \",\"~:x\",479,\"~:y\",630]]],\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~uf2256046-b127-808e-8008-411c4cba63d9\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:applied-tokens\",[\"^ \",\"~:fill\",\"in-color\",\"~:typography\",\"in-typo\"],\"~:position-data\",[[\"~#rect\",[\"^ \",\"~:y\",619,\"~:font-style\",\"normal\",\"^:\",\"none\",\"^=\",\"14px\",\"^>\",\"400\",\"~:y1\",-1,\"^L\",97.46875,\"^B\",\"none\",\"^C\",\"normal\",\"~:x\",479,\"~:x1\",0,\"~:y2\",17,\"^D\",[[\"^ \",\"^E\",\"#da1fea\",\"^F\",1]],\"~:x2\",97.46875,\"~:direction\",\"ltr\",\"^G\",\"Arizonia\",\"~:height\",18,\"^H\",\"Text with not active \"]],[\"^V\",[\"^ \",\"~:y\",635.796875,\"^W\",\"normal\",\"^:\",\"none\",\"^=\",\"14px\",\"^>\",\"400\",\"^X\",15.796875,\"^L\",42.8125,\"^B\",\"none\",\"^C\",\"normal\",\"~:x\",479,\"^Y\",0,\"^Z\",33.796875,\"^D\",[[\"^ \",\"^E\",\"#da1fea\",\"^F\",1]],\"^[\",42.8125,\"^10\",\"ltr\",\"^G\",\"Arizonia\",\"^11\",18,\"^H\",\"reference\"]]],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:x\",479,\"~:selrect\",[\"^V\",[\"^ \",\"~:x\",479,\"~:y\",602,\"^L\",139,\"^11\",28,\"^Y\",479,\"^X\",602,\"^[\",618,\"^Z\",630]],\"~:flip-x\",null,\"^11\",28,\"~:flip-y\",null]]",
"~u1de8c150-538b-80b2-8008-40356208fc39": "[\"~#shape\",[\"^ \",\"~:y\",609,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:content\",[\"^ \",\"~:type\",\"root\",\"~:children\",[[\"^ \",\"^7\",\"paragraph-set\",\"^8\",[[\"^ \",\"~:line-height\",\"1.2\",\"^8\",[[\"^ \",\"^9\",\"1.2\",\"~:text-transform\",\"none\",\"~:text-align\",\"left\",\"~:font-id\",\"gfont-ar-one-sans\",\"~:font-size\",\"14\",\"~:font-weight\",\"200\",\"~:text-direction\",\"ltr\",\"~:font-variant-id\",\"regular\",\"~:weight\",\"200\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ef0c0c\",\"~:fill-opacity\",1]],\"~:font-family\",\"AR One Sans\",\"~:text\",\"Text with not active token\"]],\"^:\",\"none\",\"^;\",\"left\",\"^<\",\"gfont-ar-one-sans\",\"~:key\",\"d22fo\",\"^=\",\"14\",\"^>\",\"200\",\"^?\",\"ltr\",\"^7\",\"paragraph\",\"^@\",\"regular\",\"^A\",\"200\",\"^B\",\"none\",\"^C\",\"0\",\"^D\",[[\"^ \",\"^E\",\"#ef0c0c\",\"^F\",1]],\"^G\",\"AR One Sans\"]]]]],\"~:hide-in-viewer\",false,\"~:name\",\"Text with not active token\",\"~:width\",139,\"^7\",\"^H\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",812,\"~:y\",609]],[\"^N\",[\"^ \",\"~:x\",951,\"~:y\",609]],[\"^N\",[\"^ \",\"~:x\",951,\"~:y\",637]],[\"^N\",[\"^ \",\"~:x\",812,\"~:y\",637]]],\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~u1de8c150-538b-80b2-8008-40356208fc39\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:applied-tokens\",[\"^ \",\"~:fill\",\"red\",\"~:typography\",\"typo-2\"],\"~:position-data\",[[\"~#rect\",[\"^ \",\"~:y\",626,\"~:font-style\",\"normal\",\"^:\",\"none\",\"^=\",\"14px\",\"^>\",\"400\",\"~:y1\",-2,\"^L\",134.625,\"^B\",\"none\",\"^C\",\"normal\",\"~:x\",812,\"~:x1\",0,\"~:y2\",17,\"^D\",[[\"^ \",\"^E\",\"#ef0c0c\",\"^F\",1]],\"~:x2\",134.625,\"~:direction\",\"ltr\",\"^G\",\"\\\"AR One Sans\\\"\",\"~:height\",19,\"^H\",\"Text with not active \"]],[\"^V\",[\"^ \",\"~:y\",642.796875,\"^W\",\"normal\",\"^:\",\"none\",\"^=\",\"14px\",\"^>\",\"400\",\"^X\",14.796875,\"^L\",37.40625,\"^B\",\"none\",\"^C\",\"normal\",\"~:x\",812,\"^Y\",0,\"^Z\",33.796875,\"^D\",[[\"^ \",\"^E\",\"#ef0c0c\",\"^F\",1]],\"^[\",37.40625,\"^10\",\"ltr\",\"^G\",\"\\\"AR One Sans\\\"\",\"^11\",19,\"^H\",\"token\"]]],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:x\",812,\"~:selrect\",[\"^V\",[\"^ \",\"~:x\",812,\"~:y\",609,\"^L\",139,\"^11\",28,\"^Y\",812,\"^X\",609,\"^[\",951,\"^Z\",637]],\"~:flip-x\",null,\"^11\",28,\"~:flip-y\",null]]",
"~u886f2846-a2c5-808d-8008-4115e86119ea": "[\"~#shape\",[\"^ \",\"~:y\",399,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle with deleted reference\",\"~:width\",162,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",193,\"~:y\",399]],[\"^<\",[\"^ \",\"~:x\",355,\"~:y\",399]],[\"^<\",[\"^ \",\"~:x\",355,\"~:y\",545]],[\"^<\",[\"^ \",\"~:x\",193,\"~:y\",545]]],\"~:r2\",20,\"~: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\",20,\"~:r1\",20,\"~:id\",\"~u886f2846-a2c5-808d-8008-4115e86119ea\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:applied-tokens\",[\"^ \",\"~:fill\",\"ref-grfeen\",\"^A\",\"ref-1\",\"^=\",\"ref-1\",\"^@\",\"ref-1\",\"~:r4\",\"ref-1\"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",193,\"~:proportion\",1,\"^F\",20,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",193,\"~:y\",399,\"^8\",162,\"~:height\",146,\"~:x1\",193,\"~:y1\",399,\"~:x2\",355,\"~:y2\",545]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#0a20bf\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^L\",146,\"~:flip-y\",null]]",
"~u886f2846-a2c5-808d-8008-4115e86119e9": "[\"~#shape\",[\"^ \",\"~:y\",586,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:content\",[\"^ \",\"~:type\",\"root\",\"~:children\",[[\"^ \",\"^7\",\"paragraph-set\",\"^8\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^8\",[[\"^ \",\"^9\",\"1.2\",\"^:\",\"normal\",\"~:text-transform\",\"none\",\"~:text-align\",\"left\",\"~:font-id\",\"gfont-adlam-display\",\"~:font-size\",\"23\",\"~:font-weight\",\"400\",\"~:text-direction\",\"ltr\",\"~:font-variant-id\",\"regular\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#000000\",\"~:fill-opacity\",1]],\"~:font-family\",\"ADLaM Display\",\"~:text\",\"Text with deleted reference\"]],\"^;\",\"none\",\"^<\",\"left\",\"^=\",\"gfont-adlam-display\",\"~:key\",\"fu3vh\",\"^>\",\"23\",\"^?\",\"400\",\"^@\",\"ltr\",\"^7\",\"paragraph\",\"^A\",\"regular\",\"^B\",\"none\",\"^C\",\"0\",\"^D\",[[\"^ \",\"^E\",\"#000000\",\"^F\",1]],\"^G\",\"ADLaM Display\"]]]]],\"~:hide-in-viewer\",false,\"~:name\",\"Text with deleted reference\",\"~:width\",137,\"^7\",\"^H\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",198,\"~:y\",586]],[\"^N\",[\"^ \",\"~:x\",335,\"~:y\",586]],[\"^N\",[\"^ \",\"~:x\",335,\"~:y\",683]],[\"^N\",[\"^ \",\"~:x\",198,\"~:y\",683]]],\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~u886f2846-a2c5-808d-8008-4115e86119e9\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:applied-tokens\",[\"^ \",\"~:fill\",\"ref-grfeen\",\"~:typography\",\"ref-typo\"],\"~:position-data\",[[\"~#rect\",[\"^ \",\"~:y\",614,\"^:\",\"normal\",\"^;\",\"none\",\"^>\",\"23px\",\"^?\",\"400\",\"~:y1\",-2,\"^L\",107.921875,\"^B\",\"none\",\"^C\",\"normal\",\"~:x\",198,\"~:x1\",0,\"~:y2\",28,\"^D\",[[\"^ \",\"^E\",\"#000000\",\"^F\",1]],\"~:x2\",107.921875,\"~:direction\",\"ltr\",\"^G\",\"\\\"ADLaM Display\\\"\",\"~:height\",30,\"^H\",\"Text with \"]],[\"^V\",[\"^ \",\"~:y\",641.59375,\"^:\",\"normal\",\"^;\",\"none\",\"^>\",\"23px\",\"^?\",\"400\",\"^W\",25.59375,\"^L\",88.890625,\"^B\",\"none\",\"^C\",\"normal\",\"~:x\",198,\"^X\",0,\"^Y\",55.59375,\"^D\",[[\"^ \",\"^E\",\"#000000\",\"^F\",1]],\"^Z\",88.890625,\"^[\",\"ltr\",\"^G\",\"\\\"ADLaM Display\\\"\",\"^10\",30,\"^H\",\"deleted \"]],[\"^V\",[\"^ \",\"~:y\",669.1875,\"^:\",\"normal\",\"^;\",\"none\",\"^>\",\"23px\",\"^?\",\"400\",\"^W\",53.1875,\"^L\",103.46875,\"^B\",\"none\",\"^C\",\"normal\",\"~:x\",198,\"^X\",0,\"^Y\",83.1875,\"^D\",[[\"^ \",\"^E\",\"#000000\",\"^F\",1]],\"^Z\",103.46875,\"^[\",\"ltr\",\"^G\",\"\\\"ADLaM Display\\\"\",\"^10\",30,\"^H\",\"reference\"]]],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:x\",198,\"~:selrect\",[\"^V\",[\"^ \",\"~:x\",198,\"~:y\",586,\"^L\",137,\"^10\",97,\"^X\",198,\"^W\",586,\"^Z\",335,\"^Y\",683]],\"~:flip-x\",null,\"^10\",97,\"~:flip-y\",null]]",
"~ua7df6ab8-bee5-802f-8008-010d9ff3da26": "[\"~#shape\",[\"^ \",\"~:y\",274,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle with not active token\",\"~:width\",287,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",723,\"~:y\",274]],[\"^<\",[\"^ \",\"~:x\",1010,\"~:y\",274]],[\"^<\",[\"^ \",\"~:x\",1010,\"~:y\",586]],[\"^<\",[\"^ \",\"~:x\",723,\"~:y\",586]]],\"~:r2\",100,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u4530574a-7a0a-807b-8008-0107b2c4628e\",\"~:r3\",100,\"~:r1\",100,\"~:id\",\"~ua7df6ab8-bee5-802f-8008-010d9ff3da26\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:applied-tokens\",[\"^ \",\"~:fill\",\"red\",\"^B\",\"border-radius\",\"^=\",\"border-radius\",\"^A\",\"border-radius\",\"~:r4\",\"border-radius\"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",723,\"~:proportion\",1,\"^G\",100,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",723,\"~:y\",274,\"^8\",287,\"~:height\",312,\"~:x1\",723,\"~:y1\",274,\"~:x2\",1010,\"~:y2\",586]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ef0c0c\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^M\",312,\"~:flip-y\",null]]"
}
}
}
},
"~:tokens-lib": {
"~#penpot/tokens-lib": {
"~:sets": {
"~#ordered-map": [
[
"S-Global",
{
"~#penpot/token-set": {
"~:id": "~uf71719b3-63a1-8157-8008-40346917de8c",
"~:name": "Global",
"~:description": "",
"~:modified-at": "~m1782813028640",
"~:tokens": {
"~#ordered-map": [
[
"base-50",
{
"~#penpot/token": {
"~:id": "~uf71719b3-63a1-8157-8008-40346917de87",
"~:name": "base-50",
"~:type": "~:border-radius",
"~:value": "50",
"~:description": "",
"~:modified-at": "~m1782742859871"
}
}
],
[
"out-ref",
{
"~#penpot/token": {
"~:id": "~uf2256046-b127-808e-8008-411bec9b1109",
"~:name": "out-ref",
"~:type": "~:color",
"~:value": "rgb(218, 31, 234)",
"~:description": "",
"~:modified-at": "~m1782803549804"
}
}
],
[
"out-typo",
{
"~#penpot/token": {
"~:id": "~uf2256046-b127-808e-8008-411bf90df57e",
"~:name": "out-typo",
"~:type": "~:typography",
"~:value": {
"~:font-family": [
"Arizonia"
]
},
"~:description": "",
"~:modified-at": "~m1782803562551"
}
}
]
]
}
}
}
],
[
"G-Mode",
{
"~#ordered-map": [
[
"S-Dark",
{
"~#penpot/token-set": {
"~:id": "~uf71719b3-63a1-8157-8008-40346917de8d",
"~:name": "Mode/Dark",
"~:description": "",
"~:modified-at": "~m1782803647245",
"~:tokens": {
"~#ordered-map": [
[
"red",
{
"~#penpot/token": {
"~:id": "~uf71719b3-63a1-8157-8008-40346917de88",
"~:name": "red",
"~:type": "~:color",
"~:value": "#ef0c0c",
"~:description": "",
"~:modified-at": "~m1782742859871"
}
}
],
[
"ref-grfeen",
{
"~#penpot/token": {
"~:id": "~u886f2846-a2c5-808d-8008-4115e5fdae2e",
"~:name": "ref-grfeen",
"~:type": "~:color",
"~:value": "{green}",
"~:description": "",
"~:modified-at": "~m1782801970166"
}
}
],
[
"ref-typo",
{
"~#penpot/token": {
"~:id": "~u886f2846-a2c5-808d-8008-41160c4641f4",
"~:name": "ref-typo",
"~:type": "~:typography",
"~:value": "{typo1}",
"~:description": "",
"~:modified-at": "~m1782802009369"
}
}
],
[
"ref-1",
{
"~#penpot/token": {
"~:id": "~u886f2846-a2c5-808d-8008-411628261dc7",
"~:name": "ref-1",
"~:type": "~:border-radius",
"~:value": "{b1}",
"~:description": "",
"~:modified-at": "~m1782802037912"
}
}
],
[
"in-br",
{
"~#penpot/token": {
"~:id": "~uf2256046-b127-808e-8008-411c0aced089",
"~:name": "in-br",
"~:type": "~:border-radius",
"~:value": "{base-50}",
"~:description": "",
"~:modified-at": "~m1782803580731"
}
}
],
[
"in-color",
{
"~#penpot/token": {
"~:id": "~uf2256046-b127-808e-8008-411c263c999b",
"~:name": "in-color",
"~:type": "~:color",
"~:value": "{out-ref}",
"~:description": "",
"~:modified-at": "~m1782803608818"
}
}
],
[
"in-typo",
{
"~#penpot/token": {
"~:id": "~uf2256046-b127-808e-8008-411c472f33d3",
"~:name": "in-typo",
"~:type": "~:typography",
"~:value": "{out-typo}",
"~:description": "",
"~:modified-at": "~m1782803642556"
}
}
]
]
}
}
}
],
[
"S-Light",
{
"~#penpot/token-set": {
"~:id": "~uf71719b3-63a1-8157-8008-40346917de8e",
"~:name": "Mode/Light",
"~:description": "",
"~:modified-at": "~m1782812999332",
"~:tokens": {
"~#ordered-map": [
[
"red",
{
"~#penpot/token": {
"~:id": "~uf71719b3-63a1-8157-8008-40346917de89",
"~:name": "red",
"~:type": "~:color",
"~:value": "#ec9090",
"~:description": "",
"~:modified-at": "~m1782742859871"
}
}
],
[
"typo-2",
{
"~#penpot/token": {
"~:id": "~u1de8c150-538b-80b2-8008-40357ed6d13a",
"~:name": "typo-2",
"~:type": "~:typography",
"~:value": {
"~:font-family": [
"AR One Sans"
],
"~:font-weight": "200"
},
"~:description": "",
"~:modified-at": "~m1782812999332"
}
}
],
[
"border-radius",
{
"~#penpot/token": {
"~:id": "~uf2256046-b127-808e-8008-4134c130c292",
"~:name": "border-radius",
"~:type": "~:border-radius",
"~:value": "{base-50} * 2",
"~:description": "",
"~:modified-at": "~m1782810058947"
}
}
]
]
}
}
}
]
]
}
]
]
},
"~: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": "~m1782812922760",
"~:sets": {
"~#set": [
"Global"
]
}
}
}
]
]
}
],
[
"App",
{
"~#ordered-map": [
[
"Web",
{
"~#penpot/token-theme": {
"~:id": "~u37900259-7f15-8003-8007-9ee75dd2f2d2",
"~:name": "Web",
"~:group": "App",
"~:description": "",
"~:is-source": false,
"~:external-id": "37900259-7f15-8003-8007-9ee75dd2f2d2",
"~:modified-at": "~m1782810072529",
"~:sets": {
"~#set": [
"Mode/Dark",
"Global"
]
}
}
}
],
[
"Mobile",
{
"~#penpot/token-theme": {
"~:id": "~u37900259-7f15-8003-8007-9ee77eb567bd",
"~:name": "Mobile",
"~:group": "App",
"~:description": "",
"~:is-source": false,
"~:external-id": "37900259-7f15-8003-8007-9ee77eb567bd",
"~:modified-at": "~m1782810072529",
"~:sets": {
"~#set": [
"Mode/Light",
"Global"
]
}
}
}
]
]
}
]
]
},
"~:active-themes": {
"~#set": [
"/__PENPOT__HIDDEN__TOKEN__THEME__"
]
}
}
},
"~:id": "~u55608328-aed3-807a-8008-41341f2f7247",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}

View File

@ -378,6 +378,29 @@ export class WorkspacePage extends BaseWebSocketPage {
}
}
/**
* Creates a new auto-width Text Shape by single-clicking at the given
* coordinates (as opposed to dragging a fixed-size box) and, optionally,
* types an initial text.
*
* @param {number} x
* @param {number} y
* @param {string} [initialText]
* @param {*} [options]
*/
async createAutoWidthTextShape(x, y, initialText, options) {
const timeToWait = options?.timeToWait ?? 100;
await this.page.keyboard.press("T");
await this.page.waitForTimeout(timeToWait);
await this.clickAt(x, y);
if (initialText) {
await this.waitForSelectedShapeName("Text");
await this.page.keyboard.type(initialText);
}
}
/**
* Copies the selected element into the clipboard, or copy the
* content of the locator into the clipboard.

View File

@ -162,7 +162,7 @@ test("Updates canvas background", async ({ page }) => {
const canvasBackgroundInput = workspace.page.getByRole("textbox", {
name: "Color",
});
}).first();
await canvasBackgroundInput.fill("FABADA");
await workspace.page.keyboard.press("Enter");
await workspace.waitForFirstRenderWithoutUI();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

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