mirror of
https://github.com/penpot/penpot.git
synced 2026-09-08 19:59:54 -04:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d89f80a046 | ||
|
|
70cf3b743d | ||
|
|
003a484540 | ||
|
|
cfa937acde | ||
|
|
67630c8c38 | ||
|
|
33f44dab73 |
No files matched your search
@@ -1,90 +0,0 @@
|
||||
# Agent skills
|
||||
|
||||
This folder is the single home for the skills our coding agents use.
|
||||
Each skill is a folder with a `SKILL.md` inside — a short instruction
|
||||
manual that an agent loads only when it needs it.
|
||||
|
||||
One copy serves every tool:
|
||||
|
||||
- **opencode** reads this folder directly.
|
||||
- **Claude Code** reads it through the `.claude/skills` symlink.
|
||||
- **Codex** reads it directly.
|
||||
|
||||
To change how the agents behave, edit the `SKILL.md` here. There is no
|
||||
second copy to keep in sync.
|
||||
|
||||
## How the skills are organized
|
||||
|
||||
**Flows** are the six skills you invoke by name. Each one covers one step
|
||||
in the life of a change: plan it, review the plan, implement it, review
|
||||
the code, open the pull request.
|
||||
|
||||
**References** hold the quality standards. A flow's reviewer loads them;
|
||||
you rarely touch them directly.
|
||||
|
||||
**Procedures** define how one concrete step is done — a plan document, an
|
||||
issue, a commit. Flows call them, but they also work on their own.
|
||||
|
||||
**Utilities** are small helpers for everyday work: search, file lookup,
|
||||
JSON, REPL access, and so on.
|
||||
|
||||
## Flows
|
||||
|
||||
| Skill | What it does | When you would say |
|
||||
|---|---|---|
|
||||
| [`make-a-plan`](skills/make-a-plan/SKILL.md) | Researches the task, writes an implementation plan, asks you the open questions in plain language, and saves the plan to `.agents/plans/`. | "make a plan for the token refresh bug" |
|
||||
| [`review-plan`](skills/review-plan/SKILL.md) | Evaluates a plan before anyone writes code: completeness, ordering, risks. Approves it or asks for changes. | "review this plan before we start" |
|
||||
| [`implement-plan`](skills/implement-plan/SKILL.md) | Shows you the full flow first — the issue and branch it will create (or the branch it continues on), the execution style, and the task checklist — and, after your go-ahead, executes a ready plan. Default: every task, one commit. On request ("step by step"): one task, one commit, your confirmation between tasks. | "implement the plan" · "step by step, one commit per task" |
|
||||
| [`review-code`](skills/review-code/SKILL.md) | Reviews a diff, branch, or PR and returns findings ranked by impact. | "review my changes before I push" |
|
||||
| [`create-pr`](skills/create-pr/SKILL.md) | Opens a pull request for the current branch — with checks on base branch, commits, issue, and push state — or updates an existing PR's title and description. | "open a PR for this branch" |
|
||||
| [`resolve-git-conflicts`](skills/resolve-git-conflicts/SKILL.md) | Untangles merge or rebase conflicts: explains both sides, proposes a resolution, applies it after you approve. Never runs `git rebase --continue`. | "resolve these conflicts" |
|
||||
|
||||
## References
|
||||
|
||||
| Skill | What it holds |
|
||||
|---|---|
|
||||
| [`plan-review-criteria`](skills/plan-review-criteria/SKILL.md) | The plan review rubric: six axes, severity levels, approval standard, output format. The `review-plan` reviewer loads it. |
|
||||
| [`code-review-criteria`](skills/code-review-criteria/SKILL.md) | The code review rubric: five axes, core principles (DRY, KISS, YAGNI), severity format, verdict. The `review-code` reviewer loads it. |
|
||||
|
||||
## Procedures
|
||||
|
||||
| Skill | What it does |
|
||||
|---|---|
|
||||
| [`planner`](skills/planner/SKILL.md) | The spec of a good plan: context, architecture decisions, tasks with acceptance criteria, checkpoints. Used by `make-a-plan`. |
|
||||
| [`create-issue`](skills/create-issue/SKILL.md) | Creates a GitHub issue that follows Penpot conventions. Used by `implement-plan`; also works on its own. |
|
||||
| [`create-commit`](skills/create-commit/SKILL.md) | Makes a commit the Penpot way: emoji subject, clear body, `AI-assisted-by` trailer. Used by `implement-plan`; also works alone when you say "commit this". |
|
||||
|
||||
## Utilities
|
||||
|
||||
| Skill | What it does |
|
||||
|---|---|
|
||||
| [`bat-cat`](skills/bat-cat/SKILL.md) | Read files in the terminal with syntax highlighting and line numbers. |
|
||||
| [`fd-find`](skills/fd-find/SKILL.md) | Find files by name or pattern, respecting `.gitignore`. |
|
||||
| [`ripgrep`](skills/ripgrep/SKILL.md) | Fast content search with regular expressions. |
|
||||
| [`jq-json-processor`](skills/jq-json-processor/SKILL.md) | Slice, filter, and reshape JSON output. |
|
||||
| [`nrepl-eval`](skills/nrepl-eval/SKILL.md) | Run Clojure or ClojureScript code in the live REPL sessions (backend and frontend). |
|
||||
| [`taiga`](skills/taiga/SKILL.md) | Look up Penpot issues, user stories, and tasks in Taiga. |
|
||||
| [`testing`](skills/testing/SKILL.md) | The repo's testing rules and TDD workflow, loaded before writing tests. |
|
||||
| [`security-and-hardening`](skills/security-and-hardening/SKILL.md) | Security checks for code that handles user input, auth, or external services. |
|
||||
| [`ste`](skills/ste/SKILL.md) | Rewrites prose in Simplified Technical English. Loads only when you name it. |
|
||||
| [`refine-prompt`](skills/refine-prompt/SKILL.md) | Rewrites a rough prompt into a clearer one. Never runs the prompt. |
|
||||
| [`update-changelog`](skills/update-changelog/SKILL.md) | Regenerates `CHANGES.md` from a GitHub milestone. |
|
||||
|
||||
## A typical round
|
||||
|
||||
1. `/make-a-plan` — you get a plan and a saved file in `.agents/plans/`.
|
||||
2. `/review-plan` — a second opinion; approve or request changes.
|
||||
3. `/implement-plan` — the code gets written and committed. Starting from a base branch, it also opens the GitHub issue and the `issue-NNNN` branch; the plans that follow continue on that same branch.
|
||||
4. `/review-code` — a reviewer checks the commit.
|
||||
5. `/create-pr` — the branch goes up as a pull request.
|
||||
|
||||
Every step also works on its own, and you can always say what you want
|
||||
in plain words — the agents pick the right skill from what you say.
|
||||
|
||||
## Adding or changing a skill
|
||||
|
||||
Create a folder here with a `SKILL.md` inside. The file needs `name` and
|
||||
`description` in its frontmatter, and a clear "When to use" section so
|
||||
agents know when to reach for it. Keep one job per skill, and keep the
|
||||
two families apart: flows are named with a verb first; reference skills
|
||||
end in `-criteria`.
|
||||
@@ -1,105 +0,0 @@
|
||||
---
|
||||
name: create-pr
|
||||
description: PR flow — open a new PR for the current task branch (validates base branch, commits, issue and push state) or update an existing PR's title or description to match Penpot conventions. Use it when the user asks to open or create a PR, in any phrasing.
|
||||
---
|
||||
|
||||
# Create PR
|
||||
|
||||
Two modes. **Open mode** takes the current task branch to a new, validated
|
||||
PR. **Update mode** rewrites an existing PR's title or description. Gather
|
||||
information, validate, and act in one pass. If validation fails, STOP with a
|
||||
single coherent message that lists every problem and states exactly what
|
||||
information is missing — never fix or work around problems silently.
|
||||
|
||||
Both modes require an authenticated `gh` CLI (`gh auth status`) and never
|
||||
push — the user pushes from their own shell.
|
||||
|
||||
## When to use
|
||||
|
||||
- The user asks to open or create a NEW PR for the current task branch, in
|
||||
any phrasing ("open a PR", "create the pull request", "put this up for
|
||||
review") — or runs `/create-pr`. → **Open mode**.
|
||||
- The user asks to fix or update an EXISTING PR's title or description to
|
||||
match conventions. → **Update mode**.
|
||||
|
||||
If the running agent cannot write (for example, the plan agent), say so and
|
||||
stop — this skill needs the build agent.
|
||||
|
||||
## Open mode
|
||||
|
||||
### 1. Gather context (read-only)
|
||||
|
||||
- Current branch: `git rev-parse --abbrev-ref HEAD`.
|
||||
- Target base branch: run `./scripts/detect-target-branch` from the repo root.
|
||||
It prints the nearest ancestor branch of HEAD (exit 0) or fails (exit 1).
|
||||
- Commits: `git log --oneline <base>..HEAD`.
|
||||
- Push state (local): `git rev-parse --verify origin/<branch>` and compare
|
||||
with HEAD. It reads the local remote-tracking ref — no network, no SSH. It
|
||||
reflects the last push or fetch this clone knows about.
|
||||
- Issue: from the session context, or from the branch name — `issue-NNNN`
|
||||
maps to issue NNNN; recover its title and body with `gh issue view NNNN`.
|
||||
|
||||
### 2. Validate — stop with one message if anything fails
|
||||
|
||||
Run all checks before reporting, then report every failure together:
|
||||
|
||||
1. **Base branch not usable.** If the script fails (exit 1), or its output —
|
||||
after stripping an optional `remotes/origin/` prefix — is not one of the
|
||||
canonical branches (`develop`, `staging`, `main`), stop and ask the user
|
||||
to re-run with more context — for example, passing the base branch
|
||||
explicitly in their invocation. An explicit base given by the user
|
||||
overrides the script's output.
|
||||
2. **On a base branch.** There is no task branch to merge — say so and stop.
|
||||
3. **No commits.** The branch has no commits ahead of the base — say so and
|
||||
stop.
|
||||
4. **No clear issue.** There is no issue in the session context, and the
|
||||
branch name has no `issue-NNNN` pattern (or `gh issue view` finds nothing)
|
||||
— say so and stop. Exception: the user's invocation says `no issue` /
|
||||
`without issue` — then continue without an issue reference.
|
||||
5. **Branch not pushed.** The remote-tracking ref `origin/<branch>` is
|
||||
missing, or `git rev-parse origin/<branch>` differs from HEAD — the
|
||||
branch was never pushed, or has commits the remote does not have. Never
|
||||
push yourself; ask the user to push and to run `/create-pr` again
|
||||
afterwards, then stop.
|
||||
|
||||
### 3. Already-open PR
|
||||
|
||||
Check whether a PR already exists for this branch (`gh pr list --head
|
||||
<branch>`). If one exists, report its URL and stop — do not create a second
|
||||
one. Title or description fixes belong to Update mode.
|
||||
|
||||
### 4. Write and create the PR
|
||||
|
||||
Write the title and body following `mem:workflow/creating-prs` (title format,
|
||||
description structure, writing principles) and `mem:workflow/creating-commits`
|
||||
(commit type emojis). Derive the title and body from the commits and, when
|
||||
there is one, from the issue body. Reference the issue with `Closes #NNNN`.
|
||||
|
||||
```bash
|
||||
gh pr create --repo penpot/penpot --title "<TITLE>" --body-file /tmp/pr-body.md
|
||||
```
|
||||
|
||||
### 5. Report
|
||||
|
||||
Report the PR URL and stop.
|
||||
|
||||
## Update mode
|
||||
|
||||
1. Identify the PR: the number given by the user, or `gh pr list --head
|
||||
<branch>`.
|
||||
2. Write the new title and/or body following `mem:workflow/creating-prs`.
|
||||
3. Apply and verify:
|
||||
|
||||
```bash
|
||||
gh pr edit <NUMBER> --repo penpot/penpot --title "<TITLE>" --body-file /tmp/pr-body.md
|
||||
gh pr view <NUMBER> --repo penpot/penpot --json title,body
|
||||
```
|
||||
|
||||
4. Report and stop.
|
||||
|
||||
## User context
|
||||
|
||||
Extra context in the user's invocation (the message that triggered this skill)
|
||||
plays the role command arguments play elsewhere: overrides such as `no issue` /
|
||||
`without issue`, an explicit base branch (`from origin/staging`), a PR number
|
||||
for Update mode, and so on.
|
||||
@@ -1,132 +0,0 @@
|
||||
---
|
||||
name: implement-plan
|
||||
description: Implementation flow — execute a ready plan from the session context: read the plan, detect the flow, then present the full picture (issue and branch to create or the branch to continue on, execution style, task checklist) and wait for confirmation. Default is every task with one final commit; on request ("step by step"), one task and one commit at a time with a pause after each. Use it when the user asks to implement or execute a plan, in any phrasing.
|
||||
---
|
||||
|
||||
# Implement Plan
|
||||
|
||||
This flow is run once a plan is ready (for example, from plan mode). Execute
|
||||
the plan already prepared in the current session context. It never pushes —
|
||||
the user pushes.
|
||||
|
||||
By default it ends with exactly one commit. When the user asks for it
|
||||
("step by step"), it commits once per task instead and waits for the
|
||||
user's confirmation after each one (see *Execution modes*).
|
||||
|
||||
## When to use
|
||||
|
||||
- The user asks to implement or execute a plan, in any phrasing:
|
||||
"implement the plan", "execute it", "go build it" — or runs
|
||||
`/implement-plan`.
|
||||
- A ready, reviewed plan is in the session context or a plan file path
|
||||
was given (typically after `/make-a-plan` or `/review-plan`).
|
||||
|
||||
Do not use it to produce plans — that is the `make-a-plan` flow.
|
||||
|
||||
## 1. Read the plan first
|
||||
|
||||
Identify the plan to execute — from the file path the user gave, the
|
||||
arguments, or the session context. Read it completely. Read the required
|
||||
memories before writing any code: `mem:critical-info` and the core memory
|
||||
of every module the plan touches, plus the deeper memories they reference
|
||||
(AGENTS.md governs this).
|
||||
|
||||
## 2. Detect the flow (no questions)
|
||||
|
||||
Inspect the current branch with `git rev-parse --abbrev-ref HEAD`, pick the
|
||||
mode, and announce it in one line before presenting anything. Detection is
|
||||
read-only: nothing is created until the user confirms (step 3).
|
||||
|
||||
- **On a base branch** (`main`, `develop`, `staging`) → **standalone mode**:
|
||||
a new GitHub issue and a branch `issue-NNNN` will be created after the
|
||||
user's confirmation.
|
||||
- **On any other branch** (a feature branch, typically `issue-NNNN`) →
|
||||
**continue mode**: the implementation continues on the current branch.
|
||||
No issue or branch is created. The branch name provides the issue
|
||||
reference when it follows the `issue-NNNN` pattern.
|
||||
|
||||
Arguments override detection: `standalone`, `continue`,
|
||||
`no issue` / `without issue`, or an explicit base such as
|
||||
`from origin/develop`.
|
||||
|
||||
**Standalone while already on a feature branch:** stop and explain that this
|
||||
would stack branches. Ask the user to re-run with an explicit base, for
|
||||
example `from origin/develop` — then branch from that base instead of HEAD.
|
||||
|
||||
## 3. Present the checklist and wait
|
||||
|
||||
Before touching the repository, show the user the full picture:
|
||||
|
||||
- **The flow**: whether the GitHub issue and the branch will be created
|
||||
(standalone mode — give the planned branch name, `issue-NNNN` or
|
||||
`plan-<slug>`), or whether you continue on the current branch
|
||||
(continue mode — name it).
|
||||
- **The execution style**: batch or step-by-step (see *Execution modes*).
|
||||
- A checklist (todolist) of the plan's tasks, in order.
|
||||
|
||||
Then WAIT for the user's explicit confirmation. Do not start until you
|
||||
have it. If the plan has no discrete tasks, ask the user how to split
|
||||
it, or propose running it as a single change.
|
||||
|
||||
## 4. Execute the plan
|
||||
|
||||
**Standalone setup, after the confirmation:** create the issue with the
|
||||
**`create-issue`** skill, following the *Creating Issues from Draft Body*
|
||||
flow in `mem:workflow/creating-issues`. Derive the issue title and body
|
||||
from the plan, capture the new issue's number — call it **NNNN** — and
|
||||
create the branch from the current HEAD:
|
||||
|
||||
```
|
||||
git checkout -b issue-NNNN
|
||||
```
|
||||
|
||||
If the arguments say `no issue` / `without issue`, skip the issue and
|
||||
create a branch named `plan-<slug>` instead, where `<slug>` is the plan
|
||||
title, lowercase and hyphen-separated.
|
||||
|
||||
### Batch mode (default)
|
||||
|
||||
Implement every task in one go. Work methodically, keeping changes
|
||||
focused on what the issue requires. Respect the plan's proposed
|
||||
parallelization when it applies.
|
||||
|
||||
When the implementation is complete, load the **`create-commit`** skill
|
||||
and follow its workflow to commit the changes. Provide a brief summary
|
||||
of what was implemented and why, the issue reference (`issue-NNNN`) when
|
||||
there is one, and the model name you are running as so the
|
||||
`AI-assisted-by` trailer is set correctly.
|
||||
|
||||
### Step-by-step mode (on request)
|
||||
|
||||
When the user asks for it — "step by step", "task by task", "one commit
|
||||
per task" — loop one task at a time:
|
||||
|
||||
- Execute exactly ONE task.
|
||||
- Commit it now: load the **`create-commit`** skill and follow it —
|
||||
one commit per task, never two tasks in one commit. Same inputs as
|
||||
always: what and why, the issue reference, your model name.
|
||||
- Show the user the result (what changed, files touched, how it was
|
||||
verified).
|
||||
- WAIT for the user's confirmation before starting the next task.
|
||||
|
||||
Never batch in this mode: no two tasks in one commit, and no new task
|
||||
before the user confirms. If a task turns out much bigger than planned,
|
||||
stop and ask the user before splitting it.
|
||||
|
||||
## When you are done
|
||||
|
||||
End by suggesting the next steps (suggestions, not a required pipeline — any
|
||||
instruction from me overrides them):
|
||||
|
||||
- `/review-code` — to review the changes just committed; it routes to
|
||||
`/make-a-plan` by itself if the findings need one.
|
||||
- `/create-pr` — when the task is done and the branch is ready to merge.
|
||||
|
||||
## User context
|
||||
|
||||
Extra context in the user's invocation (the message that triggered this
|
||||
skill) plays the role command arguments play elsewhere: `standalone`,
|
||||
`continue`, `no issue` / `without issue`, an explicit base such as
|
||||
`from origin/develop`, or `step by step` / `one commit per task` for the
|
||||
step-by-step execution mode. Modes combine freely, for example
|
||||
"standalone step by step".
|
||||
@@ -1,100 +0,0 @@
|
||||
---
|
||||
name: make-a-plan
|
||||
description: Planning flow — research the subject of this session, produce an implementation plan with the planner skill, resolve open questions with the user in plain language, and save the final plan to .agents/plans/. Use it when the user asks to plan, design, or break down a task, in any phrasing.
|
||||
---
|
||||
|
||||
# Make a Plan
|
||||
|
||||
Act as a senior software engineer: research the subject of this session in depth and
|
||||
produce a well-grounded, actionable implementation plan.
|
||||
|
||||
If the running agent cannot write (for example, the plan agent), say so and
|
||||
stop — this skill needs the build agent to save the plan.
|
||||
|
||||
## When to use
|
||||
|
||||
- The user asks to plan, design, or break down a task, in any phrasing:
|
||||
"make a plan", "how would we build X", "design an approach for Y" —
|
||||
or runs `/make-a-plan`.
|
||||
- The user asks to rework or extend an existing plan (for example, after
|
||||
review findings) — revise the saved plan file in place.
|
||||
|
||||
Do not use it to execute a plan — that is the `implement-plan` flow.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Produce the plan** with the `planner` skill. By default, research the
|
||||
subject of this session and draft the plan yourself. If I ask for it (for
|
||||
example, `delegated` in the user context), delegate to the `general` subagent
|
||||
instead — the delegate must also follow the `planner` skill and receive all
|
||||
the relevant session context (a review, user feedback, and so on).
|
||||
2. Before asking me to decide anything, explain the plan and every open question in
|
||||
plain language. Assume I know only the high-level project goal, not the codebase,
|
||||
architecture, implementation terms, or the problem this task solves.
|
||||
3. Once all decisions are answered and the plan is final, save it verbatim to the
|
||||
announced path under `.agents/plans/` (create the directory if it does not
|
||||
exist). This step is the flow's explicit authorization to write the plan
|
||||
file — the only write allowed here. If I later ask for changes, update the
|
||||
saved file directly.
|
||||
4. Present me with a clear, self-contained summary of the plan's most relevant points
|
||||
only after all required decisions have been answered. Write it for someone who knows
|
||||
only the project's high-level goal and may not know the plan's low-level context.
|
||||
Explain necessary technical language in plain terms, include the problem being
|
||||
solved and the proposed outcome, and do not assume that listing technical task names
|
||||
is enough.
|
||||
|
||||
### Hard rule — read-only while planning
|
||||
|
||||
While this flow runs, act read-only: research with read-only tools only.
|
||||
Never edit source files, never run builds, tests, linters, or any command that
|
||||
modifies state, and never commit. The single allowed write is the plan file in
|
||||
step 3. This rule expires when I approve the plan or move on to another task;
|
||||
then you act as a normal build agent again.
|
||||
|
||||
When the plan contains open questions, do not show them as bare technical questions or
|
||||
assume that I understand the technical language or technical words used in the plan.
|
||||
For each question, first explain:
|
||||
|
||||
- What part of the user problem the decision affects.
|
||||
- The relevant concept from the beginning, with a small concrete example.
|
||||
- What each available option would make the system do.
|
||||
- The practical benefits, costs, risks, and user-visible consequences of each option.
|
||||
- Which option the planner recommends and why.
|
||||
|
||||
Only after that explanation, use the `question` tool to ask the decision with clear,
|
||||
non-technical option labels. Put the recommended option first and mark it as
|
||||
`(Recommended)`. Group related questions when their context is shared, but do not ask a
|
||||
question whose meaning has not already been explained.
|
||||
|
||||
If I say that I do not understand a question or its choices, do not treat my previous
|
||||
answer as valid. Explain the concepts again from the high-level project goal, use a more
|
||||
concrete example, explain the implications, and ask the question again with the
|
||||
`question` tool. Repeat this until I can make an informed choice. If one answer creates
|
||||
new design consequences or additional decisions, explain those consequences before
|
||||
asking any new question.
|
||||
|
||||
Distinguish clearly between requirements already fixed by the roadmap or existing
|
||||
architecture and choices that actually require my input. Do not ask me to choose an
|
||||
implementation detail when the plan can resolve it safely without changing the public
|
||||
behavior. If there are no decisions that require my input, say so and present the
|
||||
summary.
|
||||
|
||||
IMPORTANT: **Under no circumstances execute the plan. Wait for the user to review it
|
||||
after all possible questions have been answered.** The final summary must explain the
|
||||
problem being solved, the proposed behavior, the main user-visible workflow, important
|
||||
constraints and risks, what is deliberately out of scope, and the path where the plan
|
||||
is saved. Never assume that a short list of task names is enough context. End
|
||||
the final response by suggesting the next steps, in this order:
|
||||
|
||||
1. `/review-plan` — to get a second opinion on the plan before executing it.
|
||||
2. `/implement-plan` — to execute the plan from the current session context.
|
||||
|
||||
These are suggestions, not a required pipeline — any instruction from me
|
||||
overrides them (for example, asking you to implement the plan directly).
|
||||
|
||||
## User context
|
||||
|
||||
Extra context in the user's invocation (the message that triggered this skill)
|
||||
plays the role command arguments play elsewhere: for example, `delegated` to
|
||||
hand the research and drafting to the `general` subagent, or corrections and
|
||||
feedback about a previous plan.
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
name: resolve-git-conflicts
|
||||
description: Conflict resolution flow — understand the local git conflicts, present a resolution plan, and resolve them after the user approves it. Never continues the rebase. Use it when the repo has unresolved conflicts (rebase, merge, cherry-pick) or the user asks to resolve them.
|
||||
---
|
||||
|
||||
# Resolve Git Conflicts
|
||||
|
||||
Resolve conflicts in the local repository. The user handles finishing the
|
||||
rebase themselves — you must **never** run `git rebase --continue`,
|
||||
`git rebase --skip`, `git merge --continue`, or anything similar.
|
||||
|
||||
## When to use
|
||||
|
||||
- The repository has unresolved conflicts — during a rebase, merge, or
|
||||
cherry-pick — whether the user asks about them or not.
|
||||
- The user asks to resolve conflicts, in any phrasing: "fix the merge
|
||||
conflicts", "resolve these", "what's conflicting here?".
|
||||
|
||||
## Phase 1 — Understand the problem (read-only)
|
||||
|
||||
1. Run `git status` to detect the conflict state (rebase, merge, cherry-pick, etc.) and list conflicted files.
|
||||
2. For each conflicted (unmerged) file, understand the situation **without modifying anything**:
|
||||
- Read the file and identify the conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`).
|
||||
- Inspect both sides — `git show <ours>:<file>` and `git show <theirs>:<file>` — plus `git log`/`git show` on the commits involved to understand intent.
|
||||
- Identify what each side changed and why, and how they should be combined.
|
||||
|
||||
## Phase 2 — Present the resolution plan
|
||||
|
||||
3. **Present a clear plan to the user before touching any file.** For each conflicted file, state:
|
||||
- What each side changed and why.
|
||||
- Your proposed resolution and the reasoning behind it.
|
||||
- How the two sides are combined (both additive → merge; both modify the same code → keep the semantically correct version, merging intent from both sides when clear from code and context).
|
||||
4. **Ask the user only when genuinely unclear.** Do not ask about anything you can determine yourself from the code, commit messages, or context. Only decisions that are not determinable and change the outcome (e.g. conflicting product decisions, which side to discard) warrant a question. **Collect all such questions together in an "Open Questions" section at the end of the plan**, so the user has full context to answer them properly.
|
||||
5. **Wait for the user to accept the plan** (and answer any open questions) before editing, staging, or otherwise modifying anything.
|
||||
|
||||
## Phase 3 — Execute
|
||||
|
||||
6. Resolve each conflicted file by editing the file to the agreed merged content and removing all conflict markers.
|
||||
|
||||
## Phase 4 — Stage and verify
|
||||
|
||||
7. **Stage every resolved file** with `git add <file>`. Do not stage unrelated untracked files unless clearly part of the resolution.
|
||||
8. Verify no conflict markers remain (search for `<<<<<<<` / `>>>>>>>` in resolved files) and that `git status` shows no unmerged paths.
|
||||
|
||||
## Phase 5 — Report
|
||||
|
||||
9. Briefly report the conflict state, how each conflicted file was resolved (and any answers received to open questions), and stop — do **not** run `git rebase --continue` or any other continuation command.
|
||||
@@ -1,73 +0,0 @@
|
||||
---
|
||||
name: review-code
|
||||
description: Code review flow — review a diff, PR, or code change, delegating the review to a subagent that follows the code-review-criteria skill. Use it when the user asks to review code or a PR, in any phrasing.
|
||||
---
|
||||
|
||||
# Review Code
|
||||
|
||||
Act as a senior software engineer and perform a thorough code review.
|
||||
|
||||
## When to use
|
||||
|
||||
- The user asks to review code, in any phrasing: "review this diff",
|
||||
"review the PR", "check my changes", "code review" — or runs
|
||||
`/review-code`.
|
||||
- A commit, branch, PR, or diff is ready and the user wants it assessed
|
||||
before merge.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Determine what is being reviewed** from the user context: a working-tree
|
||||
diff, a commit range, a branch, a PR (number or URL), or specific files. If
|
||||
the target is ambiguous, ask before reviewing.
|
||||
2. Delegate the review to the `general` subagent (via the task tool), unless the
|
||||
user specifies another agent. Include in the prompt the
|
||||
**`code-review-criteria`** skill name and all user context.
|
||||
3. When the subagent returns, output the review to the user verbatim. Do not
|
||||
summarize it and do not act on its findings.
|
||||
4. Right after the review, suggest how to proceed based on the findings. These
|
||||
are suggestions — the user decides:
|
||||
- **Approve (no required changes):** say so — there is nothing to address.
|
||||
- **Minor findings (nits):** applying them directly as-is is fine once the
|
||||
review is done — no plan needed.
|
||||
- **Substantive findings:** suggest `/make-a-plan` to make a plan to address
|
||||
them.
|
||||
|
||||
### Hard rule — read-only while reviewing
|
||||
|
||||
This flow is read-only **for the duration of the review**: from the moment it
|
||||
starts until the user considers the review finished (including any feedback,
|
||||
questions, or clarifications about it). During that period, never fix,
|
||||
implement, edit files or create commits — not even "obvious" fixes derived from
|
||||
the findings. Once the user explicitly states the review is done (or moves on to
|
||||
a different task), this rule no longer applies and you act as a normal build
|
||||
agent again.
|
||||
|
||||
## Instructions for the subagent
|
||||
|
||||
1. Load the **`code-review-criteria`** skill and follow its process and output
|
||||
format.
|
||||
2. Read `AGENTS.md` (if present) and follow its instructions for finding and
|
||||
reading all related testing documentation from memories before reviewing.
|
||||
3. Return in your final message the COMPLETE review, verbatim, exactly as the
|
||||
skill instructs it to be produced. Do not summarize it — include the full
|
||||
structured review.
|
||||
|
||||
### Strong rules for the subagent
|
||||
|
||||
1. Do not invent problems. Every finding must be real and actionable.
|
||||
2. Read-only: do not modify any file and do not create a commit — reviewing
|
||||
never writes.
|
||||
3. Be specific and constructive. "This could be better" is not helpful — explain
|
||||
why and how.
|
||||
4. Prioritize by impact. One structural issue outweighs ten nits.
|
||||
5. Missing tests are an issue, not a suggestion. Report as a severity-tagged
|
||||
finding — never as a recommendation.
|
||||
6. Skip generated files, lockfile-only changes, and unrelated modifications
|
||||
unless they introduce security risks.
|
||||
|
||||
## User context
|
||||
|
||||
Extra context in the user's invocation (the message that triggered this skill)
|
||||
plays the role command arguments play elsewhere: for example, a PR number or
|
||||
URL, a commit range, specific files, or a different agent to run the review.
|
||||
@@ -1,71 +0,0 @@
|
||||
---
|
||||
name: review-plan
|
||||
description: Plan review flow — evaluate an implementation plan before it is executed, delegating the review to a subagent that follows the plan-review-criteria skill. Use it when the user asks to review a plan, in any phrasing.
|
||||
---
|
||||
|
||||
# Review Plan
|
||||
|
||||
Act as a senior software engineer and perform a thorough review of an
|
||||
implementation plan.
|
||||
|
||||
## When to use
|
||||
|
||||
- The user asks to review a plan, in any phrasing: "review this plan",
|
||||
"does this plan look right?", "second opinion on the plan" — or runs
|
||||
`/review-plan`.
|
||||
- A plan was just produced (typically by `/make-a-plan`) and the user
|
||||
wants it evaluated before executing it.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Determine the plan under review** from the session context (for example, a
|
||||
plan just produced by `/make-a-plan`) or from a plan file path given by the
|
||||
user (typically under `.agents/plans/`). If a file path is given, read the
|
||||
file first so the complete plan is in context.
|
||||
2. Delegate the review to the `general` subagent (via the task tool), unless the
|
||||
user specifies another agent. Include in the prompt the
|
||||
**`plan-review-criteria`** skill name and all user context.
|
||||
3. When the subagent returns, output the review to the user verbatim. Do not
|
||||
summarize it and do not act on its findings.
|
||||
4. Right after the review, suggest the next step based on the verdict. These
|
||||
are suggestions — the user decides, and any instruction overrides them:
|
||||
- **Approve** → suggest `/implement-plan` to execute it.
|
||||
- **Request changes** → suggest `/make-a-plan` to make a plan to address the
|
||||
findings.
|
||||
|
||||
### Hard rule — read-only while reviewing
|
||||
|
||||
This flow is read-only **for the duration of the review**: from the moment it
|
||||
starts until the user considers the review finished (including any feedback,
|
||||
questions, or clarifications about it). During that period, never fix,
|
||||
implement, edit files or create commits — not even "obvious" fixes derived from
|
||||
the findings. Once the user explicitly states the review is done (or moves on to
|
||||
a different task), this rule no longer applies and you act as a normal build
|
||||
agent again.
|
||||
|
||||
## Instructions for the subagent
|
||||
|
||||
1. Load the **`plan-review-criteria`** skill and follow its process and output
|
||||
format.
|
||||
2. Read `AGENTS.md` (if present) and follow its instructions for finding and
|
||||
reading all related documentation and testing memories before reviewing.
|
||||
3. Return in your final message the COMPLETE review, verbatim, exactly as the
|
||||
skill instructs it to be produced. Do not summarize it — include the full
|
||||
structured review.
|
||||
|
||||
### Strong rules for the subagent
|
||||
|
||||
1. Do not invent problems. Every finding must be real and actionable.
|
||||
2. Read-only: do not modify any file and do not create a commit — reviewing
|
||||
never writes.
|
||||
3. Be specific and constructive. "This could be better" is not helpful — explain
|
||||
why and how.
|
||||
4. Prioritize by impact. One structural issue outweighs ten nits.
|
||||
5. Judge the plan as the implementer would: every task executable without
|
||||
guessing, ordering follows the dependency graph, risks named.
|
||||
|
||||
## User context
|
||||
|
||||
Extra context in the user's invocation (the message that triggered this skill)
|
||||
plays the role command arguments play elsewhere: for example, a plan file path
|
||||
to review, or a different agent to run the review.
|
||||
@@ -1 +0,0 @@
|
||||
../.agents/skills
|
||||
@@ -1,44 +0,0 @@
|
||||
name: _ADHOC
|
||||
|
||||
run-name: >-
|
||||
_ADHOC (${{ inputs.gh_ref }}${{ inputs.nitrate_ref != '' && format(' / nitrate:{0}', inputs.nitrate_ref) || '' }})
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
gh_ref:
|
||||
description: 'Branch/ref to build in penpot/penpot'
|
||||
type: string
|
||||
required: true
|
||||
nitrate_ref:
|
||||
description: 'Branch/ref to build admin-console in penpot/penpot-nitrate (defaults to gh_ref)'
|
||||
type: string
|
||||
required: false
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if already built/promoted'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
build-bundle:
|
||||
uses: ./.github/workflows/build-bundle.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: ${{ inputs.gh_ref }}
|
||||
force: ${{ inputs.force }}
|
||||
|
||||
build-docker:
|
||||
needs: build-bundle
|
||||
uses: ./.github/workflows/build-docker.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: ${{ inputs.gh_ref }}
|
||||
force: ${{ inputs.force }}
|
||||
|
||||
build-docker-admin-console:
|
||||
uses: ./.github/workflows/build-docker-admin-console.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: ${{ inputs.nitrate_ref || inputs.gh_ref }}
|
||||
force: ${{ inputs.force }}
|
||||
@@ -9,11 +9,6 @@ on:
|
||||
type: string
|
||||
required: true
|
||||
default: 'develop'
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if this version already exists in S3'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
workflow_call:
|
||||
inputs:
|
||||
gh_ref:
|
||||
@@ -21,11 +16,6 @@ on:
|
||||
type: string
|
||||
required: true
|
||||
default: 'develop'
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if this version already exists in S3'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
# Literal group name: under `workflow_call`, `github.workflow` resolves to the
|
||||
# caller's workflow, which put this workflow and the other reusable one called
|
||||
@@ -44,8 +34,6 @@ jobs:
|
||||
outputs:
|
||||
gh_ref: ${{ steps.vars.outputs.gh_ref }}
|
||||
bundle_version: ${{ steps.vars.outputs.bundle_version }}
|
||||
sha: ${{ steps.vars.outputs.sha }}
|
||||
commit_title: ${{ steps.vars.outputs.commit_title }}
|
||||
exists: ${{ steps.check.outputs.exists }}
|
||||
|
||||
steps:
|
||||
@@ -60,12 +48,10 @@ jobs:
|
||||
run: |
|
||||
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
|
||||
echo "bundle_version=$(git describe --tags --always)" >> $GITHUB_OUTPUT
|
||||
echo "sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "commit_title=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT
|
||||
|
||||
# The uploaded zip carries its version as S3 metadata. If the
|
||||
# existing object was already built from this same commit, the
|
||||
# whole build job is skipped. `force` bypasses this check entirely.
|
||||
# whole build job is skipped.
|
||||
- name: Check if this bundle is already built
|
||||
id: check
|
||||
env:
|
||||
@@ -73,16 +59,6 @@ jobs:
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
|
||||
run: |
|
||||
if [ "${{ inputs.force }}" = "true" ]; then
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
{
|
||||
echo "### 🔁 Bundle build forced"
|
||||
echo ""
|
||||
echo "\`force: true\` — skipping the S3 version check."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
EXISTING_VERSION=$(aws s3api head-object \
|
||||
--bucket ${{ secrets.S3_BUCKET }} \
|
||||
--key "penpot-${{ steps.vars.outputs.gh_ref }}.zip" \
|
||||
@@ -141,16 +117,6 @@ jobs:
|
||||
s3://${{ secrets.S3_BUCKET }}/penpot-${{ needs.check.outputs.gh_ref }}.zip \
|
||||
--metadata bundle-version=${{ needs.check.outputs.bundle_version }}
|
||||
|
||||
- name: Write step summary
|
||||
run: |
|
||||
{
|
||||
echo "### ✅ Bundle built"
|
||||
echo ""
|
||||
echo "- Version: \`${{ needs.check.outputs.bundle_version }}\` (\`git describe --tags --always\`)"
|
||||
echo "- Commit: [\`${{ needs.check.outputs.sha }}\`](https://github.com/${{ github.repository }}/commit/${{ needs.check.outputs.sha }}) — ${{ needs.check.outputs.commit_title }}"
|
||||
echo "- Built at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ── 3. Single failure notification for the whole workflow ─────────────
|
||||
notify:
|
||||
name: Notify failure
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
name: _DEVELOP
|
||||
|
||||
run-name: >-
|
||||
_DEVELOP (develop @ ${{ github.sha }})
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if already built/promoted'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
schedule:
|
||||
- cron: '16 5-20 * * 1-5'
|
||||
|
||||
@@ -24,7 +15,6 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "develop"
|
||||
force: ${{ inputs.force || false }}
|
||||
|
||||
build-docker:
|
||||
needs: build-bundle
|
||||
@@ -32,11 +22,9 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "develop"
|
||||
force: ${{ inputs.force || false }}
|
||||
|
||||
build-docker-admin-console:
|
||||
uses: ./.github/workflows/build-docker-admin-console.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "develop"
|
||||
force: ${{ inputs.force || false }}
|
||||
@@ -13,11 +13,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: 'develop'
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if already built'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
workflow_call:
|
||||
inputs:
|
||||
gh_ref:
|
||||
@@ -29,11 +24,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: 'develop'
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if already built'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
secrets:
|
||||
ORG_WORKFLOW_TOKEN:
|
||||
description: 'Token with Actions write access on penpot-nitrate'
|
||||
@@ -57,7 +47,6 @@ jobs:
|
||||
|
||||
gh workflow run "$WORKFLOW" --repo "$REPO" --ref "$DISPATCH_REF" \
|
||||
-f gh_ref="$GH_REF" \
|
||||
-f force="${{ inputs.force }}" \
|
||||
-f caller_run_id="$DISTINCT_ID" \
|
||||
-f caller_run_url="$CALLER_URL"
|
||||
|
||||
|
||||
@@ -8,11 +8,6 @@ on:
|
||||
type: string
|
||||
required: true
|
||||
default: 'develop'
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if this sha is already promoted'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
workflow_call:
|
||||
inputs:
|
||||
gh_ref:
|
||||
@@ -20,11 +15,6 @@ on:
|
||||
type: string
|
||||
required: true
|
||||
default: 'develop'
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if this sha is already promoted'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
# Literal group name: under `workflow_call`, `github.workflow` resolves to the
|
||||
# caller's workflow, which put this workflow and the other reusable one called
|
||||
@@ -52,7 +42,6 @@ jobs:
|
||||
gh_ref: ${{ steps.vars.outputs.gh_ref }}
|
||||
bundle_version: ${{ steps.vars.outputs.bundle_version }}
|
||||
sha: ${{ steps.vars.outputs.sha }}
|
||||
commit_title: ${{ steps.vars.outputs.commit_title }}
|
||||
exists: ${{ steps.check.outputs.exists }}
|
||||
|
||||
steps:
|
||||
@@ -71,7 +60,6 @@ jobs:
|
||||
GH_REF="${{ inputs.gh_ref || github.ref_name }}"
|
||||
echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT
|
||||
echo "sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "commit_title=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT
|
||||
|
||||
BUNDLE_VERSION=$(aws s3api head-object \
|
||||
--bucket ${{ secrets.S3_BUCKET }} \
|
||||
@@ -83,8 +71,7 @@ jobs:
|
||||
# The image set is a single block, so a single set-level check is
|
||||
# enough: `promote` drops a marker object in S3 only after every
|
||||
# image was built AND every branch tag was moved. Marker present
|
||||
# means there is nothing at all to do for this commit. `force`
|
||||
# bypasses this check entirely.
|
||||
# means there is nothing at all to do for this commit.
|
||||
- name: Check if this image set is already built
|
||||
id: check
|
||||
env:
|
||||
@@ -92,21 +79,6 @@ jobs:
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
|
||||
run: |
|
||||
if [ "${{ inputs.force }}" = "true" ]; then
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
mkdir -p "$BUNDLE_CACHE"
|
||||
find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true
|
||||
ZIP="$BUNDLE_CACHE/penpot-${{ steps.vars.outputs.bundle_version }}.zip"
|
||||
aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-${{ steps.vars.outputs.gh_ref }}.zip" "$ZIP.$$.tmp"
|
||||
mv "$ZIP.$$.tmp" "$ZIP"
|
||||
{
|
||||
echo "### 🔁 Image set build forced"
|
||||
echo ""
|
||||
echo "\`force: true\` — skipping the S3 marker check."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if aws s3api head-object \
|
||||
--bucket ${{ secrets.S3_BUCKET }} \
|
||||
--key "markers/images-sha-${{ steps.vars.outputs.sha }}" \
|
||||
@@ -166,7 +138,7 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
# To avoid the "429 Too Many Requests" error when downloading
|
||||
# To avoid the “429 Too Many Requests” error when downloading
|
||||
# images from DockerHub for unregistered users.
|
||||
# https://docs.docker.com/docker-hub/usage/
|
||||
- name: Login to DockerHub Registry
|
||||
@@ -286,16 +258,9 @@ jobs:
|
||||
run: |
|
||||
echo "${{ github.run_id }}" | aws s3 cp - \
|
||||
"s3://${{ secrets.S3_BUCKET }}/markers/images-sha-${{ needs.prepare.outputs.sha }}"
|
||||
|
||||
- name: Write step summary
|
||||
run: |
|
||||
{
|
||||
echo "### ✅ Image set promoted"
|
||||
echo ""
|
||||
echo "- Version: \`${{ needs.prepare.outputs.bundle_version }}\` (\`git describe --tags --always\`)"
|
||||
echo "- Commit: [\`${{ needs.prepare.outputs.sha }}\`](https://github.com/${{ github.repository }}/commit/${{ needs.prepare.outputs.sha }}) — ${{ needs.prepare.outputs.commit_title }}"
|
||||
echo "- Built at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo ""
|
||||
echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`sha-${{ needs.prepare.outputs.sha }}\`."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
name: _STAGING
|
||||
|
||||
run-name: >-
|
||||
_STAGING (staging)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if already built/promoted'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
schedule:
|
||||
- cron: '36 5-20 * * 1-5'
|
||||
|
||||
@@ -24,7 +15,6 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "staging"
|
||||
force: ${{ inputs.force || false }}
|
||||
|
||||
build-docker:
|
||||
needs: build-bundle
|
||||
@@ -32,11 +22,9 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "staging"
|
||||
force: ${{ inputs.force || false }}
|
||||
|
||||
build-docker-admin-console:
|
||||
uses: ./.github/workflows/build-docker-admin-console.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "staging"
|
||||
force: ${{ inputs.force || false }}
|
||||
@@ -1,16 +1,7 @@
|
||||
name: _TAG
|
||||
|
||||
run-name: >-
|
||||
_TAG (${{ github.ref_name }} @ ${{ github.sha }})
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if already built/promoted (manual re-releases only)'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
@@ -27,7 +18,6 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: ${{ github.ref_name }}
|
||||
force: ${{ inputs.force || false }}
|
||||
|
||||
build-docker:
|
||||
needs: build-bundle
|
||||
@@ -35,14 +25,12 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: ${{ github.ref_name }}
|
||||
force: ${{ inputs.force || false }}
|
||||
|
||||
build-docker-admin-console:
|
||||
uses: ./.github/workflows/build-docker-admin-console.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: ${{ github.ref_name }}
|
||||
force: ${{ inputs.force || false }}
|
||||
|
||||
notify:
|
||||
name: Notifications
|
||||
|
||||
+1
-1
@@ -103,7 +103,7 @@ opencode.json
|
||||
/.playwright-mcp
|
||||
/.devenv/mcp/
|
||||
/opencode.json
|
||||
/.agents/plans
|
||||
/.opencode/plans
|
||||
/.opencode/reports
|
||||
/.opencode/prompts
|
||||
/.ci-logs
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
description: Create a PR for the current task branch or update an existing one — loads and follows the create-pr skill
|
||||
agent: build
|
||||
---
|
||||
|
||||
Load the **`create-pr`** skill and follow it as your only instruction.
|
||||
|
||||
## User input, overrides and additional context
|
||||
|
||||
$ARGUMENTS
|
||||
@@ -1,10 +1,39 @@
|
||||
---
|
||||
description: Execute a ready plan — task checklist, your confirmation, then all tasks with one commit (default) or step by step with a commit and a pause per task; creates issue + branch when on a base branch — loads and follows the implement-plan skill
|
||||
description: Execute a ready plan end-to-end — create a GitHub issue, branch issue-NNNN, implement the plan, then commit via the create-commit skill
|
||||
agent: build
|
||||
---
|
||||
|
||||
Load the **`implement-plan`** skill and follow it as your only instruction.
|
||||
This command is run once a plan is ready (for example, from plan mode). Execute
|
||||
the plan already prepared in the current session context. Follow these steps in order.
|
||||
|
||||
## User input, overrides and additional context
|
||||
## 1. Create the issue
|
||||
|
||||
$ARGUMENTS
|
||||
Use the **`create-issue`** skill, following the *Creating Issues from Draft Body*
|
||||
flow in `mem:workflow/creating-issues`. Derive the issue title and body from the
|
||||
plan. Capture the new issue's number — call it **NNNN** (needed for the branch
|
||||
name and the commit reference).
|
||||
|
||||
## 2. Create the branch
|
||||
|
||||
Create and switch to a branch named after the issue:
|
||||
|
||||
```
|
||||
git checkout -b issue-NNNN
|
||||
```
|
||||
|
||||
(Replace NNNN with the issue number from step 1.)
|
||||
|
||||
## 3. Execute the plan
|
||||
|
||||
Implement the prepared plan from the session context. Work methodically, keeping
|
||||
changes focused on what the issue requires. Do not commit — the commit happens in
|
||||
step 4.
|
||||
|
||||
## 4. Commit with the create-commit skill
|
||||
|
||||
After the implementation is complete, load the **`create-commit`** skill and
|
||||
follow its workflow to commit the changes. Provide a brief summary of what was
|
||||
implemented and why, the issue reference (`issue-NNNN`), and the model name you
|
||||
are running as so the `AI-assisted-by` trailer is set correctly.
|
||||
|
||||
Do not push. Pushing is handled separately by the user.
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
description: Investigate the chosen task, produce an implementation plan, and save it — loads and follows the make-a-plan skill
|
||||
agent: build
|
||||
---
|
||||
|
||||
Load the **`make-a-plan`** skill and follow it as your only instruction.
|
||||
|
||||
## User input, overrides and additional context
|
||||
|
||||
$ARGUMENTS
|
||||
@@ -1,6 +1,40 @@
|
||||
---
|
||||
description: Resolve local git conflicts and stage the resolved files; never continues the rebase — loads and follows the resolve-git-conflicts skill
|
||||
description: Resolve local git conflicts and stage the resolved files with git add — never continues the rebase
|
||||
agent: build
|
||||
---
|
||||
|
||||
Load the **`resolve-git-conflicts`** skill and follow it as your only instruction.
|
||||
# Fix Git Conflicts
|
||||
|
||||
Resolve conflicts in the local repository. The user handles finishing the
|
||||
rebase themselves — you must **never** run `git rebase --continue`,
|
||||
`git rebase --skip`, `git merge --continue`, or anything similar.
|
||||
|
||||
## Phase 1 — Understand the problem (read-only)
|
||||
|
||||
1. Run `git status` to detect the conflict state (rebase, merge, cherry-pick, etc.) and list conflicted files.
|
||||
2. For each conflicted (unmerged) file, understand the situation **without modifying anything**:
|
||||
- Read the file and identify the conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`).
|
||||
- Inspect both sides — `git show <ours>:<file>` and `git show <theirs>:<file>` — plus `git log`/`git show` on the commits involved to understand intent.
|
||||
- Identify what each side changed and why, and how they should be combined.
|
||||
|
||||
## Phase 2 — Present the resolution plan
|
||||
|
||||
3. **Present a clear plan to the user before touching any file.** For each conflicted file, state:
|
||||
- What each side changed and why.
|
||||
- Your proposed resolution and the reasoning behind it.
|
||||
- How the two sides are combined (both additive → merge; both modify the same code → keep the semantically correct version, merging intent from both sides when clear from code and context).
|
||||
4. **Ask the user only when genuinely unclear.** Do not ask about anything you can determine yourself from the code, commit messages, or context. Only decisions that are not determinable and change the outcome (e.g. conflicting product decisions, which side to discard) warrant a question. **Collect all such questions together in an "Open Questions" section at the end of the plan**, so the user has full context to answer them properly.
|
||||
5. **Wait for the user to accept the plan** (and answer any open questions) before editing, staging, or otherwise modifying anything.
|
||||
|
||||
## Phase 3 — Execute
|
||||
|
||||
6. Resolve each conflicted file by editing the file to the agreed merged content and removing all conflict markers.
|
||||
|
||||
## Phase 4 — Stage and verify
|
||||
|
||||
7. **Stage every resolved file** with `git add <file>`. Do not stage unrelated untracked files unless clearly part of the resolution.
|
||||
8. Verify no conflict markers remain (search for `<<<<<<<` / `>>>>>>>` in resolved files) and that `git status` shows no unmerged paths.
|
||||
|
||||
## Phase 5 — Report
|
||||
|
||||
9. Briefly report the conflict state, how each conflicted file was resolved (and any answers received to open questions), and stop — do **not** run `git rebase --continue` or any other continuation command.
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
description: Code review — review a diff, PR, or code change — loads and follows the review-code skill
|
||||
agent: build
|
||||
---
|
||||
|
||||
Load the **`review-code`** skill and follow it as your only instruction.
|
||||
|
||||
## User input, overrides and additional context
|
||||
|
||||
$ARGUMENTS
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
description: Plan review — evaluate an implementation plan before executing it — loads and follows the review-plan skill
|
||||
agent: build
|
||||
---
|
||||
|
||||
Load the **`review-plan`** skill and follow it as your only instruction.
|
||||
|
||||
## User input, overrides and additional context
|
||||
|
||||
$ARGUMENTS
|
||||
@@ -9,11 +9,6 @@ metadata: {"clawdbot":{"emoji":"🦇","requires":{"bins":["bat"]},"install":[{"i
|
||||
|
||||
`cat` with syntax highlighting, line numbers, and Git integration.
|
||||
|
||||
## When to use
|
||||
|
||||
- Reading or displaying a file in the terminal — prefer it over plain
|
||||
`cat`: syntax highlighting, line numbers, git-side indicators.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic usage
|
||||
+8
-7
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: code-review-criteria
|
||||
description: Code review criteria — the five review axes, core principles, severity format, and verdict for reviewing code changes. Loaded by the reviewer subagent of the review-code flow. Not a user-facing flow — to review code, use the review-code flow.
|
||||
name: code-review
|
||||
description: Conducts multi-axis code review. Use before merging any change. Use when reviewing code written by yourself, another agent, or a human. Use when you need to assess code quality across multiple dimensions before it enters the main branch.
|
||||
---
|
||||
|
||||
# Code Review Criteria and Quality
|
||||
# Code Review and Quality
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -13,10 +13,11 @@ Multi-dimensional code review with quality gates. Every change gets reviewed bef
|
||||
|
||||
## When to Use
|
||||
|
||||
- The reviewer subagent of the `review-code` flow loads this skill to perform
|
||||
the review of a code change.
|
||||
- To review code, always go through the `review-code` flow — never load this
|
||||
skill directly for that. This is the criteria reference, not the flow.
|
||||
- Before merging any PR or change
|
||||
- After completing a feature implementation
|
||||
- When another agent or model produced code you need to evaluate
|
||||
- When refactoring existing code
|
||||
- After any bug fix (review both the fix and the regression test)
|
||||
|
||||
## Core Principles
|
||||
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: create-pr
|
||||
description: Create or update a GitHub PR following Penpot conventions.
|
||||
---
|
||||
|
||||
# Skill: create-pr
|
||||
|
||||
Create or update a GitHub PR. Read and follow:
|
||||
- `mem:workflow/creating-prs` — title format, description structure, writing principles
|
||||
- `mem:workflow/creating-commits` — commit type emojis
|
||||
|
||||
## When to Use
|
||||
|
||||
- Creating a new PR from a feature branch
|
||||
- Updating an existing PR's title or description to match conventions
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `gh` CLI authenticated (`gh auth status`)
|
||||
|
||||
## Commands
|
||||
|
||||
**Create:**
|
||||
|
||||
```bash
|
||||
gh pr create --repo penpot/penpot --title "<TITLE>" --body-file /tmp/pr-body.md
|
||||
```
|
||||
|
||||
**Update:**
|
||||
|
||||
```bash
|
||||
gh pr edit <NUMBER> --repo penpot/penpot --title "<TITLE>" --body-file /tmp/pr-body.md
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
|
||||
```bash
|
||||
gh pr view <NUMBER> --repo penpot/penpot --json title,body
|
||||
```
|
||||
@@ -9,11 +9,6 @@ metadata: {"clawdbot":{"emoji":"📂","requires":{"bins":["fd"]},"install":[{"id
|
||||
|
||||
User-friendly alternative to `find` with smart defaults.
|
||||
|
||||
## When to use
|
||||
|
||||
- Locating files or directories by name or pattern — prefer it over
|
||||
plain `find`: simpler syntax, smart defaults, respects `.gitignore`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic search
|
||||
-5
@@ -9,11 +9,6 @@ metadata: {"clawdbot":{"emoji":"🔍","requires":{"bins":["jq"]},"install":[{"id
|
||||
|
||||
Process, filter, and transform JSON data with jq.
|
||||
|
||||
## When to use
|
||||
|
||||
- Parsing, filtering, or transforming JSON from commands, files, or API
|
||||
responses — slicing, reshaping, or validating JSON output.
|
||||
|
||||
## Quick Examples
|
||||
|
||||
### Basic filtering
|
||||
@@ -10,12 +10,6 @@ Evaluate Clojure (or ClojureScript) code via a running nREPL server using
|
||||
|
||||
Full documentation: `mem:scripts/nrepl-eval` (file: `.serena/memories/scripts/nrepl-eval.md`)
|
||||
|
||||
## When to use
|
||||
|
||||
- Evaluating Clojure or ClojureScript code against the running nREPL
|
||||
sessions (backend 6064, frontend 3447) — live inspection, patching, or
|
||||
debugging.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
+10
-10
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: plan-review-criteria
|
||||
description: Plan review criteria — the six review axes, severity rubric, approval standard, and output format for reviewing implementation plans. Loaded by the reviewer subagent of the review-plan flow. Not a user-facing flow — to review a plan, use the review-plan flow.
|
||||
name: plan-review
|
||||
description: Reviews implementation plans for quality, completeness, and actionability. Use after a plan is produced by the planner skill, before starting implementation. Use when evaluating a plan written by yourself, another agent, or a human.
|
||||
---
|
||||
|
||||
# Plan Review Criteria
|
||||
# Plan Review
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -13,10 +13,10 @@ Multi-dimensional plan review with quality gates. Every plan gets reviewed befor
|
||||
|
||||
## When to Use
|
||||
|
||||
- The reviewer subagent of the `review-plan` flow loads this skill to perform
|
||||
the review of a plan.
|
||||
- To review a plan, always go through the `review-plan` flow — never load this
|
||||
skill directly for that. This is the criteria reference, not the flow.
|
||||
- After the planner skill produces a plan
|
||||
- Before starting implementation on any non-trivial task
|
||||
- When reviewing a plan written by another agent or a human
|
||||
- When a plan feels too large, vague, or risky to start
|
||||
|
||||
**Do NOT use for:** Single-file changes with obvious scope, or when the task is trivial enough to just do.
|
||||
|
||||
@@ -87,7 +87,7 @@ Can an implementer actually execute this?
|
||||
|
||||
### 6. Proposed Code Quality *(when the plan includes implementation details)*
|
||||
|
||||
If the plan proposes code shapes, function signatures, data structures, or API designs, evaluate those proposals against `code-review-criteria`:
|
||||
If the plan proposes code shapes, function signatures, data structures, or API designs, evaluate those proposals against `code-review` criteria:
|
||||
|
||||
- **Correctness:** Do the proposed types/signatures handle edge cases (null, empty, boundaries)?
|
||||
- **Readability:** Are proposed names descriptive and consistent with project conventions?
|
||||
@@ -215,7 +215,7 @@ Check that the plan can actually confirm it worked:
|
||||
If the plan includes code snippets, types, or API designs:
|
||||
|
||||
```
|
||||
- Load code-review-criteria skill for criteria
|
||||
- Load code-review skill for criteria
|
||||
- Check proposed signatures for edge cases
|
||||
- Verify naming follows project conventions
|
||||
- Confirm abstractions follow existing patterns
|
||||
@@ -310,6 +310,6 @@ If the plan includes code snippets, types, or API designs:
|
||||
## See Also
|
||||
|
||||
- For producing plans, use the `planner` skill
|
||||
- For reviewing implemented code, use `code-review-criteria` — also the criteria source for axis 6
|
||||
- For reviewing implemented code, use `code-review` — also the criteria source for axis 6
|
||||
- For security-specific concerns, see `security-and-hardening`
|
||||
- For testing strategy guidance, see `testing`
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: planner
|
||||
description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user with the plan's save path (saved or suggested) and the next steps.
|
||||
description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user and save to .opencode/plans/YYYY-MM-DD-<title>.md.
|
||||
---
|
||||
|
||||
# Planner
|
||||
@@ -215,9 +215,9 @@ Add explicit checkpoints with the relevant module commands:
|
||||
|
||||
## Constraints
|
||||
|
||||
- You are **analysis-only** — never create, edit, or delete source code. The
|
||||
only file you may write is the plan itself, and only when the command or
|
||||
user explicitly instructs you to save it.
|
||||
- You are **analysis-only** — never create, edit, or delete source code.
|
||||
- The only file write you may attempt is the plan itself, saved to
|
||||
`.opencode/plans/`.
|
||||
- You do **not** run builds, tests, linters, or any commands that modify state.
|
||||
- You do **not** create git commits or interact with version control.
|
||||
- You do **not** execute shell commands beyond read-only searches (`rg`, `ls`,
|
||||
@@ -228,23 +228,22 @@ Add explicit checkpoints with the relevant module commands:
|
||||
## Output Format
|
||||
|
||||
The plan is always delivered in the response so the user sees it regardless
|
||||
of which agent is running the skill. By default you never write the plan file;
|
||||
announce the path instead. Write the file only when the command or user
|
||||
explicitly instructs you to save it — and then only that file.
|
||||
of which agent is running the skill.
|
||||
|
||||
Announce the suggested save path:
|
||||
Additionally, save the plan to:
|
||||
|
||||
```
|
||||
.agents/plans/YYYY-MM-DD-<plan-one-line-title>.md
|
||||
.opencode/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`). If the user explicitly
|
||||
provides a target file path, announce that path instead of the default.
|
||||
(e.g. `add-batch-get-profiles-for-file-comments`). Create the
|
||||
`.opencode/plans/` directory if it does not exist.
|
||||
|
||||
End the response by suggesting the next steps: `/review-plan` to get a second
|
||||
opinion on the plan and `/implement-plan` to execute it.
|
||||
IMPORTANT: The plan agent has write permission specifically for
|
||||
`.opencode/plans/` — always attempt the write. If the user explicitly provides
|
||||
a target file path, use that path instead of the default.
|
||||
|
||||
### Plan Document Template
|
||||
|
||||
@@ -375,6 +374,4 @@ Before delivering the plan, confirm:
|
||||
- [ ] Task dependencies are identified and ordered correctly
|
||||
- [ ] No task is XL or larger — break it down instead
|
||||
- [ ] Checkpoints exist after every 2-3 tasks
|
||||
- [ ] The response states the plan's path (saved or suggested) and suggests
|
||||
`/review-plan` and `/implement-plan`
|
||||
- [ ] The plan is ready for human review
|
||||
File renamed without changes.
@@ -9,11 +9,6 @@ metadata: {"clawdbot":{"emoji":"🔎","requires":{"bins":["rg"]},"install":[{"id
|
||||
|
||||
Fast, smart recursive search. Respects `.gitignore` by default.
|
||||
|
||||
## When to use
|
||||
|
||||
- Searching file contents across the repo for regex patterns — the
|
||||
default code search, respects `.gitignore`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic search
|
||||
File renamed without changes.
@@ -9,13 +9,6 @@ Apply the ASD-STE100 standard to all prose you produce in this task. Do not anno
|
||||
|
||||
Compliance note (for you, not for output): the official specification and its dictionary are copyright ASD. This skill encodes paraphrased rules and a publicly sourced word list. For certified aerospace/defense deliverables, tell the user that full compliance requires the free official specification (asd-ste100.org) and a human sign-off. Never claim certified compliance.
|
||||
|
||||
## When to use
|
||||
|
||||
Only when the user explicitly invokes it: they type `/ste`, or say "use
|
||||
the ste skill" / "apply ASD-STE100". Requests like "simplify this",
|
||||
"make it clearer", or "shorter sentences" do NOT invoke it — respond
|
||||
normally unless it is named.
|
||||
|
||||
## Step 0 — Classify the text
|
||||
|
||||
Before writing a single sentence, decide: is this **procedural** text (instructions someone follows) or **descriptive** text (explanation, background, description)? Every limit below depends on this. Mixed documents get classified section by section.
|
||||
File renamed without changes.
File renamed without changes.
@@ -11,12 +11,6 @@ Fetch information from Taiga public API for the **Penpot** project
|
||||
|
||||
**No authentication required** — only public project data is accessed.
|
||||
|
||||
## When to use
|
||||
|
||||
- The user asks about Penpot issues, user stories, or tasks tracked in
|
||||
Taiga — fetch them via the public API (project id 345963), no
|
||||
authentication needed.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `python3` — the `scripts/taiga.py` CLI script is self-contained (stdlib only)
|
||||
File renamed without changes.
File renamed without changes.
@@ -15,7 +15,6 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
|
||||
- Before `gh issue create` → `mem:workflow/creating-issues` (title derivation, body template, labels, Issue Type)
|
||||
- Before `gh pr create` / `gh pr edit` → `mem:workflow/creating-prs` (title format, body structure, "Note:" line)
|
||||
- **Never `git push`, force-push, or modify `git origin`** (or any other remote). The user pushes from their own shell; if a push is required, say so and wait. Never amend a commit that the user has already pushed unless explicitly asked.
|
||||
- **Never edit `CHANGES.md` by hand.** The changelog is generated from GitHub milestones during the release process; update it only via the `update-changelog` skill flow or on explicit user request.
|
||||
- You have access to the GitHub CLI `gh` or corresponding MCP tools.
|
||||
- Issues are also managed on Taiga. Read issues using the `read_taiga_issue` tool.
|
||||
- Before writing code, analyze the task in depth and describe your plan. If the task is complex, break it down into atomic steps.
|
||||
|
||||
@@ -351,5 +351,5 @@ gh issue view <NUMBER> --repo penpot/penpot --json title
|
||||
## See Also
|
||||
|
||||
- End-to-end orchestration entry point: the `create-issue` skill at
|
||||
`.agents/skills/create-issue/SKILL.md`. The skill is a thin entry
|
||||
`.opencode/skills/create-issue/SKILL.md`. The skill is a thin entry
|
||||
point; this memory is the canonical home for all issue-creation rules.
|
||||
@@ -8,9 +8,6 @@
|
||||
wait for the user to push. Do not change the remote URL, do not switch SSH↔HTTPS.
|
||||
- **Never amend a commit that has been pushed** unless the user explicitly asks.
|
||||
If the user pushes, treat that commit as final from the agent's side.
|
||||
- **Never edit `CHANGES.md` by hand** in commits or PRs. The changelog is
|
||||
generated from GitHub milestones during the release process; update it only
|
||||
via the `update-changelog` skill flow or on explicit user request.
|
||||
- **Never pipe test output directly to filters** (`| head`, `| tail`, `| grep`, etc.).
|
||||
Always redirect to a file first: `command > /tmp/output.txt 2>&1`, then read/grep the file.
|
||||
This prevents hiding test failures. See `mem:testing` for details.
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
Read and follow the instructions in `AGENTS.md`.
|
||||
|
||||
Treat `AGENTS.md` as the canonical project instruction file.
|
||||
@@ -1037,7 +1037,7 @@
|
||||
provider (prepare-organization-sso-provider cfg sso)
|
||||
_info (get-info cfg provider state code)
|
||||
session (session/get-session request)
|
||||
exp (ct/in-future {:hours 4})]
|
||||
exp (ct/in-future {:minutes 15})]
|
||||
(when (and session organization-id)
|
||||
(let [props (-> (or (:props session) {})
|
||||
(update :sso assoc organization-id exp))]
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
[app.common.uuid :as uuid]
|
||||
;; Required for side effects: binds the generated enums.
|
||||
[app.wasm.enums]
|
||||
[cuerdas.core :as str]
|
||||
[promesa.core :as p]
|
||||
[shadow.esm :refer [dynamic-import]]))
|
||||
|
||||
@@ -191,22 +190,6 @@
|
||||
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
|
||||
false)))))
|
||||
|
||||
(defn store-image-url!
|
||||
"Registers the public URL an image was loaded from. The SVG export emits
|
||||
linked `<image href>` from these, and falls back to Skia base64 when missing,
|
||||
so this should run for every media id the scene references (including
|
||||
already-cached images).
|
||||
|
||||
Does NOT call `mem/free`, for the same reason as `store-font-url!`."
|
||||
[image-id url]
|
||||
(when (and (some? url) (not (str/blank? url)))
|
||||
(let [bytes (js/Buffer.from url "utf-8")
|
||||
ptr (mem/alloc (.-byteLength bytes))
|
||||
quart (uuid/get-u32 image-id)]
|
||||
(mem/write-buffer ptr (mem/get-heap-u8) bytes)
|
||||
(h/call wasm/internal-module "_store_image_url"
|
||||
(aget quart 0) (aget quart 1) (aget quart 2) (aget quart 3)))))
|
||||
|
||||
(defn store-image!
|
||||
"Uploads one image's *encoded* bytes (PNG/JPEG — Skia decodes, no WebGL) into
|
||||
the WASM image store via `_store_image`. Buffer layout matches the Rust reader:
|
||||
|
||||
@@ -369,18 +369,13 @@
|
||||
"Fetches and stores every image the scene references (shape, stroke and
|
||||
text-span fills, enumerated by `app.common.types.shape.images`). Unlike fonts,
|
||||
the image store is not reset per request, so already-held images are skipped
|
||||
and repeated exports of a file reuse them.
|
||||
|
||||
Always registers a public media URL for each id so SVG export can emit linked
|
||||
`<image href>` even when the encoded bytes were already cached."
|
||||
and repeated exports of a file reuse them."
|
||||
[scene params]
|
||||
(let [all-ids (images/scene-image-ids scene)
|
||||
new-ids (remove wasm/image-cached? all-ids)]
|
||||
(l/dbg :hint "wasm render: provisioning images"
|
||||
:total (count all-ids)
|
||||
:cached (- (count all-ids) (count new-ids)))
|
||||
(doseq [image-id all-ids]
|
||||
(wasm/store-image-url! image-id (public-uri (str "assets/by-file-media-id/" image-id))))
|
||||
(->> new-ids
|
||||
(map (fn [image-id]
|
||||
(->> (fetch-file-media-bytes image-id params)
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"import": "./dist/modal.js",
|
||||
"types": "./dist/modal.d.ts"
|
||||
},
|
||||
"./menu": {
|
||||
"import": "./dist/menu.js",
|
||||
"types": "./dist/menu.d.ts"
|
||||
},
|
||||
"./style.css": "./dist/style.css"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
export { Modal, useModalClose } from './lib/modal/Modal';
|
||||
export { Modal, useModalClose } from "./lib/modal/Modal";
|
||||
export {
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
SubMenu,
|
||||
ContextMenu,
|
||||
} from "./lib/menu/Menu";
|
||||
@@ -0,0 +1,172 @@
|
||||
@use "ds/_borders" as *;
|
||||
@use "ds/_sizes" as *;
|
||||
@use "ds/_utils" as *;
|
||||
@use "ds/spacing" as *;
|
||||
@use "ds/typography" as *;
|
||||
@use "ds/mixins" as *;
|
||||
|
||||
.popover {
|
||||
z-index: var(--z-index-dropdown);
|
||||
|
||||
&[data-entering] {
|
||||
animation: popover-fade-in 0.15s ease-out;
|
||||
}
|
||||
|
||||
&[data-exiting] {
|
||||
animation: popover-fade-out 0.1s ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
@include custom-scrollbar;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xxs);
|
||||
min-inline-size: $sz-160;
|
||||
|
||||
// react-aria sets the Popover's own max-height (inline, on .popover, our
|
||||
// direct parent) to whatever actually fits between the trigger and the
|
||||
// viewport edge. A fixed px value here ignores that entirely: .popover
|
||||
// has no overflow of its own, so once its computed space is smaller
|
||||
// than our fixed cap we'd render straight past it (and off the edge of
|
||||
// the viewport, with nothing left to scroll it back into view). inherit
|
||||
// picks up .popover's own computed max-height instead, so we always
|
||||
// fit — at the cost of no longer capping how tall the menu gets when
|
||||
// there's plenty of room, since react-aria's own value is the only
|
||||
// thing to inherit here.
|
||||
max-block-size: inherit;
|
||||
padding: var(--sp-xxs);
|
||||
margin: 0;
|
||||
border-radius: $br-8;
|
||||
border: $b-1 solid var(--color-background-quaternary);
|
||||
background-color: var(--color-background-tertiary);
|
||||
overflow-y: auto;
|
||||
outline: none;
|
||||
|
||||
// Matches the other DS dropdowns (options-dropdown.scss et al.), and the
|
||||
// legacy context-menu-a11y menu this replaces.
|
||||
box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark);
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
@include use-typography("body-small");
|
||||
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: var(--sp-s);
|
||||
block-size: $sz-32;
|
||||
padding-inline: var(--sp-s);
|
||||
border-radius: $br-6;
|
||||
color: var(--color-foreground-primary);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
|
||||
&[data-hovered],
|
||||
&[data-focused] {
|
||||
background-color: var(--color-background-quaternary);
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
color: var(--color-foreground-disabled);
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.subMenuItem {
|
||||
justify-content: space-between;
|
||||
|
||||
&[data-open] {
|
||||
background-color: var(--color-background-quaternary);
|
||||
}
|
||||
}
|
||||
|
||||
.subMenuLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-s);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subMenuChevron {
|
||||
flex: 0 0 auto;
|
||||
inline-size: $sz-16;
|
||||
block-size: $sz-16;
|
||||
color: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
// The item a drilldown SubMenu (see Menu.tsx) shows at the top of its
|
||||
// content, above a separator, to return to the level it was entered from.
|
||||
.backItem {
|
||||
color: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
.backLabel {
|
||||
flex: 1 1 auto;
|
||||
min-inline-size: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.separator {
|
||||
flex: 0 0 auto;
|
||||
block-size: $b-1;
|
||||
margin-block: var(--sp-xxs);
|
||||
margin-inline: 0;
|
||||
border: none;
|
||||
background-color: var(--color-background-quaternary);
|
||||
}
|
||||
|
||||
// Unlike .contextMenuTrigger below, this needs its own real bounding box —
|
||||
// the Popover positions itself against this element's DOM rect — so it
|
||||
// can't be display: contents. A flex/grid item is blockified and, by
|
||||
// default, stretched to fill its container's cross axis regardless of this
|
||||
// display value, which would inflate that box past the actual trigger and
|
||||
// throw off the Popover's position; place-self: start start opts this out
|
||||
// so the box always shrink-wraps its content instead.
|
||||
.menuTrigger {
|
||||
display: inline-block;
|
||||
place-self: start start;
|
||||
}
|
||||
|
||||
.contextMenuTrigger {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
// Invisible 0x0 anchor moved to the pointer position on right-click; the
|
||||
// Popover positions itself relative to this instead of the trigger's own
|
||||
// (possibly large) bounding box.
|
||||
.contextMenuAnchor {
|
||||
position: fixed;
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
inline-size: 0;
|
||||
block-size: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes popover-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes popover-fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
import {
|
||||
Menu as RACMenu,
|
||||
MenuItem as RACMenuItem,
|
||||
Popover,
|
||||
Separator,
|
||||
SubmenuTrigger,
|
||||
} from "react-aria-components";
|
||||
import type { Key } from "@react-types/shared";
|
||||
import {
|
||||
createContext,
|
||||
Fragment,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import styles from "./Menu.module.scss";
|
||||
|
||||
type Placement =
|
||||
| "top"
|
||||
| "top start"
|
||||
| "top end"
|
||||
| "bottom"
|
||||
| "bottom start"
|
||||
| "bottom end"
|
||||
| "left"
|
||||
| "left top"
|
||||
| "left bottom"
|
||||
| "right"
|
||||
| "right top"
|
||||
| "right bottom";
|
||||
|
||||
// SubMenu needs a way to close the whole tree (not just its own level) when
|
||||
// one of its items is selected. MenuTrigger normally provides this via a
|
||||
// shared RootMenuTriggerStateContext, but Menu/ContextMenu don't use
|
||||
// MenuTrigger (see below), so that context is never established — this
|
||||
// fills the same role explicitly.
|
||||
//
|
||||
// closing both the root and the submenu popovers at once (rather than just
|
||||
// the submenu, which is the only thing react-aria itself does on select)
|
||||
// has to skip their closing CSS animation: react-aria detects animation end
|
||||
// via each popover's own `getAnimations()`, and closing both simultaneously
|
||||
// leaves their animations permanently stuck at "running" — neither ever
|
||||
// settles, so neither popover ever actually unmounts. shouldSkipAnimation
|
||||
// sidesteps that by closing instantly instead, shared here so the root's
|
||||
// own Popover and every nested SubMenu's Popover skip it together.
|
||||
interface MenuCloseController {
|
||||
closeAll: () => void;
|
||||
shouldSkipAnimation: boolean;
|
||||
}
|
||||
const MenuCloseContext = createContext<MenuCloseController | null>(null);
|
||||
|
||||
// Lets a "drilldown" SubMenu (see below) replace the menu's own content with
|
||||
// its items instead of opening a nested flyout popover, for trees too deep
|
||||
// or too wide for a chain of flyouts (e.g. move-to-project, which nests
|
||||
// team -> project). Menu/ContextMenu each own one navigation stack and
|
||||
// provide this to their entire content tree, so a drilldown SubMenu nested
|
||||
// inside another drilldown SubMenu still drills into the same stack.
|
||||
interface MenuNavigationController {
|
||||
drillIn: (label: ReactNode, content: ReactNode) => void;
|
||||
}
|
||||
const MenuNavigationContext =
|
||||
createContext<MenuNavigationController | null>(null);
|
||||
|
||||
interface NavigationLevel {
|
||||
// Distinct per push, so switching levels always fully unmounts the
|
||||
// previous level's items and mounts the new ones, rather than updating
|
||||
// them in place — react-stately's Collection requires each item's id to
|
||||
// stay stable across an update, but the back item's label and every item
|
||||
// underneath it genuinely change identity between levels, so this forces
|
||||
// a remount instead (React.Fragment key) rather than an update.
|
||||
key: string;
|
||||
label: ReactNode;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
// Renders the back item + separator for whatever level of the navigation
|
||||
// stack is current, and provides drillIn to the rest of `children`. Shared
|
||||
// between Menu and ContextMenu, which each keep their own stack (a
|
||||
// drilldown inside one popover has no bearing on the other).
|
||||
function useMenuNavigation(children: ReactNode, isOpen: boolean | undefined) {
|
||||
const [stack, setStack] = useState<NavigationLevel[]>([]);
|
||||
const nextLevelKey = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) setStack([]);
|
||||
}, [isOpen]);
|
||||
|
||||
const drillIn = useCallback((label: ReactNode, content: ReactNode) => {
|
||||
nextLevelKey.current += 1;
|
||||
const key = `level-${nextLevelKey.current}`;
|
||||
setStack((prev) => [...prev, { key, label, content }]);
|
||||
}, []);
|
||||
|
||||
const drillBack = useCallback(() => {
|
||||
setStack((prev) => prev.slice(0, -1));
|
||||
}, []);
|
||||
|
||||
const current = stack[stack.length - 1];
|
||||
|
||||
const content = (
|
||||
<MenuNavigationContext.Provider value={{ drillIn }}>
|
||||
<Fragment key={current ? current.key : "root"}>
|
||||
{current && (
|
||||
<>
|
||||
<MenuItem
|
||||
id="__menu-back"
|
||||
className={styles.backItem}
|
||||
textValue={typeof current.label === "string" ? current.label : undefined}
|
||||
shouldCloseOnSelect={false}
|
||||
onAction={drillBack}
|
||||
>
|
||||
<svg
|
||||
className={styles.subMenuChevron}
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M10 4l-4 4 4 4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span className={styles.backLabel}>{current.label}</span>
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{current ? current.content : children}
|
||||
</Fragment>
|
||||
</MenuNavigationContext.Provider>
|
||||
);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
// Menu/ContextMenu deliberately don't use react-aria-components' own
|
||||
// MenuTrigger (see the comments on each below), so they also don't get its
|
||||
// built-in RootMenuTriggerStateContext coordination that would otherwise
|
||||
// close one open instance when another opens elsewhere — e.g. right-clicking
|
||||
// file B while file A's context menu is still open should close A's, the
|
||||
// way a native OS context menu would, but each Menu/ContextMenu here is an
|
||||
// independent instance (one per row) with no shared ancestor to hold that
|
||||
// state. A window-level broadcast fills the same role without requiring
|
||||
// one: opening announces this instance's id, and every other mounted
|
||||
// instance closes itself on hearing an id that isn't its own.
|
||||
const GLOBAL_OPEN_EVENT = "penpot-ds-menu-open";
|
||||
|
||||
function useSoloOpen(isOpen: boolean | undefined, close: () => void) {
|
||||
const id = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(GLOBAL_OPEN_EVENT, { detail: id }),
|
||||
);
|
||||
}, [isOpen, id]);
|
||||
|
||||
useEffect(() => {
|
||||
const onOtherOpen = (e: Event) => {
|
||||
if ((e as CustomEvent).detail !== id) close();
|
||||
};
|
||||
window.addEventListener(GLOBAL_OPEN_EVENT, onOtherOpen);
|
||||
return () => window.removeEventListener(GLOBAL_OPEN_EVENT, onOtherOpen);
|
||||
}, [id, close]);
|
||||
}
|
||||
|
||||
// isNonModal (see the comment on Menu's own Popover below) has a side
|
||||
// effect beyond the inert-marking it's there to avoid: react-aria's
|
||||
// Popover only wires up its own click-outside-closes behavior when it
|
||||
// considers itself dismissable, which — for a plain Menu/ContextMenu (not
|
||||
// a SubmenuTrigger's nested flyout) — isNonModal forces off entirely, and
|
||||
// that isn't something a prop can turn back on independently. This
|
||||
// restores it directly: any pointerdown that lands outside the popover's
|
||||
// own content closes it, exactly like a normal dismissable popover would.
|
||||
function useCloseOnOutsideClick(
|
||||
isOpen: boolean | undefined,
|
||||
popoverRef: RefObject<HTMLElement | null>,
|
||||
close: () => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (!popoverRef.current?.contains(e.target as Node)) close();
|
||||
};
|
||||
document.addEventListener("pointerdown", onPointerDown);
|
||||
return () => document.removeEventListener("pointerdown", onPointerDown);
|
||||
}, [isOpen, popoverRef, close]);
|
||||
}
|
||||
|
||||
interface MenuProps {
|
||||
isOpen?: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
trigger?: ReactNode;
|
||||
children: ReactNode;
|
||||
placement?: Placement;
|
||||
className?: string;
|
||||
onAction?: (key: Key) => void;
|
||||
}
|
||||
|
||||
// MenuTrigger normally locates the trigger's DOM node by requiring its
|
||||
// child to be "pressable" (call usePress() itself, as react-aria-components'
|
||||
// own Button does). Penpot's DS buttons are plain rumext components that
|
||||
// don't do that, so MenuTrigger silently gets a null triggerRef and the
|
||||
// Popover falls back to positioning at (0, 0). As with ContextMenu below,
|
||||
// this drives the trigger ref explicitly instead of relying on that
|
||||
// detection.
|
||||
export function Menu({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
trigger,
|
||||
children,
|
||||
placement = "bottom start",
|
||||
className,
|
||||
onAction,
|
||||
}: MenuProps) {
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const triggerId = useId();
|
||||
const [shouldSkipAnimation, setShouldSkipAnimation] = useState(false);
|
||||
const navigationContent = useMenuNavigation(children, isOpen);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) setShouldSkipAnimation(false);
|
||||
}, [isOpen]);
|
||||
|
||||
const closeController: MenuCloseController = {
|
||||
closeAll: () => {
|
||||
setShouldSkipAnimation(true);
|
||||
onOpenChange?.(false);
|
||||
},
|
||||
shouldSkipAnimation,
|
||||
};
|
||||
|
||||
// Closing (any reason: outside click, Escape, item select) skips the exit
|
||||
// animation — the trigger can be asked to reopen this same Popover at any
|
||||
// moment (another click on it), and if that lands while the previous
|
||||
// instance is still mid exit-fade, the Popover can fail to reopen or
|
||||
// briefly show both. Skipping the exit keeps the DOM state unambiguous by
|
||||
// the time any subsequent open request comes in.
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) setShouldSkipAnimation(true);
|
||||
onOpenChange?.(open);
|
||||
},
|
||||
[onOpenChange],
|
||||
);
|
||||
|
||||
const close = useCallback(() => handleOpenChange(false), [handleOpenChange]);
|
||||
useSoloOpen(isOpen, close);
|
||||
useCloseOnOutsideClick(isOpen, popoverRef, close);
|
||||
|
||||
return (
|
||||
<MenuCloseContext.Provider value={closeController}>
|
||||
<div className={styles.menuTrigger} ref={triggerRef} id={triggerId}>
|
||||
{trigger}
|
||||
</div>
|
||||
<Popover
|
||||
ref={popoverRef}
|
||||
triggerRef={triggerRef}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
placement={placement}
|
||||
offset={4}
|
||||
className={styles.popover}
|
||||
shouldSkipAnimation={shouldSkipAnimation}
|
||||
// A menu is a lightweight, dismissable overlay, not a true modal —
|
||||
// Popover treats itself as modal by default, which marks the rest
|
||||
// of the app inert (unfocusable and unclickable) while it's open.
|
||||
// That's correct for a real Dialog, but here it silently breaks any
|
||||
// interaction with the rest of the page (e.g. right-clicking a
|
||||
// different row to open its own context menu) until this one
|
||||
// closes.
|
||||
isNonModal
|
||||
>
|
||||
<RACMenu
|
||||
aria-labelledby={triggerId}
|
||||
className={`${styles.menu} ${className ?? ""}`}
|
||||
onAction={onAction}
|
||||
onClose={() => handleOpenChange(false)}
|
||||
autoFocus="first"
|
||||
>
|
||||
{navigationContent}
|
||||
</RACMenu>
|
||||
</Popover>
|
||||
</MenuCloseContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuItemProps {
|
||||
id?: Key;
|
||||
children: ReactNode;
|
||||
isDisabled?: boolean;
|
||||
onAction?: () => void;
|
||||
className?: string;
|
||||
textValue?: string;
|
||||
// False for an item that navigates (a drilldown SubMenu's own trigger row,
|
||||
// the back item) instead of performing an action the menu should close
|
||||
// after. Defaults to true, react-aria-components' own default.
|
||||
shouldCloseOnSelect?: boolean;
|
||||
}
|
||||
|
||||
export function MenuItem({
|
||||
id,
|
||||
children,
|
||||
isDisabled,
|
||||
onAction,
|
||||
className,
|
||||
textValue,
|
||||
shouldCloseOnSelect,
|
||||
}: MenuItemProps) {
|
||||
return (
|
||||
<RACMenuItem
|
||||
id={id}
|
||||
isDisabled={isDisabled}
|
||||
onAction={onAction}
|
||||
textValue={textValue}
|
||||
shouldCloseOnSelect={shouldCloseOnSelect}
|
||||
className={`${styles.menuItem} ${className ?? ""}`}
|
||||
>
|
||||
{children}
|
||||
</RACMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function SubMenuTriggerContent({ trigger }: { trigger: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<span className={styles.subMenuLabel}>{trigger}</span>
|
||||
<svg
|
||||
className={styles.subMenuChevron}
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M6 4l4 4-4 4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface SubMenuProps {
|
||||
id?: Key;
|
||||
trigger: ReactNode;
|
||||
children: ReactNode;
|
||||
isDisabled?: boolean;
|
||||
textValue?: string;
|
||||
className?: string;
|
||||
onAction?: (key: Key) => void;
|
||||
// "flyout" (default) opens a nested popover next to this item, like a
|
||||
// desktop context menu. "drilldown" replaces the parent menu's own
|
||||
// content with this submenu's items and adds a back item, for trees too
|
||||
// deep/wide for a chain of flyouts (e.g. move-to-project's team ->
|
||||
// project nesting). onAction is ignored in drilldown mode: its items sit
|
||||
// in the same RACMenu as everything else, so the root Menu/ContextMenu's
|
||||
// own onAction already sees them selected.
|
||||
variant?: "flyout" | "drilldown";
|
||||
}
|
||||
|
||||
// The submenu's own trigger is always a MenuItem, which — unlike the
|
||||
// arbitrary trigger passed to Menu/ContextMenu above — is a real
|
||||
// react-aria-components element that forwards its ref properly. So
|
||||
// SubmenuTrigger's built-in ref/positioning detection (the thing that
|
||||
// doesn't work for Penpot's own DS buttons) works fine here, and this can
|
||||
// use the plain react-aria-components composition.
|
||||
export function SubMenu({
|
||||
id,
|
||||
trigger,
|
||||
children,
|
||||
isDisabled,
|
||||
textValue,
|
||||
className,
|
||||
onAction,
|
||||
variant = "flyout",
|
||||
}: SubMenuProps) {
|
||||
const closeController = useContext(MenuCloseContext);
|
||||
const navigation = useContext(MenuNavigationContext);
|
||||
|
||||
if (variant === "drilldown") {
|
||||
return (
|
||||
<MenuItem
|
||||
id={id}
|
||||
isDisabled={isDisabled}
|
||||
textValue={textValue}
|
||||
className={styles.subMenuItem}
|
||||
shouldCloseOnSelect={false}
|
||||
onAction={() => navigation?.drillIn(trigger, children)}
|
||||
>
|
||||
<SubMenuTriggerContent trigger={trigger} />
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubmenuTrigger>
|
||||
<MenuItem
|
||||
id={id}
|
||||
isDisabled={isDisabled}
|
||||
textValue={textValue}
|
||||
className={styles.subMenuItem}
|
||||
>
|
||||
<SubMenuTriggerContent trigger={trigger} />
|
||||
</MenuItem>
|
||||
<Popover
|
||||
className={styles.popover}
|
||||
offset={4}
|
||||
crossOffset={-4}
|
||||
shouldSkipAnimation={closeController?.shouldSkipAnimation}
|
||||
// See the isNonModal comment on Menu's own Popover above.
|
||||
isNonModal
|
||||
>
|
||||
<RACMenu
|
||||
className={`${styles.menu} ${className ?? ""}`}
|
||||
onAction={(key) => {
|
||||
onAction?.(key);
|
||||
closeController?.closeAll();
|
||||
}}
|
||||
autoFocus="first"
|
||||
>
|
||||
{children}
|
||||
</RACMenu>
|
||||
</Popover>
|
||||
</SubmenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuSeparatorProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MenuSeparator({ className }: MenuSeparatorProps) {
|
||||
return <Separator className={`${styles.separator} ${className ?? ""}`} />;
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
trigger: ReactNode;
|
||||
children: ReactNode;
|
||||
"aria-label": string;
|
||||
placement?: Placement;
|
||||
className?: string;
|
||||
isDisabled?: boolean;
|
||||
onAction?: (key: Key) => void;
|
||||
}
|
||||
|
||||
// MenuTrigger's built-in press/context-menu detection only works when its
|
||||
// child calls usePress() itself (e.g. react-aria-components' own Button).
|
||||
// Penpot's own DS buttons aren't react-aria components, so instead of
|
||||
// relying on that, this drives everything explicitly: a plain onContextMenu
|
||||
// handler opens a standalone Popover anchored to an invisible element moved
|
||||
// to the click position.
|
||||
export function ContextMenu({
|
||||
trigger,
|
||||
children,
|
||||
"aria-label": ariaLabel,
|
||||
placement = "bottom start",
|
||||
className,
|
||||
isDisabled,
|
||||
onAction,
|
||||
}: ContextMenuProps) {
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [shouldSkipAnimation, setShouldSkipAnimation] = useState(false);
|
||||
const navigationContent = useMenuNavigation(children, isOpen);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) setShouldSkipAnimation(false);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(e: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (isDisabled) return;
|
||||
e.preventDefault();
|
||||
const anchor = anchorRef.current;
|
||||
if (anchor) {
|
||||
anchor.style.left = `${e.clientX}px`;
|
||||
anchor.style.top = `${e.clientY}px`;
|
||||
}
|
||||
setIsOpen(true);
|
||||
},
|
||||
[isDisabled],
|
||||
);
|
||||
|
||||
const closeController: MenuCloseController = {
|
||||
closeAll: () => {
|
||||
setShouldSkipAnimation(true);
|
||||
setIsOpen(false);
|
||||
},
|
||||
shouldSkipAnimation,
|
||||
};
|
||||
|
||||
// See the same handleOpenChange in Menu above: closing always skips the
|
||||
// exit animation so a right-click landing while the previous instance is
|
||||
// still mid exit-fade can't race it into failing to reopen.
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) setShouldSkipAnimation(true);
|
||||
setIsOpen(open);
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => handleOpenChange(false), [handleOpenChange]);
|
||||
useSoloOpen(isOpen, close);
|
||||
useCloseOnOutsideClick(isOpen, popoverRef, close);
|
||||
|
||||
return (
|
||||
<MenuCloseContext.Provider value={closeController}>
|
||||
<div
|
||||
className={styles.contextMenuTrigger}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{trigger}
|
||||
</div>
|
||||
{createPortal(
|
||||
// Popover itself portals to document.body, so its anchor must too —
|
||||
// otherwise an ancestor with a CSS transform (a Storybook decorator,
|
||||
// or any app-level one) can make position: fixed here resolve
|
||||
// against that ancestor instead of the real viewport, while
|
||||
// clientX/clientY (used to place it) always stay viewport-relative.
|
||||
<div ref={anchorRef} className={styles.contextMenuAnchor} />,
|
||||
document.body,
|
||||
)}
|
||||
<Popover
|
||||
ref={popoverRef}
|
||||
triggerRef={anchorRef}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
placement={placement}
|
||||
offset={0}
|
||||
className={styles.popover}
|
||||
shouldSkipAnimation={shouldSkipAnimation}
|
||||
// See the isNonModal comment on Menu's own Popover.
|
||||
isNonModal
|
||||
>
|
||||
<RACMenu
|
||||
aria-label={ariaLabel}
|
||||
className={`${styles.menu} ${className ?? ""}`}
|
||||
onAction={onAction}
|
||||
onClose={() => handleOpenChange(false)}
|
||||
autoFocus="first"
|
||||
>
|
||||
{navigationContent}
|
||||
</RACMenu>
|
||||
</Popover>
|
||||
</MenuCloseContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
SubMenu,
|
||||
ContextMenu,
|
||||
} from "./lib/menu/Menu";
|
||||
@@ -1,20 +1,19 @@
|
||||
/// <reference types='vitest' />
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import dts from 'vite-plugin-dts';
|
||||
import * as path from 'path';
|
||||
import { copyFileSync } from 'node:fs';
|
||||
import { defineConfig, esmExternalRequirePlugin } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import dts from "vite-plugin-dts";
|
||||
import * as path from "path";
|
||||
import { copyFileSync } from "node:fs";
|
||||
|
||||
const externalDeps = ["react", "react-dom", "react/jsx-runtime"];
|
||||
|
||||
const copyCssPlugin = () => ({
|
||||
name: 'copy-css',
|
||||
name: "copy-css",
|
||||
closeBundle: () => {
|
||||
try {
|
||||
copyFileSync(
|
||||
'dist/ui.css',
|
||||
'../../resources/public/css/ui.css',
|
||||
);
|
||||
copyFileSync("dist/ui.css", "../../resources/public/css/ui.css");
|
||||
} catch (e) {
|
||||
console.log('Error copying css file', e);
|
||||
console.log("Error copying css file", e);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -24,27 +23,25 @@ export default defineConfig(() => ({
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
loadPaths: [
|
||||
path.resolve(import.meta.dirname, '../../src/app/main/ui'),
|
||||
],
|
||||
loadPaths: [path.resolve(import.meta.dirname, "../../src/app/main/ui")],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
react({
|
||||
babel: {
|
||||
plugins: ['babel-plugin-react-compiler'],
|
||||
plugins: ["babel-plugin-react-compiler"],
|
||||
},
|
||||
}),
|
||||
dts({
|
||||
entryRoot: 'src',
|
||||
tsconfigPath: path.join(import.meta.dirname, 'tsconfig.lib.json'),
|
||||
entryRoot: "src",
|
||||
tsconfigPath: path.join(import.meta.dirname, "tsconfig.lib.json"),
|
||||
pathsToAliases: false,
|
||||
}),
|
||||
copyCssPlugin(),
|
||||
],
|
||||
build: {
|
||||
outDir: 'dist/',
|
||||
outDir: "dist/",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
commonjsOptions: {
|
||||
@@ -52,26 +49,34 @@ export default defineConfig(() => ({
|
||||
},
|
||||
lib: {
|
||||
entry: {
|
||||
index: 'src/index.ts',
|
||||
modal: 'src/modal.ts',
|
||||
index: "src/index.ts",
|
||||
modal: "src/modal.ts",
|
||||
menu: "src/menu.ts",
|
||||
},
|
||||
name: 'ui',
|
||||
formats: ['es' as const],
|
||||
name: "ui",
|
||||
formats: ["es" as const],
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ['react', 'react-dom', 'react/jsx-runtime'],
|
||||
// Vendored CJS-only deps (e.g. use-sync-external-store) call
|
||||
// require("react") internally. Rolldown keeps require() calls
|
||||
// against external modules as-is instead of converting them to
|
||||
// import, which breaks in the browser where require() doesn't
|
||||
// exist. esmExternalRequirePlugin both marks these as external and
|
||||
// rewrites those calls to real ESM imports.
|
||||
// https://rolldown.rs/in-depth/bundling-cjs#require-external-modules
|
||||
plugins: [esmExternalRequirePlugin({ external: externalDeps })],
|
||||
},
|
||||
},
|
||||
test: {
|
||||
name: 'ui',
|
||||
name: "ui",
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
reporters: ['default'],
|
||||
environment: "jsdom",
|
||||
include: ["{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
|
||||
reporters: ["default"],
|
||||
coverage: {
|
||||
reportsDirectory: '../../coverage/libs/ui',
|
||||
provider: 'v8' as const,
|
||||
reportsDirectory: "../../coverage/libs/ui",
|
||||
provider: "v8" as const,
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -1,208 +0,0 @@
|
||||
{
|
||||
"~:features": {
|
||||
"~#set": [
|
||||
"fdata/path-data",
|
||||
"plugins/runtime",
|
||||
"design-tokens/v1",
|
||||
"variants/v1",
|
||||
"layout/grid",
|
||||
"styles/v2",
|
||||
"fdata/objects-map",
|
||||
"text-editor/v2",
|
||||
"render-wasm/v1",
|
||||
"text-editor-wasm/v1",
|
||||
"components/v2",
|
||||
"fdata/shape-data-type"
|
||||
]
|
||||
},
|
||||
"~:team-id": "~u8b485740-3f39-8080-8008-400e7784f55a",
|
||||
"~:permissions": {
|
||||
"~:type": "~:membership",
|
||||
"~:is-owner": true,
|
||||
"~:is-admin": true,
|
||||
"~:can-edit": true,
|
||||
"~:can-read": true,
|
||||
"~:is-logged": true
|
||||
},
|
||||
"~:has-media-trimmed": false,
|
||||
"~:comment-thread-seqn": 0,
|
||||
"~:name": "New File 3",
|
||||
"~:revn": 1,
|
||||
"~:modified-at": "~m1788780032767",
|
||||
"~:vern": 0,
|
||||
"~:id": "~u77d38721-22c1-81f4-8008-9a2a3e7ce674",
|
||||
"~:is-shared": false,
|
||||
"~:migrations": {
|
||||
"~#ordered-set": [
|
||||
"legacy-2",
|
||||
"legacy-3",
|
||||
"legacy-5",
|
||||
"legacy-6",
|
||||
"legacy-7",
|
||||
"legacy-8",
|
||||
"legacy-9",
|
||||
"legacy-10",
|
||||
"legacy-11",
|
||||
"legacy-12",
|
||||
"legacy-13",
|
||||
"legacy-14",
|
||||
"legacy-16",
|
||||
"legacy-17",
|
||||
"legacy-18",
|
||||
"legacy-19",
|
||||
"legacy-25",
|
||||
"legacy-26",
|
||||
"legacy-27",
|
||||
"legacy-28",
|
||||
"legacy-29",
|
||||
"legacy-31",
|
||||
"legacy-32",
|
||||
"legacy-33",
|
||||
"legacy-34",
|
||||
"legacy-36",
|
||||
"legacy-37",
|
||||
"legacy-38",
|
||||
"legacy-39",
|
||||
"legacy-40",
|
||||
"legacy-41",
|
||||
"legacy-42",
|
||||
"legacy-43",
|
||||
"legacy-44",
|
||||
"legacy-45",
|
||||
"legacy-46",
|
||||
"legacy-47",
|
||||
"legacy-48",
|
||||
"legacy-49",
|
||||
"legacy-50",
|
||||
"legacy-51",
|
||||
"legacy-52",
|
||||
"legacy-53",
|
||||
"legacy-54",
|
||||
"legacy-55",
|
||||
"legacy-56",
|
||||
"legacy-57",
|
||||
"legacy-59",
|
||||
"legacy-62",
|
||||
"legacy-65",
|
||||
"legacy-66",
|
||||
"legacy-67",
|
||||
"0001-remove-tokens-from-groups",
|
||||
"0002-normalize-bool-content-v2",
|
||||
"0002-clean-shape-interactions",
|
||||
"0003-fix-root-shape",
|
||||
"0003-convert-path-content-v2",
|
||||
"0005-deprecate-image-type",
|
||||
"0006-fix-old-texts-fills",
|
||||
"0008-fix-library-colors-v4",
|
||||
"0009-clean-library-colors",
|
||||
"0009-add-partial-text-touched-flags",
|
||||
"0010-fix-swap-slots-pointing-non-existent-shapes",
|
||||
"0011-fix-invalid-text-touched-flags",
|
||||
"0012-fix-position-data",
|
||||
"0013-fix-component-path",
|
||||
"0013-clear-invalid-strokes-and-fills",
|
||||
"0014-fix-tokens-lib-duplicate-ids",
|
||||
"0014-clear-components-nil-objects",
|
||||
"0015-fix-text-attrs-blank-strings",
|
||||
"0015-clean-shadow-color",
|
||||
"0016-copy-fills-from-position-data-to-text-node",
|
||||
"0017-fix-layout-flex-dir",
|
||||
"0018-remove-unneeded-objects-from-components",
|
||||
"0019-fix-missing-swap-slots",
|
||||
"0020-sync-component-id-with-near-main",
|
||||
"0021-fix-shape-svg-attrs",
|
||||
"0022-normalize-component-root-and-resync",
|
||||
"0023-repair-token-themes-with-inexistent-sets",
|
||||
"0024b-fix-stroke-cap-placement",
|
||||
"0025-repair-empty-text-content",
|
||||
"0026-fix-svg-raw-shapes-uuids"
|
||||
]
|
||||
},
|
||||
"~:version": 67,
|
||||
"~:project-id": "~u8b485740-3f39-8080-8008-400e7786d1d0",
|
||||
"~:created-at": "~m1788779992563",
|
||||
"~:backend": "db",
|
||||
"~:data": {
|
||||
"~:pages": [
|
||||
"~u77d38721-22c1-81f4-8008-9a2a3e7ce675"
|
||||
],
|
||||
"~:pages-index": {
|
||||
"~u77d38721-22c1-81f4-8008-9a2a3e7ce675": {
|
||||
"~:objects": {
|
||||
"~#penpot/objects-map/v2": {
|
||||
"~u00000000-0000-0000-0000-000000000000": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"Root Frame\",\"~:width\",0.01,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0.0,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.01]],[\"^:\",[\"^ \",\"~:x\",0.0,\"~:y\",0.01]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1.0,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",0.01,\"~:height\",0.01,\"~:x1\",0,\"~:y1\",0,\"~:x2\",0.01,\"~:y2\",0.01]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^H\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~u099a17ba-4c81-804d-8008-9a2a586a1217\",\"~u099a17ba-4c81-804d-8008-9a2a587927c9\",\"~u099a17ba-4c81-804d-8008-9a2a5886910d\",\"~u099a17ba-4c81-804d-8008-9a2a5894fa56\"]]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a57981d1d": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-15\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",300.0000008940697,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",320.0000011920929,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",320.0000011920929,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",300.0000008940697,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57981d1d\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",300.0000008940697,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",300.0000008940697,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",300.0000008940697,\"~:y1\",0,\"~:x2\",320.0000011920929,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a58059cfd": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-35\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",700.0000020861626,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",720.0000023841858,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",720.0000023841858,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",700.0000020861626,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a58059cfd\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",700.0000020861626,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",700.0000020861626,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",700.0000020861626,\"~:y1\",0,\"~:x2\",720.0000023841858,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a576c177d": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-9\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",180.0000005364418,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",200.00000083446503,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",200.00000083446503,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",180.0000005364418,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a576c177d\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",180.0000005364418,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",180.0000005364418,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",180.0000005364418,\"~:y1\",0,\"~:x2\",200.00000083446503,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a57d786dd": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-26\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",520.0000015497208,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",540.000001847744,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",540.000001847744,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",520.0000015497208,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57d786dd\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",520.0000015497208,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",520.0000015497208,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",520.0000015497208,\"~:y1\",0,\"~:x2\",540.000001847744,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a57b171fc": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-19\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",380.00000113248825,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",400.0000014305115,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",400.0000014305115,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",380.00000113248825,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57b171fc\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",380.00000113248825,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",380.00000113248825,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",380.00000113248825,\"~:y1\",0,\"~:x2\",400.0000014305115,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a578122dc": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-12\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",240.00000071525574,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",260.00000101327896,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",260.00000101327896,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",240.00000071525574,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a578122dc\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",240.00000071525574,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",240.00000071525574,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",240.00000071525574,\"~:y1\",0,\"~:x2\",260.00000101327896,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a58193cbf": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-39\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",780.0000023245811,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",800.0000026226044,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",800.0000026226044,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",780.0000023245811,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a58193cbf\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",780.0000023245811,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",780.0000023245811,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",780.0000023245811,\"~:y1\",0,\"~:x2\",800.0000026226044,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a5814337e": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-38\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",760.0000022649765,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",780.0000025629997,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",780.0000025629997,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",760.0000022649765,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5814337e\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",760.0000022649765,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",760.0000022649765,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",760.0000022649765,\"~:y1\",0,\"~:x2\",780.0000025629997,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a57e7221e": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-29\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",580.0000017285347,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",600.0000020265579,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",600.0000020265579,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",580.0000017285347,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57e7221e\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",580.0000017285347,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",580.0000017285347,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",580.0000017285347,\"~:y1\",0,\"~:x2\",600.0000020265579,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a580f6e3e": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-37\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",740.0000022053719,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",760.0000025033951,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",760.0000025033951,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",740.0000022053719,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a580f6e3e\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",740.0000022053719,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",740.0000022053719,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",740.0000022053719,\"~:y1\",0,\"~:x2\",760.0000025033951,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a577afc79": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-11\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",220.0000006556511,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",240.00000095367432,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",240.00000095367432,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",220.0000006556511,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a577afc79\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",220.0000006556511,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",220.0000006556511,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",220.0000006556511,\"~:y1\",0,\"~:x2\",240.00000095367432,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a57ec0a79": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-30\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",600.0000017881393,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",620.0000020861626,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",620.0000020861626,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",600.0000017881393,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57ec0a79\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",600.0000017881393,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",600.0000017881393,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",600.0000017881393,\"~:y1\",0,\"~:x2\",620.0000020861626,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a57f5d458": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-32\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",640.0000019073486,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",660.0000022053719,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",660.0000022053719,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",640.0000019073486,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57f5d458\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",640.0000019073486,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",640.0000019073486,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",640.0000019073486,\"~:y1\",0,\"~:x2\",660.0000022053719,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a5833c5bb": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-44\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",880.0000026226044,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",900.0000029206276,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",900.0000029206276,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",880.0000026226044,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5833c5bb\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",880.0000026226044,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",880.0000026226044,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",880.0000026226044,\"~:y1\",0,\"~:x2\",900.0000029206276,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a5772831b": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-10\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",200.00000059604645,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",220.00000089406967,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",220.00000089406967,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",200.00000059604645,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5772831b\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",200.00000059604645,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",200.00000059604645,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",200.00000059604645,\"~:y1\",0,\"~:x2\",220.00000089406967,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a5849a83a": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-48\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",960.000002861023,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",980.0000031590462,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",980.0000031590462,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",960.000002861023,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5849a83a\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",960.000002861023,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",960.000002861023,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",960.000002861023,\"~:y1\",0,\"~:x2\",980.0000031590462,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a57c1477a": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-22\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",440.0000013113022,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",460.0000016093254,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",460.0000016093254,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",440.0000013113022,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57c1477a\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",440.0000013113022,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",440.0000013113022,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",440.0000013113022,\"~:y1\",0,\"~:x2\",460.0000016093254,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a581f6595": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-40\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",800.0000023841858,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",820.000002682209,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",820.000002682209,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",800.0000023841858,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a581f6595\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",800.0000023841858,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",800.0000023841858,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",800.0000023841858,\"~:y1\",0,\"~:x2\",820.000002682209,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a5722f855": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-0\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",20.000000298023224,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",20.000000298023224,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",0,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5722f855\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",0,\"~:y1\",0,\"~:x2\",20.000000298023224,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a5853f075": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-50\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1000.0000029802322,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1020.0000032782555,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1020.0000032782555,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",1000.0000029802322,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5853f075\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",1000.0000029802322,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1000.0000029802322,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",1000.0000029802322,\"~:y1\",0,\"~:x2\",1020.0000032782555,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a58713434": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:background-blur\",[\"^ \",\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5878a273\",\"~:type\",\"^1\",\"~:value\",20,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"blurred-child\",\"~:width\",259.99999046325684,\"^3\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0,\"~:y\",0]],[\"^=\",[\"^ \",\"~:x\",259.99999046325684,\"~:y\",0]],[\"^=\",[\"^ \",\"~:x\",259.99999046325684,\"~:y\",320.0000047683716]],[\"^=\",[\"^ \",\"~:x\",0,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:r1\",0,\"^2\",\"~u099a17ba-4c81-804d-8008-9a2a58713434\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a586a1217\",\"~:frame-id\",\"~u099a17ba-4c81-804d-8008-9a2a586a1217\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^:\",259.99999046325684,\"~:height\",320.0000047683716,\"~:x1\",0,\"~:y1\",0,\"~:x2\",259.99999046325684,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.1]],\"~:flip-x\",null,\"^N\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a579e7ad4": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-16\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",320.0000009536743,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",340.00000125169754,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",340.00000125169754,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",320.0000009536743,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a579e7ad4\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",320.0000009536743,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",320.0000009536743,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",320.0000009536743,\"~:y1\",0,\"~:x2\",340.00000125169754,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a586a1217": "[\"~#shape\",[\"^ \",\"~:y\",40.000003814697266,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"clip-on-child-larger\",\"~:width\",179.99999523162842,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",39.99999809265137,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",219.99999332427979,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",219.99999332427979,\"~:y\",280.00001335144043]],[\"^:\",[\"^ \",\"~:x\",39.99999809265137,\"~:y\",280.00001335144043]]],\"~:r2\",24,\"~:show-content\",false,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",24,\"~:r1\",24,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a586a1217\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#ff00ff\",\"~:stroke-opacity\",1,\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",2]],\"~:x\",39.99999809265137,\"~:proportion\",1,\"~:r4\",24,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",39.99999809265137,\"~:y\",40.000003814697266,\"^6\",179.99999523162842,\"~:height\",240.00000953674316,\"~:x1\",39.99999809265137,\"~:y1\",40.000003814697266,\"~:x2\",219.99999332427979,\"~:y2\",280.00001335144043]],\"~:fills\",[],\"~:flip-x\",null,\"^N\",240.00000953674316,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a58713434\"]]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a588ce9f6": "[\"~#shape\",[\"^ \",\"~:y\",40.000003814697266,\"~:background-blur\",[\"^ \",\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a58944011\",\"~:type\",\"^1\",\"~:value\",20,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"blurred-child\",\"~:width\",179.99999523162842,\"^3\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",40.000003814697266]],[\"^=\",[\"^ \",\"~:x\",739.9999685287476,\"~:y\",40.000003814697266]],[\"^=\",[\"^ \",\"~:x\",739.9999685287476,\"~:y\",280.00001335144043]],[\"^=\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",280.00001335144043]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:r1\",0,\"^2\",\"~u099a17ba-4c81-804d-8008-9a2a588ce9f6\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5886910d\",\"~:frame-id\",\"~u099a17ba-4c81-804d-8008-9a2a5886910d\",\"~:strokes\",[],\"~:x\",559.9999732971191,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",40.000003814697266,\"^:\",179.99999523162842,\"~:height\",240.00000953674316,\"~:x1\",559.9999732971191,\"~:y1\",40.000003814697266,\"~:x2\",739.9999685287476,\"~:y2\",280.00001335144043]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.1]],\"~:flip-x\",null,\"^N\",240.00000953674316,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a587ff716": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:background-blur\",[\"^ \",\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5885fde3\",\"~:type\",\"^1\",\"~:value\",20,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"blurred-child\",\"~:width\",259.99999046325684,\"^3\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",259.9999752044678,\"~:y\",0]],[\"^=\",[\"^ \",\"~:x\",519.9999656677246,\"~:y\",0]],[\"^=\",[\"^ \",\"~:x\",519.9999656677246,\"~:y\",320.0000047683716]],[\"^=\",[\"^ \",\"~:x\",259.9999752044678,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:r1\",0,\"^2\",\"~u099a17ba-4c81-804d-8008-9a2a587ff716\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a587927c9\",\"~:frame-id\",\"~u099a17ba-4c81-804d-8008-9a2a587927c9\",\"~:strokes\",[],\"~:x\",259.9999752044678,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",259.9999752044678,\"~:y\",0,\"^:\",259.99999046325684,\"~:height\",320.0000047683716,\"~:x1\",259.9999752044678,\"~:y1\",0,\"~:x2\",519.9999656677246,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.1]],\"~:flip-x\",null,\"^N\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a5894fa56": "[\"~#shape\",[\"^ \",\"~:y\",40.000003814697266,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"clip-on-text-child\",\"~:width\",179.99999523162842,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",819.999960899353,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",999.9999561309814,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",999.9999561309814,\"~:y\",280.00001335144043]],[\"^:\",[\"^ \",\"~:x\",819.999960899353,\"~:y\",280.00001335144043]]],\"~:r2\",24,\"~:show-content\",false,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",24,\"~:r1\",24,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5894fa56\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#ff00ff\",\"~:stroke-opacity\",1,\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",2]],\"~:x\",819.999960899353,\"~:proportion\",1,\"~:r4\",24,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",819.999960899353,\"~:y\",40.000003814697266,\"^6\",179.99999523162842,\"~:height\",240.00000953674316,\"~:x1\",819.999960899353,\"~:y1\",40.000003814697266,\"~:x2\",999.9999561309814,\"~:y2\",280.00001335144043]],\"~:fills\",[],\"~:flip-x\",null,\"^N\",240.00000953674316,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a589b7051\"]]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a589b7051": "[\"~#shape\",[\"^ \",\"~:y\",60,\"~:background-blur\",[\"^ \",\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a58a8f6a4\",\"~:type\",\"^1\",\"~:value\",20,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-width\",\"~:content\",[\"^ \",\"^3\",\"root\",\"~:children\",[[\"^ \",\"^3\",\"paragraph-set\",\"^<\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^<\",[[\"^ \",\"^=\",\"1.2\",\"^>\",\"normal\",\"~:typography-ref-id\",null,\"~:text-transform\",\"none\",\"~:text-align\",\"left\",\"~:font-id\",\"sourcesanspro\",\"~:font-size\",\"110\",\"~:font-weight\",\"400\",\"~:typography-ref-file\",null,\"~:text-direction\",\"ltr\",\"~:font-variant-id\",\"regular\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.25]],\"~:font-family\",\"sourcesanspro\",\"~:text\",\"Aa\"]],\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"110\",\"^D\",\"400\",\"^E\",null,\"^F\",\"ltr\",\"^3\",\"paragraph\",\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0\",\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.25]],\"^M\",\"sourcesanspro\"],[\"^ \",\"^=\",\"1.2\",\"^>\",\"normal\",\"^<\",[[\"^ \",\"^=\",\"1.2\",\"^>\",\"normal\",\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"110\",\"^D\",\"400\",\"^E\",null,\"^F\",\"ltr\",\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0\",\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.25]],\"^M\",\"sourcesanspro\",\"^N\",\"Aa\"]],\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"110\",\"^D\",\"400\",\"^E\",null,\"^F\",\"ltr\",\"^3\",\"paragraph\",\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0\",\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.25]],\"^M\",\"sourcesanspro\"]]]]],\"~:name\",\"blurred-text-child\",\"~:width\",117,\"^3\",\"^N\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",790,\"~:y\",60]],[\"^R\",[\"^ \",\"~:x\",907,\"~:y\",60]],[\"^R\",[\"^ \",\"~:x\",907,\"~:y\",324]],[\"^R\",[\"^ \",\"~:x\",790,\"~:y\",324]]],\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"^2\",\"~u099a17ba-4c81-804d-8008-9a2a589b7051\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5894fa56\",\"~:position-data\",[[\"^ \",\"~:y\",197.22000122070312,\"^=\",\"1.2\",\"^>\",\"normal\",\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"110px\",\"^D\",\"400\",\"^E\",null,\"^F\",\"ltr\",\"^P\",116.1199951171875,\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0px\",\"~:x\",790,\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.25]],\"~:direction\",\"ltr\",\"^M\",\"sourcesanspro\",\"~:height\",142.44000244140625,\"^N\",\"Aa\"],[\"^ \",\"~:y\",329.2200012207031,\"^=\",\"1.2\",\"^>\",\"normal\",\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"110px\",\"^D\",\"400\",\"^E\",null,\"^F\",\"ltr\",\"^P\",116.1199951171875,\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0px\",\"~:x\",790,\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.25]],\"^Z\",\"ltr\",\"^M\",\"sourcesanspro\",\"^[\",142.44000244140625,\"^N\",\"Aa\"]],\"~:frame-id\",\"~u099a17ba-4c81-804d-8008-9a2a5894fa56\",\"~:x\",790,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",790,\"~:y\",60,\"^P\",117,\"^[\",264,\"~:x1\",790,\"~:y1\",60,\"~:x2\",907,\"~:y2\",324]],\"~:flip-x\",null,\"^[\",264,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a58248d30": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-41\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",820.0000024437904,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",840.0000027418137,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",840.0000027418137,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",820.0000024437904,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a58248d30\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",820.0000024437904,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",820.0000024437904,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",820.0000024437904,\"~:y1\",0,\"~:x2\",840.0000027418137,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a5800e610": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-34\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",680.0000020265579,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",700.0000023245811,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",700.0000023245811,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",680.0000020265579,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5800e610\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",680.0000020265579,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",680.0000020265579,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",680.0000020265579,\"~:y1\",0,\"~:x2\",700.0000023245811,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a585a7293": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-51\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1020.0000030398369,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1040.00000333786,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1040.00000333786,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",1020.0000030398369,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a585a7293\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",1020.0000030398369,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1020.0000030398369,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",1020.0000030398369,\"~:y1\",0,\"~:x2\",1040.00000333786,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a573259d2": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-1\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",20.000000059604645,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",40.00000035762787,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",40.00000035762787,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",20.000000059604645,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a573259d2\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",20.000000059604645,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",20.000000059604645,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",20.000000059604645,\"~:y1\",0,\"~:x2\",40.00000035762787,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a57b6beb2": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-20\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",400.0000011920929,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",420.0000014901161,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",420.0000014901161,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",400.0000011920929,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57b6beb2\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",400.0000011920929,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",400.0000011920929,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",400.0000011920929,\"~:y1\",0,\"~:x2\",420.0000014901161,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a5886910d": "[\"~#shape\",[\"^ \",\"~:y\",40.000003814697266,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"clip-on-child-exact\",\"~:width\",179.99999523162842,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",739.9999685287476,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",739.9999685287476,\"~:y\",280.00001335144043]],[\"^:\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",280.00001335144043]]],\"~:r2\",24,\"~:show-content\",false,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",24,\"~:r1\",24,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5886910d\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#ff00ff\",\"~:stroke-opacity\",1,\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",2]],\"~:x\",559.9999732971191,\"~:proportion\",1,\"~:r4\",24,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",40.000003814697266,\"^6\",179.99999523162842,\"~:height\",240.00000953674316,\"~:x1\",559.9999732971191,\"~:y1\",40.000003814697266,\"~:x2\",739.9999685287476,\"~:y2\",280.00001335144043]],\"~:fills\",[],\"~:flip-x\",null,\"^N\",240.00000953674316,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a588ce9f6\"]]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a584428ad": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-47\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",940.0000028014183,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",960.0000030994415,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",960.0000030994415,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",940.0000028014183,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a584428ad\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",940.0000028014183,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",940.0000028014183,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",940.0000028014183,\"~:y1\",0,\"~:x2\",960.0000030994415,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a5749400d": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-4\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",80.00000023841858,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",100.0000005364418,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",100.0000005364418,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",80.00000023841858,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5749400d\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",80.00000023841858,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",80.00000023841858,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",80.00000023841858,\"~:y1\",0,\"~:x2\",100.0000005364418,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a578dcf0d": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-14\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",280.000000834465,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",300.00000113248825,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",300.00000113248825,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",280.000000834465,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a578dcf0d\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",280.000000834465,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",280.000000834465,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",280.000000834465,\"~:y1\",0,\"~:x2\",300.00000113248825,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a5864694c": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:index\",53,\"~:name\",\"backdrop\",\"~:width\",1060.0000033974648,\"~:type\",\"~:group\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0,\"~:y\",0]],[\"^:\",[\"^ \",\"~:x\",1060.0000033974648,\"~:y\",0]],[\"^:\",[\"^ \",\"~:x\",1060.0000033974648,\"~:y\",320.0000047683716]],[\"^:\",[\"^ \",\"~:x\",0,\"~:y\",320.0000047683716]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",1060.0000033974648,\"~:height\",320.0000047683716,\"~:x1\",0,\"~:y1\",0,\"~:x2\",1060.0000033974648,\"~:y2\",320.0000047683716]],\"~:fills\",[],\"~:flip-x\",null,\"^D\",320.0000047683716,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a5722f855\",\"~u099a17ba-4c81-804d-8008-9a2a573259d2\",\"~u099a17ba-4c81-804d-8008-9a2a573b052e\",\"~u099a17ba-4c81-804d-8008-9a2a574272cb\",\"~u099a17ba-4c81-804d-8008-9a2a5749400d\",\"~u099a17ba-4c81-804d-8008-9a2a5750f0ef\",\"~u099a17ba-4c81-804d-8008-9a2a57590dc2\",\"~u099a17ba-4c81-804d-8008-9a2a575f6f84\",\"~u099a17ba-4c81-804d-8008-9a2a5765bd49\",\"~u099a17ba-4c81-804d-8008-9a2a576c177d\",\"~u099a17ba-4c81-804d-8008-9a2a5772831b\",\"~u099a17ba-4c81-804d-8008-9a2a577afc79\",\"~u099a17ba-4c81-804d-8008-9a2a578122dc\",\"~u099a17ba-4c81-804d-8008-9a2a578826e0\",\"~u099a17ba-4c81-804d-8008-9a2a578dcf0d\",\"~u099a17ba-4c81-804d-8008-9a2a57981d1d\",\"~u099a17ba-4c81-804d-8008-9a2a579e7ad4\",\"~u099a17ba-4c81-804d-8008-9a2a57a5a180\",\"~u099a17ba-4c81-804d-8008-9a2a57abfe20\",\"~u099a17ba-4c81-804d-8008-9a2a57b171fc\",\"~u099a17ba-4c81-804d-8008-9a2a57b6beb2\",\"~u099a17ba-4c81-804d-8008-9a2a57bc32eb\",\"~u099a17ba-4c81-804d-8008-9a2a57c1477a\",\"~u099a17ba-4c81-804d-8008-9a2a57c81962\",\"~u099a17ba-4c81-804d-8008-9a2a57cd8cae\",\"~u099a17ba-4c81-804d-8008-9a2a57d2c500\",\"~u099a17ba-4c81-804d-8008-9a2a57d786dd\",\"~u099a17ba-4c81-804d-8008-9a2a57dcb9a7\",\"~u099a17ba-4c81-804d-8008-9a2a57e26c66\",\"~u099a17ba-4c81-804d-8008-9a2a57e7221e\",\"~u099a17ba-4c81-804d-8008-9a2a57ec0a79\",\"~u099a17ba-4c81-804d-8008-9a2a57f0d9a7\",\"~u099a17ba-4c81-804d-8008-9a2a57f5d458\",\"~u099a17ba-4c81-804d-8008-9a2a57faee43\",\"~u099a17ba-4c81-804d-8008-9a2a5800e610\",\"~u099a17ba-4c81-804d-8008-9a2a58059cfd\",\"~u099a17ba-4c81-804d-8008-9a2a580a33cb\",\"~u099a17ba-4c81-804d-8008-9a2a580f6e3e\",\"~u099a17ba-4c81-804d-8008-9a2a5814337e\",\"~u099a17ba-4c81-804d-8008-9a2a58193cbf\",\"~u099a17ba-4c81-804d-8008-9a2a581f6595\",\"~u099a17ba-4c81-804d-8008-9a2a58248d30\",\"~u099a17ba-4c81-804d-8008-9a2a5829b348\",\"~u099a17ba-4c81-804d-8008-9a2a582f24ee\",\"~u099a17ba-4c81-804d-8008-9a2a5833c5bb\",\"~u099a17ba-4c81-804d-8008-9a2a583a7c6b\",\"~u099a17ba-4c81-804d-8008-9a2a583f7ca0\",\"~u099a17ba-4c81-804d-8008-9a2a584428ad\",\"~u099a17ba-4c81-804d-8008-9a2a5849a83a\",\"~u099a17ba-4c81-804d-8008-9a2a584e9da3\",\"~u099a17ba-4c81-804d-8008-9a2a5853f075\",\"~u099a17ba-4c81-804d-8008-9a2a585a7293\",\"~u099a17ba-4c81-804d-8008-9a2a585f48c3\"]]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a5750f0ef": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-5\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",100.00000029802322,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",120.00000059604645,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",120.00000059604645,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",100.00000029802322,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5750f0ef\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",100.00000029802322,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",100.00000029802322,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",100.00000029802322,\"~:y1\",0,\"~:x2\",120.00000059604645,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a573b052e": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-2\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",40.00000011920929,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",60.00000041723251,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",60.00000041723251,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",40.00000011920929,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a573b052e\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",40.00000011920929,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",40.00000011920929,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",40.00000011920929,\"~:y1\",0,\"~:x2\",60.00000041723251,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a582f24ee": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-43\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",860.0000025629997,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",880.000002861023,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",880.000002861023,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",860.0000025629997,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a582f24ee\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",860.0000025629997,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",860.0000025629997,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",860.0000025629997,\"~:y1\",0,\"~:x2\",880.000002861023,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a57cd8cae": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-24\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",480.0000014305115,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",500.0000017285347,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",500.0000017285347,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",480.0000014305115,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57cd8cae\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",480.0000014305115,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",480.0000014305115,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",480.0000014305115,\"~:y1\",0,\"~:x2\",500.0000017285347,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a5765bd49": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-8\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",160.00000047683716,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",180.00000077486038,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",180.00000077486038,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",160.00000047683716,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5765bd49\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",160.00000047683716,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",160.00000047683716,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",160.00000047683716,\"~:y1\",0,\"~:x2\",180.00000077486038,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a587927c9": "[\"~#shape\",[\"^ \",\"~:y\",40.000003814697266,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"clip-off-child-larger\",\"~:width\",179.99999523162842,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",299.99998569488525,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",479.9999809265137,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",479.9999809265137,\"~:y\",280.00001335144043]],[\"^:\",[\"^ \",\"~:x\",299.99998569488525,\"~:y\",280.00001335144043]]],\"~:r2\",24,\"~:show-content\",true,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",24,\"~:r1\",24,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a587927c9\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#ff00ff\",\"~:stroke-opacity\",1,\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",2]],\"~:x\",299.99998569488525,\"~:proportion\",1,\"~:r4\",24,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",299.99998569488525,\"~:y\",40.000003814697266,\"^6\",179.99999523162842,\"~:height\",240.00000953674316,\"~:x1\",299.99998569488525,\"~:y1\",40.000003814697266,\"~:x2\",479.9999809265137,\"~:y2\",280.00001335144043]],\"~:fills\",[],\"~:flip-x\",null,\"^N\",240.00000953674316,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a587ff716\"]]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a5829b348": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-42\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",840.0000025033951,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",860.0000028014183,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",860.0000028014183,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",840.0000025033951,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5829b348\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",840.0000025033951,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",840.0000025033951,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",840.0000025033951,\"~:y1\",0,\"~:x2\",860.0000028014183,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a583a7c6b": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-45\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",900.000002682209,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",920.0000029802322,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",920.0000029802322,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",900.000002682209,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a583a7c6b\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",900.000002682209,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",900.000002682209,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",900.000002682209,\"~:y1\",0,\"~:x2\",920.0000029802322,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a580a33cb": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-36\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",720.0000021457672,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",740.0000024437904,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",740.0000024437904,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",720.0000021457672,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a580a33cb\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",720.0000021457672,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",720.0000021457672,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",720.0000021457672,\"~:y1\",0,\"~:x2\",740.0000024437904,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a574272cb": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-3\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",60.000000178813934,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",80.00000047683716,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",80.00000047683716,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",60.000000178813934,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a574272cb\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",60.000000178813934,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",60.000000178813934,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",60.000000178813934,\"~:y1\",0,\"~:x2\",80.00000047683716,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a57bc32eb": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-21\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",420.00000125169754,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",440.00000154972076,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",440.00000154972076,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",420.00000125169754,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57bc32eb\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",420.00000125169754,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",420.00000125169754,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",420.00000125169754,\"~:y1\",0,\"~:x2\",440.00000154972076,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a575f6f84": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-7\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",140.0000004172325,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",160.00000071525574,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",160.00000071525574,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",140.0000004172325,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a575f6f84\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",140.0000004172325,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",140.0000004172325,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",140.0000004172325,\"~:y1\",0,\"~:x2\",160.00000071525574,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a57dcb9a7": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-27\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",540.0000016093254,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",560.0000019073486,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",560.0000019073486,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",540.0000016093254,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57dcb9a7\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",540.0000016093254,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",540.0000016093254,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",540.0000016093254,\"~:y1\",0,\"~:x2\",560.0000019073486,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a57f0d9a7": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-31\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",620.000001847744,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",640.0000021457672,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",640.0000021457672,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",620.000001847744,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57f0d9a7\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",620.000001847744,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",620.000001847744,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",620.000001847744,\"~:y1\",0,\"~:x2\",640.0000021457672,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a57e26c66": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-28\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",560.00000166893,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",580.0000019669533,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",580.0000019669533,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",560.00000166893,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57e26c66\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",560.00000166893,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",560.00000166893,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",560.00000166893,\"~:y1\",0,\"~:x2\",580.0000019669533,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a57a5a180": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-17\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",340.00000101327896,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",360.0000013113022,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",360.0000013113022,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",340.00000101327896,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57a5a180\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",340.00000101327896,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",340.00000101327896,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",340.00000101327896,\"~:y1\",0,\"~:x2\",360.0000013113022,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a57d2c500": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-25\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",500.0000014901161,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",520.0000017881393,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",520.0000017881393,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",500.0000014901161,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57d2c500\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",500.0000014901161,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",500.0000014901161,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",500.0000014901161,\"~:y1\",0,\"~:x2\",520.0000017881393,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a583f7ca0": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-46\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",920.0000027418137,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",940.0000030398369,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",940.0000030398369,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",920.0000027418137,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a583f7ca0\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",920.0000027418137,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",920.0000027418137,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",920.0000027418137,\"~:y1\",0,\"~:x2\",940.0000030398369,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a578826e0": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-13\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",260.0000007748604,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",280.0000010728836,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",280.0000010728836,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",260.0000007748604,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a578826e0\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",260.0000007748604,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",260.0000007748604,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",260.0000007748604,\"~:y1\",0,\"~:x2\",280.0000010728836,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a57abfe20": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-18\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",360.0000010728836,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",380.00000137090683,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",380.00000137090683,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",360.0000010728836,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57abfe20\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",360.0000010728836,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",360.0000010728836,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",360.0000010728836,\"~:y1\",0,\"~:x2\",380.00000137090683,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a584e9da3": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-49\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",980.0000029206276,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1000.0000032186508,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1000.0000032186508,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",980.0000029206276,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a584e9da3\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",980.0000029206276,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",980.0000029206276,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",980.0000029206276,\"~:y1\",0,\"~:x2\",1000.0000032186508,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a585f48c3": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-52\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1040.0000030994415,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1060.0000033974648,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1060.0000033974648,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",1040.0000030994415,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a585f48c3\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",1040.0000030994415,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1040.0000030994415,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",1040.0000030994415,\"~:y1\",0,\"~:x2\",1060.0000033974648,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a57faee43": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-33\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",660.0000019669533,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",680.0000022649765,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",680.0000022649765,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",660.0000019669533,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57faee43\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",660.0000019669533,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",660.0000019669533,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",660.0000019669533,\"~:y1\",0,\"~:x2\",680.0000022649765,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a57590dc2": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-6\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",120.00000035762787,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",140.0000006556511,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",140.0000006556511,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",120.00000035762787,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57590dc2\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",120.00000035762787,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",120.00000035762787,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",120.00000035762787,\"~:y1\",0,\"~:x2\",140.0000006556511,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
|
||||
"~u099a17ba-4c81-804d-8008-9a2a57c81962": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-23\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",460.00000137090683,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",480.00000166893005,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",480.00000166893005,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",460.00000137090683,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57c81962\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",460.00000137090683,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",460.00000137090683,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",460.00000137090683,\"~:y1\",0,\"~:x2\",480.00000166893005,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]"
|
||||
}
|
||||
},
|
||||
"~:id": "~u77d38721-22c1-81f4-8008-9a2a3e7ce675",
|
||||
"~:name": "bg-blur-clip"
|
||||
}
|
||||
},
|
||||
"~:id": "~u77d38721-22c1-81f4-8008-9a2a3e7ce674",
|
||||
"~:options": {
|
||||
"~:components-v2": true,
|
||||
"~:base-font-size": "16px"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -608,21 +608,4 @@ test("Renders background blur under strokes on rects, paths and texts", async ({
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
|
||||
test("Renders background blur clipped by a board with clip content", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = new WasmWorkspacePage(page);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("render-wasm/get-file-background-blur-clip.json");
|
||||
|
||||
await workspace.goToWorkspace({
|
||||
id: "77d38721-22c1-81f4-8008-9a2a3e7ce674",
|
||||
pageId: "77d38721-22c1-81f4-8008-9a2a3e7ce675",
|
||||
pageName: "bg-blur-clip",
|
||||
});
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
});
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 49 KiB |
@@ -198,8 +198,7 @@
|
||||
(update [_ state]
|
||||
(-> state
|
||||
(dissoc :selected-files)
|
||||
(dissoc :selected-project)
|
||||
(update :dashboard-local dissoc :menu-open :menu-pos)))))
|
||||
(dissoc :selected-project)))))
|
||||
|
||||
(defn toggle-file-select
|
||||
[{:keys [id project-id] :as file}]
|
||||
@@ -214,36 +213,6 @@
|
||||
(assoc :selected-project project-id))
|
||||
state)))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Show grid menu
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn show-file-menu-with-position
|
||||
[file-id pos]
|
||||
(ptk/reify ::show-file-menu-with-position
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(update state :dashboard-local assoc
|
||||
:menu-open true
|
||||
:menu-pos pos
|
||||
:file-id file-id))))
|
||||
|
||||
(defn show-file-menu
|
||||
[]
|
||||
(ptk/reify ::show-file-menu
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(update state :dashboard-local
|
||||
assoc :menu-open true))))
|
||||
|
||||
(defn hide-file-menu
|
||||
[]
|
||||
(ptk/reify ::hide-file-menu
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(update state :dashboard-local
|
||||
assoc :menu-open false))))
|
||||
|
||||
(defn start-edit-file-name
|
||||
[file-id]
|
||||
(ptk/reify ::start-edit-file-menu
|
||||
|
||||
@@ -124,8 +124,7 @@
|
||||
(defn- await-font-faces
|
||||
"Waits for missing WASM faces, then resizes the affected texts."
|
||||
[stream face-keys ids]
|
||||
(let [resize-opts {:stack-undo? true :undo-transation? false}
|
||||
resize-stream (->> (rx/from ids) (rx/map #(dwwt/resize-wasm-text % resize-opts)))]
|
||||
(let [resize-stream (->> (rx/from ids) (rx/map dwwt/resize-wasm-text))]
|
||||
(if (empty? face-keys)
|
||||
resize-stream
|
||||
(->> (rx/merge wasm.fonts/font-stored-stream
|
||||
|
||||
@@ -79,26 +79,20 @@
|
||||
|
||||
(defn resize-wasm-text
|
||||
"Resize a single text shape (auto-width/auto-height) by id.
|
||||
No-op if the id is not a text shape or is :fixed.
|
||||
`opts` are forwarded to `apply-wasm-modifiers`, so a caller whose undo
|
||||
transaction is already closed when the resize lands can still get the
|
||||
geometry into the right undo entry."
|
||||
([id]
|
||||
(resize-wasm-text id nil))
|
||||
([id opts]
|
||||
(ptk/reify ::resize-wasm-text
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [objects (dsh/lookup-page-objects state)
|
||||
shape (get objects id)
|
||||
apply-opts (or opts {})
|
||||
resize-stream
|
||||
(if (and (some? shape)
|
||||
(cfh/text-shape? shape)
|
||||
(not= :fixed (:grow-type shape)))
|
||||
(rx/of (dwm/apply-wasm-modifiers (resize-wasm-text-modifiers shape) apply-opts))
|
||||
(rx/empty))]
|
||||
(wrf/with-pending :text-resize [id] resize-stream))))))
|
||||
No-op if the id is not a text shape or is :fixed."
|
||||
[id]
|
||||
(ptk/reify ::resize-wasm-text
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [objects (dsh/lookup-page-objects state)
|
||||
shape (get objects id)
|
||||
resize-stream
|
||||
(if (and (some? shape)
|
||||
(cfh/text-shape? shape)
|
||||
(not= :fixed (:grow-type shape)))
|
||||
(rx/of (dwm/apply-wasm-modifiers (resize-wasm-text-modifiers shape)))
|
||||
(rx/empty))]
|
||||
(wrf/with-pending :text-resize [id] resize-stream)))))
|
||||
|
||||
(defn- merge-resize-debounce-opts
|
||||
[prev {:keys [undo-group undo-id skip-component-sync?]}]
|
||||
|
||||
@@ -16,12 +16,10 @@
|
||||
[app.main.repo :as rp]
|
||||
[app.main.router :as rt]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.components.context-menu-a11y :refer [context-menu*]]
|
||||
[app.main.ui.context :as ctx]
|
||||
[app.util.dom :as dom]
|
||||
[app.main.ui.ds.layout.menu :refer [menu* menu-item* menu-separator* sub-menu*]]
|
||||
[app.util.i18n :as i18n :refer [tr]]
|
||||
[beicon.v2.core :as rx]
|
||||
[potok.v2.core :as ptk]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(defn- get-project-name
|
||||
@@ -55,25 +53,64 @@
|
||||
{}
|
||||
projects))
|
||||
|
||||
(mf/defc file-menu*
|
||||
[{:keys [files on-edit on-close top left navigate origin parent-id can-edit can-restore]}]
|
||||
;; The "move to" tree can be arbitrarily deep (current team's projects, then
|
||||
;; every other team's own projects), so every level here uses SubMenu's
|
||||
;; drilldown variant instead of a flyout: opening a chain of flyouts that
|
||||
;; deep would run off-screen well before it ran out of teams.
|
||||
(mf/defc move-to-items*
|
||||
{::mf/private true}
|
||||
[{:keys [current-projects other-teams current-team-id on-move]}]
|
||||
[:*
|
||||
(for [project current-projects]
|
||||
[:> menu-item* {:key (get-project-id project)
|
||||
:id (get-project-id project)
|
||||
:on-action (on-move current-team-id (:id project))}
|
||||
(get-project-name project)])
|
||||
|
||||
(when (seq other-teams)
|
||||
[:> sub-menu* {:key "move-to-other-team"
|
||||
:id "move-to-other-team"
|
||||
:trigger (tr "dashboard.move-to-other-team")
|
||||
:variant "drilldown"}
|
||||
(for [team other-teams]
|
||||
[:> sub-menu* {:key (get-project-id team)
|
||||
:id (get-project-id team)
|
||||
:trigger (get-team-name team)
|
||||
:variant "drilldown"}
|
||||
(for [sub-project (:projects team)]
|
||||
[:> menu-item* {:key (get-project-id sub-project)
|
||||
:id (get-project-id sub-project)
|
||||
:on-action (on-move (:id team) (:id sub-project))}
|
||||
(get-project-name sub-project)])])])])
|
||||
|
||||
;; The menu items only, with no popover of their own — shared by file-menu*
|
||||
;; below (opened from the "..." button, via Menu) and by grid.cljs's own
|
||||
;; right-click handling (via ContextMenu), so both triggers show the exact
|
||||
;; same options.
|
||||
;; The popover this renders inside only mounts file-menu-items* while it's
|
||||
;; open, so a plain component-local fetch would re-run — and start every
|
||||
;; single open from an empty "Move to" state — every time. Caching the last
|
||||
;; response here means only the first open of a session (per tab) pays that
|
||||
;; cost; later opens render with the previous list immediately while a
|
||||
;; fresh fetch updates it in the background.
|
||||
(defonce ^:private teams-cache (atom nil))
|
||||
|
||||
(mf/defc file-menu-items*
|
||||
[{:keys [files on-edit navigate origin can-edit can-restore]}]
|
||||
|
||||
(assert (seq files) "missing `files` prop")
|
||||
(assert (fn? on-edit) "missing `on-edit` prop")
|
||||
(assert (fn? on-close) "missing `on-close` prop")
|
||||
(assert (boolean? navigate) "missing `navigate` prop")
|
||||
|
||||
(let [is-lib-page? (= :libraries origin)
|
||||
is-search-page? (= :search origin)
|
||||
top (or top 0)
|
||||
left (or left 0)
|
||||
|
||||
file (first files)
|
||||
file-count (count files)
|
||||
multi? (> file-count 1)
|
||||
|
||||
current-team-id (mf/use-ctx ctx/current-team-id)
|
||||
teams* (mf/use-state nil)
|
||||
teams* (mf/use-state #(deref teams-cache))
|
||||
teams (deref teams*)
|
||||
|
||||
current-team (get teams current-team-id)
|
||||
@@ -83,13 +120,13 @@
|
||||
(:projects current-team))
|
||||
|
||||
on-new-tab
|
||||
(fn [_]
|
||||
(fn []
|
||||
(st/emit! (dcm/go-to-workspace
|
||||
{:file-id (:id file)
|
||||
::rt/new-window true})))
|
||||
|
||||
on-duplicate
|
||||
(fn [_]
|
||||
(fn []
|
||||
(apply st/emit! (map dd/duplicate-file files))
|
||||
(st/emit! (ntf/success (tr "dashboard.success-duplicate-file" (i18n/c file-count)))))
|
||||
|
||||
@@ -100,8 +137,7 @@
|
||||
(dd/clear-selected-files)))
|
||||
|
||||
on-delete
|
||||
(fn [event]
|
||||
(dom/stop-propagation event)
|
||||
(fn []
|
||||
(let [num-shared (filter #(:is-shared %) files)]
|
||||
|
||||
(if (< 0 (count num-shared))
|
||||
@@ -149,7 +185,6 @@
|
||||
(let [params {:ids (into #{} (map :id) files)
|
||||
:project-id project-id}]
|
||||
(fn []
|
||||
|
||||
(let [num-shared (filter #(:is-shared %) files)]
|
||||
(if (and (< 0 (count num-shared))
|
||||
(not= team-id current-team-id))
|
||||
@@ -171,14 +206,11 @@
|
||||
(run! #(st/emit! (dd/set-file-shared (assoc % :is-shared false))) files))
|
||||
|
||||
on-add-shared
|
||||
(fn [event]
|
||||
(dom/stop-propagation event)
|
||||
(fn []
|
||||
(st/emit! (dcm/show-shared-dialog (:id file) add-shared)))
|
||||
|
||||
on-del-shared
|
||||
(fn [event]
|
||||
(dom/prevent-default event)
|
||||
(dom/stop-propagation event)
|
||||
(fn []
|
||||
(st/emit! (modal/show
|
||||
{:type :delete-shared-libraries
|
||||
:origin :unpublish
|
||||
@@ -226,124 +258,99 @@
|
||||
(mf/with-effect []
|
||||
(->> (rp/cmd! :get-all-projects)
|
||||
(rx/map group-by-team)
|
||||
(rx/subs! #(reset! teams* %))))
|
||||
(rx/subs! #(do (reset! teams-cache %)
|
||||
(reset! teams* %)))))
|
||||
|
||||
(mf/with-effect [on-close]
|
||||
(st/emit! (ptk/data-event :dropdown/open {:id "file-menu"}))
|
||||
(let [stream (->> st/stream
|
||||
(rx/filter (ptk/type? :dropdown/open))
|
||||
(rx/map deref)
|
||||
(rx/filter #(not= "file-menu" (:id %)))
|
||||
(rx/take 1))
|
||||
subs (rx/subs! nil nil on-close stream)]
|
||||
(fn []
|
||||
(rx/dispose! subs))))
|
||||
(cond
|
||||
can-restore
|
||||
[:*
|
||||
[:> menu-item* {:id "restore-file" :on-action on-restore-immediately}
|
||||
(tr "dashboard.file-menu.restore-files-option" (i18n/c file-count))]
|
||||
[:> menu-item* {:id "delete-file" :on-action on-delete-immediately}
|
||||
(tr "dashboard.file-menu.delete-files-permanently-option" (i18n/c file-count))]]
|
||||
|
||||
(let [sub-options
|
||||
(concat
|
||||
(for [project current-projects]
|
||||
{:name (get-project-name project)
|
||||
:id (get-project-id project)
|
||||
:handler (on-move current-team-id (:id project))})
|
||||
(when (seq other-teams)
|
||||
[{:name (tr "dashboard.move-to-other-team")
|
||||
:id "move-to-other-team"
|
||||
:options
|
||||
(for [team other-teams]
|
||||
{:name (get-team-name team)
|
||||
:id (get-project-id team)
|
||||
:options
|
||||
(for [sub-project (:projects team)]
|
||||
{:name (get-project-name sub-project)
|
||||
:id (get-project-id sub-project)
|
||||
:handler (on-move (:id team)
|
||||
(:id sub-project))})})}]))
|
||||
multi?
|
||||
[:*
|
||||
(when can-edit
|
||||
[:> menu-item* {:id "duplicate-multi" :on-action on-duplicate}
|
||||
(tr "dashboard.duplicate-multi" file-count)])
|
||||
|
||||
options
|
||||
(if can-restore
|
||||
[{:name (tr "dashboard.file-menu.restore-files-option" (i18n/c file-count))
|
||||
:id "restore-file"
|
||||
:handler on-restore-immediately}
|
||||
{:name (tr "dashboard.file-menu.delete-files-permanently-option" (i18n/c file-count))
|
||||
:id "delete-file"
|
||||
:handler on-delete-immediately}]
|
||||
(if multi?
|
||||
[(when can-edit
|
||||
{:name (tr "dashboard.duplicate-multi" file-count)
|
||||
:id "duplicate-multi"
|
||||
:handler on-duplicate})
|
||||
(when (and (or (seq current-projects) (seq other-teams)) can-edit)
|
||||
[:> sub-menu* {:id "file-move-multi" :trigger (tr "dashboard.move-to-multi" file-count) :variant "drilldown"}
|
||||
[:> move-to-items* {:current-projects current-projects
|
||||
:other-teams other-teams
|
||||
:current-team-id current-team-id
|
||||
:on-move on-move}]])
|
||||
|
||||
(when (and (or (seq current-projects) (seq other-teams)) can-edit)
|
||||
{:name (tr "dashboard.move-to-multi" file-count)
|
||||
:id "file-move-multi"
|
||||
:options sub-options})
|
||||
[:> menu-item* {:id "file-binary-export-multi" :on-action on-export-binary-files}
|
||||
(tr "dashboard.export-binary-multi" file-count)]
|
||||
|
||||
{:name (tr "dashboard.export-binary-multi" file-count)
|
||||
:id "file-binary-export-multi"
|
||||
:handler on-export-binary-files}
|
||||
(when (and (:is-shared file) can-edit)
|
||||
[:> menu-item* {:id "file-unpublish-multi" :on-action on-del-shared}
|
||||
(tr "labels.unpublish-multi-files" file-count)])
|
||||
|
||||
(when (and (:is-shared file) can-edit)
|
||||
{:name (tr "labels.unpublish-multi-files" file-count)
|
||||
:id "file-unpublish-multi"
|
||||
:handler on-del-shared})
|
||||
(when (and (not is-lib-page?) can-edit)
|
||||
[:*
|
||||
[:> menu-separator*]
|
||||
[:> menu-item* {:id "file-delete-multi" :on-action on-delete}
|
||||
(tr "labels.delete-multi-files" file-count)]])]
|
||||
|
||||
(when (and (not is-lib-page?) can-edit)
|
||||
{:name :separator}
|
||||
{:name (tr "labels.delete-multi-files" file-count)
|
||||
:id "file-delete-multi"
|
||||
:handler on-delete})]
|
||||
:else
|
||||
[:*
|
||||
[:> menu-item* {:id "file-open-new-tab" :on-action on-new-tab}
|
||||
(tr "dashboard.open-in-new-tab")]
|
||||
|
||||
[{:name (tr "dashboard.open-in-new-tab")
|
||||
:id "file-open-new-tab"
|
||||
:handler on-new-tab}
|
||||
(when (and (not is-search-page?) can-edit)
|
||||
{:name (tr "labels.rename")
|
||||
:id "file-rename"
|
||||
:handler on-edit})
|
||||
(when (and (not is-search-page?) can-edit)
|
||||
[:> menu-item* {:id "file-rename" :on-action on-edit}
|
||||
(tr "labels.rename")])
|
||||
|
||||
(when (and (not is-search-page?) can-edit)
|
||||
{:name (tr "dashboard.duplicate")
|
||||
:id "file-duplicate"
|
||||
:handler on-duplicate})
|
||||
(when (and (not is-search-page?) can-edit)
|
||||
[:> menu-item* {:id "file-duplicate" :on-action on-duplicate}
|
||||
(tr "dashboard.duplicate")])
|
||||
|
||||
(when (and (not is-lib-page?)
|
||||
(not is-search-page?)
|
||||
(or (seq current-projects) (seq other-teams))
|
||||
can-edit)
|
||||
{:name (tr "dashboard.move-to")
|
||||
:id "file-move-to"
|
||||
:options sub-options})
|
||||
(when (and (not is-lib-page?)
|
||||
(not is-search-page?)
|
||||
(or (seq current-projects) (seq other-teams))
|
||||
can-edit)
|
||||
[:> sub-menu* {:id "file-move-to" :trigger (tr "dashboard.move-to") :variant "drilldown"}
|
||||
[:> move-to-items* {:current-projects current-projects
|
||||
:other-teams other-teams
|
||||
:current-team-id current-team-id
|
||||
:on-move on-move}]])
|
||||
|
||||
(when (and (not is-search-page?)
|
||||
can-edit)
|
||||
(if (:is-shared file)
|
||||
{:name (tr "dashboard.unpublish-shared")
|
||||
:id "file-del-shared"
|
||||
:handler on-del-shared}
|
||||
{:name (tr "dashboard.add-shared")
|
||||
:id "file-add-shared"
|
||||
:handler on-add-shared}))
|
||||
(when (and (not is-search-page?) can-edit)
|
||||
;; Same id in both branches: :is-shared can flip while this menu
|
||||
;; instance stays mounted (the on-add-shared/on-del-shared action
|
||||
;; itself changes it), and react-stately's Collection requires an
|
||||
;; item's id to stay stable across such an update rather than swap
|
||||
;; to a differently-id'd item in the same slot.
|
||||
(if (:is-shared file)
|
||||
[:> menu-item* {:id "file-shared-toggle" :on-action on-del-shared}
|
||||
(tr "dashboard.unpublish-shared")]
|
||||
[:> menu-item* {:id "file-shared-toggle" :on-action on-add-shared}
|
||||
(tr "dashboard.add-shared")]))
|
||||
|
||||
{:name :separator}
|
||||
[:> menu-separator*]
|
||||
|
||||
{:name (tr "dashboard.download-binary-file")
|
||||
:id "download-binary-file"
|
||||
:handler on-export-binary-files}
|
||||
[:> menu-item* {:id "download-binary-file" :on-action on-export-binary-files}
|
||||
(tr "dashboard.download-binary-file")]
|
||||
|
||||
(when (and (not is-lib-page?) (not is-search-page?) can-edit)
|
||||
{:name :separator})
|
||||
(when (and (not is-lib-page?) (not is-search-page?) can-edit)
|
||||
[:*
|
||||
[:> menu-separator*]
|
||||
[:> menu-item* {:id "file-delete" :on-action on-delete}
|
||||
(tr "labels.delete")]])])))
|
||||
|
||||
(when (and (not is-lib-page?) (not is-search-page?) can-edit)
|
||||
{:name (tr "labels.delete")
|
||||
:id "file-delete"
|
||||
:handler on-delete})]))]
|
||||
|
||||
[:> context-menu*
|
||||
{:on-close on-close
|
||||
:fixed (or (not= top 0) (not= left 0))
|
||||
:show true
|
||||
:min-width true
|
||||
:top top
|
||||
:left left
|
||||
:options options
|
||||
:origin parent-id}])))
|
||||
(mf/defc file-menu*
|
||||
[{:keys [files on-edit is-open on-open-change trigger navigate origin can-edit can-restore]}]
|
||||
[:> menu*
|
||||
{:is-open is-open
|
||||
:on-open-change on-open-change
|
||||
:placement "bottom end"
|
||||
:trigger trigger}
|
||||
[:> file-menu-items* {:files files
|
||||
:on-edit on-edit
|
||||
:navigate navigate
|
||||
:origin origin
|
||||
:can-edit can-edit
|
||||
:can-restore can-restore}]])
|
||||
@@ -9,7 +9,6 @@
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.logging :as log]
|
||||
[app.common.time :as ct]
|
||||
[app.config :as cf]
|
||||
@@ -26,12 +25,12 @@
|
||||
[app.main.repo :as rp]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.components.color-bullet :as bc]
|
||||
[app.main.ui.components.portal :refer [portal-on-document*]]
|
||||
[app.main.ui.dashboard.file-menu :refer [file-menu*]]
|
||||
[app.main.ui.dashboard.file-menu :refer [file-menu* file-menu-items*]]
|
||||
[app.main.ui.dashboard.import :refer [use-import-file]]
|
||||
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
|
||||
[app.main.ui.dashboard.placeholder :refer [empty-grid-placeholder* loading-placeholder*]]
|
||||
[app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]]
|
||||
[app.main.ui.ds.layout.menu :refer [context-menu*]]
|
||||
[app.main.ui.ds.product.loader :refer [loader*]]
|
||||
[app.main.ui.hooks :as h]
|
||||
[app.main.worker :as mw]
|
||||
@@ -269,9 +268,16 @@
|
||||
|
||||
file-id (get file :id)
|
||||
|
||||
menu-pos (get state :menu-pos)
|
||||
menu-open? (and (get state :menu-open)
|
||||
(= file-id (:file-id state)))
|
||||
menu-open* (mf/use-state false)
|
||||
menu-open? (deref menu-open*)
|
||||
|
||||
;; The menu can open before this file has actually been added to
|
||||
;; selected-files yet (the very first click/right-click on it both
|
||||
;; selects it and opens the menu in the same event, but selection is
|
||||
;; applied via a store dispatch that only lands on the next render)
|
||||
;; — fall back to just this file so file-menu-items* never sees an
|
||||
;; empty list.
|
||||
menu-files (if (seq selected-files) (vals selected-files) [file])
|
||||
|
||||
selected? (contains? selected-files file-id)
|
||||
selected-num (count selected-files)
|
||||
@@ -283,10 +289,6 @@
|
||||
|
||||
library-view? (= origin :libraries)
|
||||
|
||||
on-menu-close
|
||||
(mf/use-fn
|
||||
#(st/emit! (dd/hide-file-menu)))
|
||||
|
||||
on-select
|
||||
(mf/use-fn
|
||||
(mf/deps selected? selected-num)
|
||||
@@ -312,7 +314,7 @@
|
||||
(mf/use-fn
|
||||
(mf/deps selected? selected-num)
|
||||
(fn [event]
|
||||
(st/emit! (dd/hide-file-menu))
|
||||
(reset! menu-open* false)
|
||||
(when can-edit
|
||||
(let [offset (dom/get-offset-position (dom/event->native-event event))
|
||||
item-el (mf/ref-val node-ref)
|
||||
@@ -341,39 +343,23 @@
|
||||
|
||||
on-menu-click
|
||||
(mf/use-fn
|
||||
(mf/deps file selected? menu-open?)
|
||||
(mf/deps file selected?)
|
||||
(fn [event]
|
||||
(dom/stop-propagation event)
|
||||
|
||||
(if menu-open?
|
||||
(st/emit! (dd/hide-file-menu))
|
||||
|
||||
(do
|
||||
(when-not selected?
|
||||
(when-not (kbd/shift? event)
|
||||
(st/emit! (dd/clear-selected-files)))
|
||||
(st/emit! (dd/toggle-file-select file)))
|
||||
|
||||
(let [client-position
|
||||
(dom/get-client-position event)
|
||||
|
||||
position
|
||||
(if (and (nil? (:y client-position)) (nil? (:x client-position)))
|
||||
(let [target-element (dom/get-target event)
|
||||
points (dom/get-bounding-rect target-element)
|
||||
y (:top points)
|
||||
x (:left points)]
|
||||
(gpt/point x y))
|
||||
client-position)]
|
||||
|
||||
(st/emit! (dd/show-file-menu-with-position file-id position)))))))
|
||||
(when-not selected?
|
||||
(when-not (kbd/shift? event)
|
||||
(st/emit! (dd/clear-selected-files)))
|
||||
(st/emit! (dd/toggle-file-select file)))
|
||||
(swap! menu-open* not)))
|
||||
|
||||
on-context-menu
|
||||
(mf/use-fn
|
||||
(mf/deps on-menu-click)
|
||||
(mf/deps file selected?)
|
||||
(fn [event]
|
||||
(dom/prevent-default event)
|
||||
(on-menu-click event)))
|
||||
(when-not selected?
|
||||
(when-not (kbd/shift? event)
|
||||
(st/emit! (dd/clear-selected-files)))
|
||||
(st/emit! (dd/toggle-file-select file)))))
|
||||
|
||||
edit
|
||||
(mf/use-fn
|
||||
@@ -386,9 +372,8 @@
|
||||
|
||||
on-edit
|
||||
(mf/use-fn
|
||||
(mf/deps file)
|
||||
(fn [event]
|
||||
(dom/stop-propagation event)
|
||||
(mf/deps file-id)
|
||||
(fn []
|
||||
(st/emit! (dd/start-edit-file-name file-id))))
|
||||
|
||||
on-key-down
|
||||
@@ -422,104 +407,116 @@
|
||||
(mf/html
|
||||
[:div {:class (stl/css-case :project-thumbnail-actions true
|
||||
:is-force-display menu-open?)}
|
||||
[:div {:class (stl/css :project-thumbnail-icon :menu)
|
||||
:tab-index "0"
|
||||
:role "button"
|
||||
:aria-label (tr "dashboard.options")
|
||||
:ref menu-ref
|
||||
:id (dm/str file-id "-action-menu")
|
||||
:on-click on-menu-click
|
||||
:on-key-down on-menu-key-down}
|
||||
|
||||
[:> icon* {:icon-id i/menu
|
||||
:class (stl/css :menu-icon)}]
|
||||
|
||||
(when (and selected? menu-open?)
|
||||
;; When the menu is open we disable events in the dashboard. We need to force pointer events
|
||||
;; so the menu can be handled
|
||||
[:> portal-on-document* {}
|
||||
[:> file-menu* {:files (vals selected-files)
|
||||
:left (+ 24 (:x menu-pos))
|
||||
:top (:y menu-pos)
|
||||
:can-edit can-edit
|
||||
:navigate true
|
||||
:on-edit on-edit
|
||||
:on-close on-menu-close
|
||||
:origin origin
|
||||
:parent-id (dm/str file-id "-action-menu")
|
||||
:can-restore can-restore}]])]])]
|
||||
[:> file-menu* {:files menu-files
|
||||
:is-open menu-open?
|
||||
:on-open-change #(reset! menu-open* %)
|
||||
:can-edit can-edit
|
||||
:navigate true
|
||||
:on-edit on-edit
|
||||
:origin origin
|
||||
:can-restore can-restore
|
||||
:trigger
|
||||
(mf/html
|
||||
[:div {:class (stl/css :project-thumbnail-icon :menu)
|
||||
:tab-index "0"
|
||||
:role "button"
|
||||
:aria-label (tr "dashboard.options")
|
||||
:ref menu-ref
|
||||
:id (dm/str file-id "-action-menu")
|
||||
:on-click on-menu-click
|
||||
:on-key-down on-menu-key-down}
|
||||
[:> icon* {:icon-id i/menu
|
||||
:class (stl/css :menu-icon)}]])}]])]
|
||||
|
||||
(if ^boolean list?
|
||||
[:li {:class (stl/css-case :grid-item true
|
||||
:list-item true
|
||||
:library-item library-view?)}
|
||||
[:div
|
||||
{:class (stl/css-case :list-item-row true
|
||||
:is-selected selected?)
|
||||
:ref node-ref
|
||||
:role "button"
|
||||
:title (:name file)
|
||||
:aria-label (:name file)
|
||||
:draggable (dm/str can-edit)
|
||||
:on-click on-select
|
||||
:on-key-down on-key-down
|
||||
:on-double-click on-navigate
|
||||
:on-drag-start on-drag-start
|
||||
:on-context-menu on-context-menu}
|
||||
[:> context-menu* {:aria-label (tr "dashboard.options")
|
||||
:trigger
|
||||
(mf/html
|
||||
[:div
|
||||
{:class (stl/css-case :list-item-row true
|
||||
:is-selected selected?)
|
||||
:ref node-ref
|
||||
:role "button"
|
||||
:title (:name file)
|
||||
:aria-label (:name file)
|
||||
:draggable (dm/str can-edit)
|
||||
:on-click on-select
|
||||
:on-key-down on-key-down
|
||||
:on-double-click on-navigate
|
||||
:on-drag-start on-drag-start
|
||||
:on-context-menu on-context-menu}
|
||||
|
||||
(if ^boolean editing?
|
||||
[:& inline-edition {:content (:name file)
|
||||
:on-end edit
|
||||
:max-length 250}]
|
||||
[:h3 {:class (stl/css :list-item-name)} (:name file)])
|
||||
(if ^boolean editing?
|
||||
[:& inline-edition {:content (:name file)
|
||||
:on-end edit
|
||||
:max-length 250}]
|
||||
[:h3 {:class (stl/css :list-item-name)} (:name file)])
|
||||
|
||||
(when (and (:is-shared file) (not library-view?))
|
||||
[:span {:class (stl/css :list-item-badge)
|
||||
:aria-label (tr "workspace.assets.shared-library")
|
||||
:title (tr "workspace.assets.shared-library")}
|
||||
[:> icon* {:icon-id i/library}]])
|
||||
(when (and (:is-shared file) (not library-view?))
|
||||
[:span {:class (stl/css :list-item-badge)
|
||||
:aria-label (tr "workspace.assets.shared-library")
|
||||
:title (tr "workspace.assets.shared-library")}
|
||||
[:> icon* {:icon-id i/library}]])
|
||||
|
||||
[:> grid-item-metadata* {:file file :layout :list}]
|
||||
[:> grid-item-metadata* {:file file :layout :list}]
|
||||
|
||||
menu-element]]
|
||||
menu-element])}
|
||||
[:> file-menu-items* {:files menu-files
|
||||
:can-edit can-edit
|
||||
:navigate true
|
||||
:on-edit on-edit
|
||||
:origin origin
|
||||
:can-restore can-restore}]]]
|
||||
|
||||
[:li {:class (stl/css-case :grid-item true
|
||||
:project-thumbnail true
|
||||
:library-item library-view?)}
|
||||
[:div {:class (stl/css-case :is-selected selected?
|
||||
:grid-item-button true)
|
||||
:ref node-ref
|
||||
:role "button"
|
||||
:title (:name file)
|
||||
:aria-label (:name file)
|
||||
:draggable (dm/str can-edit)
|
||||
:on-click on-select
|
||||
:on-key-down on-key-down
|
||||
:on-double-click on-navigate
|
||||
:on-drag-start on-drag-start
|
||||
:on-context-menu on-context-menu}
|
||||
[:> context-menu* {:aria-label (tr "dashboard.options")
|
||||
:trigger
|
||||
(mf/html
|
||||
[:div {:class (stl/css-case :is-selected selected?
|
||||
:grid-item-button true)
|
||||
:ref node-ref
|
||||
:role "button"
|
||||
:title (:name file)
|
||||
:aria-label (:name file)
|
||||
:draggable (dm/str can-edit)
|
||||
:on-click on-select
|
||||
:on-key-down on-key-down
|
||||
:on-double-click on-navigate
|
||||
:on-drag-start on-drag-start
|
||||
:on-context-menu on-context-menu}
|
||||
|
||||
(if ^boolean library-view?
|
||||
[:> grid-item-library* {:file file
|
||||
:can-restore can-restore}]
|
||||
[:> grid-item-thumbnail* {:file file
|
||||
:can-edit can-edit
|
||||
:can-restore can-restore}])
|
||||
(if ^boolean library-view?
|
||||
[:> grid-item-library* {:file file
|
||||
:can-restore can-restore}]
|
||||
[:> grid-item-thumbnail* {:file file
|
||||
:can-edit can-edit
|
||||
:can-restore can-restore}])
|
||||
|
||||
(when (and (:is-shared file) (not library-view?))
|
||||
[:div {:class (stl/css :grid-item-badge)}
|
||||
[:> icon* {:icon-id i/library}]])
|
||||
(when (and (:is-shared file) (not library-view?))
|
||||
[:div {:class (stl/css :grid-item-badge)}
|
||||
[:> icon* {:icon-id i/library}]])
|
||||
|
||||
[:div {:class (stl/css :grid-item-info)}
|
||||
[:div {:class (stl/css :grid-item-meta)}
|
||||
(if ^boolean editing?
|
||||
[:& inline-edition {:content (:name file)
|
||||
:on-end edit
|
||||
:max-length 250}]
|
||||
[:h3 {:class (stl/css :grid-item-title)} (:name file)])
|
||||
[:> grid-item-metadata* {:file file :layout :grid}]]
|
||||
[:div {:class (stl/css :grid-item-info)}
|
||||
[:div {:class (stl/css :grid-item-meta)}
|
||||
(if ^boolean editing?
|
||||
[:& inline-edition {:content (:name file)
|
||||
:on-end edit
|
||||
:max-length 250}]
|
||||
[:h3 {:class (stl/css :grid-item-title)} (:name file)])
|
||||
[:> grid-item-metadata* {:file file :layout :grid}]]
|
||||
|
||||
menu-element]]])))
|
||||
menu-element]])}
|
||||
[:> file-menu-items* {:files menu-files
|
||||
:can-edit can-edit
|
||||
:navigate true
|
||||
:on-edit on-edit
|
||||
:origin origin
|
||||
:can-restore can-restore}]]])))
|
||||
|
||||
(mf/defc grid*
|
||||
[{:keys [files project origin limit create-fn can-edit selected-files can-restore layout]}]
|
||||
@@ -541,9 +538,6 @@
|
||||
import-files
|
||||
(use-import-file project-id on-finish-import)
|
||||
|
||||
on-scroll
|
||||
(mf/use-fn #(st/emit! (dd/hide-file-menu)))
|
||||
|
||||
on-drag-enter
|
||||
(mf/use-fn
|
||||
(fn [e]
|
||||
@@ -585,7 +579,6 @@
|
||||
:on-drag-over on-drag-over
|
||||
:on-drag-leave on-drag-leave
|
||||
:on-drop on-drop
|
||||
:on-scroll on-scroll
|
||||
:ref node-ref}
|
||||
(cond
|
||||
(nil? files)
|
||||
|
||||
@@ -198,6 +198,15 @@ $thumbnail-default-height: px2rem(168);
|
||||
&.is-force-display {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// The DS Menu component wraps its trigger in its own div with
|
||||
// align-self: start (so it shrink-wraps instead of stretching in an
|
||||
// arbitrary parent) — override that back to center here, since this
|
||||
// container's own align-items: center above has no effect on a child
|
||||
// that sets its own align-self.
|
||||
> div {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
.project-thumbnail-icon {
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
[app.main.ui.ds.foundations.typography.text :refer [text*]]
|
||||
[app.main.ui.ds.foundations.utilities.token.token-status :refer [token-status-icon*
|
||||
token-status-list]]
|
||||
[app.main.ui.ds.layout.menu :refer [menu* menu-item* menu-separator* sub-menu* context-menu*]]
|
||||
[app.main.ui.ds.layout.modal :refer [modal* modal-header* modal-content* modal-footer*]]
|
||||
[app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]]
|
||||
[app.main.ui.ds.notifications.actionable :refer [actionable*]]
|
||||
@@ -88,6 +89,11 @@
|
||||
:ModalHeader modal-header*
|
||||
:ModalContent modal-content*
|
||||
:ModalFooter modal-footer*
|
||||
:Menu menu*
|
||||
:MenuItem menu-item*
|
||||
:MenuSeparator menu-separator*
|
||||
:SubMenu sub-menu*
|
||||
:ContextMenu context-menu*
|
||||
|
||||
:set-default-translations
|
||||
(fn [data]
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
{ /* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
Copyright (c) KALEIDOS SUBSIDIARY SL */ }
|
||||
import { Canvas, Meta } from "@storybook/addon-docs/blocks";
|
||||
import * as ContextMenu from "./context_menu.stories";
|
||||
|
||||
<Meta title="Layout/Context Menu" />
|
||||
|
||||
# Context Menu
|
||||
|
||||
A context menu displays a list of actions or options tied to a specific area of the interface, opened with a right click (or long press on touch) instead of clicking a visible trigger button. It's positioned at the pointer, growing down and to the right when there's room, and flipping to grow upward when it isn't.
|
||||
|
||||
## Example
|
||||
|
||||
### Default
|
||||
|
||||
<Canvas of={ContextMenu.Default} />
|
||||
|
||||
---
|
||||
|
||||
# Usage
|
||||
|
||||
```clojure
|
||||
[:> context-menu*
|
||||
{:aria-label (tr "workspace.shape.menu.title")
|
||||
:on-action (fn [key] (handle-action key))
|
||||
:trigger [:> layer-row* {:shape shape}]}
|
||||
|
||||
[:> menu-item* {:id "rename"} "Rename"]
|
||||
[:> menu-item* {:id "duplicate"} "Duplicate"]
|
||||
[:> menu-separator*]
|
||||
[:> menu-item* {:id "delete"} "Delete"]]
|
||||
```
|
||||
|
||||
`trigger` is the area that responds to a right click — it can be any content, not just a button; it's rendered as-is (no extra box around it) and only gains a right-click listener. The menu itself is entirely self-contained: opening, positioning, and closing (selection, **Escape**, outside click) are all handled internally, no `is-open` plumbing required from the caller.
|
||||
|
||||
---
|
||||
|
||||
# Context menu props
|
||||
## trigger
|
||||
|
||||
The area that opens the menu on right click. Rendered without adding any wrapping box to the layout.
|
||||
|
||||
Type: React element
|
||||
|
||||
## aria-label
|
||||
|
||||
Accessible name for the menu, read by screen readers. Required — a context menu has no visible trigger button to derive a label from.
|
||||
|
||||
Type: string
|
||||
|
||||
## on-action
|
||||
|
||||
Callback invoked with the selected item's `id` when an item is chosen.
|
||||
|
||||
Type: function
|
||||
|
||||
## placement
|
||||
|
||||
Controls where the menu grows from the click point.
|
||||
|
||||
Options
|
||||
|
||||
"top", "top start", "top end", "bottom", "bottom start" (default), "bottom end", "left", "left top", "left bottom", "right", "right top", "right bottom"
|
||||
|
||||
The menu flips to the opposite vertical side automatically when there isn't enough room in the preferred direction — e.g. `"bottom start"` (grows right and down) becomes `"top start"` (grows right and up) near the bottom of the viewport.
|
||||
|
||||
## is-disabled
|
||||
|
||||
Prevents the context menu from opening.
|
||||
|
||||
Default: false
|
||||
|
||||
## class
|
||||
|
||||
Additional CSS class applied to the menu.
|
||||
|
||||
---
|
||||
|
||||
# MenuItem props
|
||||
|
||||
Same as the [Menu](/docs/layout-menu--docs) component's `menu-item*` and `menu-separator*`.
|
||||
|
||||
---
|
||||
|
||||
# Accessibility
|
||||
|
||||
The context menu automatically provides:
|
||||
|
||||
- Accessible `menu`/`menuitem` semantics
|
||||
- Full keyboard navigation (arrow keys, Home/End, typeahead)
|
||||
- Focus management and restoration when closed
|
||||
- Dismissal with **Escape** and outside click
|
||||
- Long-press support on touch devices, where right click doesn't exist
|
||||
|
||||
---
|
||||
|
||||
# Best practices
|
||||
|
||||
Use a context menu for:
|
||||
|
||||
- Actions tied to a specific item (a layer, a file, a row) that don't need a persistently visible trigger
|
||||
|
||||
Avoid using a context menu for:
|
||||
|
||||
- The *only* way to reach an action — right click isn't discoverable; pair it with a visible menu trigger (see [Menu](/docs/layout-menu--docs)) or keyboard shortcut for the same actions when possible
|
||||
- A long list of unrelated actions — keep it scoped to the item that was clicked
|
||||
@@ -0,0 +1,85 @@
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
import * as React from "react";
|
||||
import Components from "@target/components";
|
||||
|
||||
const { ContextMenu, MenuItem, MenuSeparator } = Components;
|
||||
|
||||
const ContextMenuWrapper = ({ children, ...props }) => {
|
||||
return (
|
||||
<ContextMenu
|
||||
{...props}
|
||||
trigger={
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
inlineSize: "16rem",
|
||||
blockSize: "10rem",
|
||||
border: "1px dashed var(--color-background-quaternary)",
|
||||
borderRadius: "8px",
|
||||
color: "var(--color-foreground-secondary)",
|
||||
}}
|
||||
>
|
||||
Right click here
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
title: "Layout/Context Menu",
|
||||
component: ContextMenuWrapper,
|
||||
args: {
|
||||
"aria-label": "Item actions",
|
||||
placement: "bottom start",
|
||||
onAction: (key) => console.log("action", key),
|
||||
children: (
|
||||
<>
|
||||
<MenuItem id="rename">Rename</MenuItem>
|
||||
<MenuItem id="duplicate">Duplicate</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem id="delete">Delete</MenuItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
argTypes: {
|
||||
placement: {
|
||||
control: "select",
|
||||
options: [
|
||||
"top",
|
||||
"top start",
|
||||
"top end",
|
||||
"bottom",
|
||||
"bottom start",
|
||||
"bottom end",
|
||||
"left",
|
||||
"left top",
|
||||
"left bottom",
|
||||
"right",
|
||||
"right top",
|
||||
"right bottom",
|
||||
],
|
||||
},
|
||||
isDisabled: { control: "boolean" },
|
||||
},
|
||||
parameters: {
|
||||
controls: { exclude: ["trigger", "children"] },
|
||||
},
|
||||
render: ({ ...args }) => <ContextMenuWrapper {...args} />,
|
||||
};
|
||||
|
||||
export const Default = {};
|
||||
|
||||
export const Disabled = {
|
||||
args: {
|
||||
isDisabled: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.main.ui.ds.layout.menu
|
||||
(:require-macros
|
||||
[app.main.style :as stl])
|
||||
(:require
|
||||
["@penpot/ui/menu" :as menu]
|
||||
[app.common.data :as d]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(def ^:private schema:menu
|
||||
[:map
|
||||
[:class {:optional true} [:maybe :string]]
|
||||
[:is-open {:optional true} [:maybe :boolean]]
|
||||
[:on-open-change {:optional true} [:maybe fn?]]
|
||||
[:trigger {:optional true} [:maybe :any]]
|
||||
[:placement {:optional true}
|
||||
[:maybe [:enum "top" "top start" "top end"
|
||||
"bottom" "bottom start" "bottom end"
|
||||
"left" "left top" "left bottom"
|
||||
"right" "right top" "right bottom"]]]
|
||||
[:on-action {:optional true} [:maybe fn?]]])
|
||||
|
||||
(mf/defc menu*
|
||||
{::mf/schema schema:menu}
|
||||
[{:keys [class is-open on-open-change trigger placement on-action children] :rest props}]
|
||||
(let [placement (d/nilv placement "bottom start")
|
||||
props
|
||||
(mf/spread-props props
|
||||
{:class class
|
||||
:is-open is-open
|
||||
:on-open-change on-open-change
|
||||
:trigger trigger
|
||||
:placement placement
|
||||
:on-action on-action})]
|
||||
[:> menu/Menu props
|
||||
children]))
|
||||
|
||||
(def ^:private schema:menu-item
|
||||
[:map
|
||||
[:id {:optional true} [:maybe [:or :string :int]]]
|
||||
[:class {:optional true} [:maybe :string]]
|
||||
[:is-disabled {:optional true} [:maybe :boolean]]
|
||||
[:on-action {:optional true} [:maybe fn?]]
|
||||
[:text-value {:optional true} [:maybe :string]]])
|
||||
|
||||
(mf/defc menu-item*
|
||||
{::mf/schema schema:menu-item}
|
||||
[{:keys [id class is-disabled on-action text-value children] :rest props}]
|
||||
(let [props
|
||||
(mf/spread-props props
|
||||
{:id id
|
||||
:class class
|
||||
:is-disabled is-disabled
|
||||
:on-action on-action
|
||||
:text-value text-value})]
|
||||
[:> menu/MenuItem props
|
||||
children]))
|
||||
|
||||
(def ^:private schema:sub-menu
|
||||
[:map
|
||||
[:id {:optional true} [:maybe [:or :string :int]]]
|
||||
[:class {:optional true} [:maybe :string]]
|
||||
[:trigger {:optional true} [:maybe :any]]
|
||||
[:is-disabled {:optional true} [:maybe :boolean]]
|
||||
[:text-value {:optional true} [:maybe :string]]
|
||||
[:on-action {:optional true} [:maybe fn?]]
|
||||
[:variant {:optional true} [:maybe [:enum "flyout" "drilldown"]]]])
|
||||
|
||||
(mf/defc sub-menu*
|
||||
{::mf/schema schema:sub-menu}
|
||||
[{:keys [id class trigger is-disabled text-value on-action variant children] :rest props}]
|
||||
(let [variant (d/nilv variant "flyout")
|
||||
props
|
||||
(mf/spread-props props
|
||||
{:id id
|
||||
:class class
|
||||
:trigger trigger
|
||||
:is-disabled is-disabled
|
||||
:text-value text-value
|
||||
:on-action on-action
|
||||
:variant variant})]
|
||||
[:> menu/SubMenu props
|
||||
children]))
|
||||
|
||||
(def ^:private schema:menu-separator
|
||||
[:map
|
||||
[:class {:optional true} [:maybe :string]]])
|
||||
|
||||
(mf/defc menu-separator*
|
||||
{::mf/schema schema:menu-separator}
|
||||
[{:keys [class] :rest props}]
|
||||
(let [props (mf/spread-props props {:class class})]
|
||||
[:> menu/MenuSeparator props]))
|
||||
|
||||
(def ^:private schema:context-menu
|
||||
[:map
|
||||
[:class {:optional true} [:maybe :string]]
|
||||
[:aria-label :string]
|
||||
[:trigger {:optional true} [:maybe :any]]
|
||||
[:placement {:optional true}
|
||||
[:maybe [:enum "top" "top start" "top end"
|
||||
"bottom" "bottom start" "bottom end"
|
||||
"left" "left top" "left bottom"
|
||||
"right" "right top" "right bottom"]]]
|
||||
[:is-disabled {:optional true} [:maybe :boolean]]
|
||||
[:on-action {:optional true} [:maybe fn?]]])
|
||||
|
||||
(mf/defc context-menu*
|
||||
{::mf/schema schema:context-menu}
|
||||
[{:keys [class aria-label trigger placement is-disabled on-action children] :rest props}]
|
||||
(let [placement (d/nilv placement "bottom start")
|
||||
props
|
||||
(mf/spread-props props
|
||||
{:class class
|
||||
:aria-label aria-label
|
||||
:trigger trigger
|
||||
:placement placement
|
||||
:is-disabled is-disabled
|
||||
:on-action on-action})]
|
||||
[:> menu/ContextMenu props
|
||||
children]))
|
||||
@@ -0,0 +1,205 @@
|
||||
{ /* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
Copyright (c) KALEIDOS SUBSIDIARY SL */ }
|
||||
import { Canvas, Meta } from "@storybook/addon-docs/blocks";
|
||||
import * as Menu from "./menu.stories";
|
||||
|
||||
<Meta title="Layout/Menu" />
|
||||
|
||||
# Menu
|
||||
|
||||
A menu displays a list of actions or options that a user can choose from. It opens next to a trigger element and closes on selection, **Escape**, or an outside click. Use it to build dropdowns and context menus.
|
||||
|
||||
## Example
|
||||
|
||||
### Default
|
||||
|
||||
<Canvas of={Menu.Default} />
|
||||
|
||||
### With a submenu
|
||||
|
||||
<Canvas of={Menu.WithSubMenu} />
|
||||
|
||||
---
|
||||
|
||||
# Usage
|
||||
|
||||
```clojure
|
||||
(let [open* (mf/use-state false)]
|
||||
[:> menu*
|
||||
{:is-open @open*
|
||||
:on-open-change #(reset! open* %)
|
||||
:trigger (mf/html
|
||||
[:> button* {:variant "secondary"
|
||||
:on-click #(reset! open* true)}
|
||||
"Open menu"])
|
||||
:on-action (fn [key] (handle-action key))}
|
||||
|
||||
[:> menu-item* {:id "rename"} "Rename"]
|
||||
[:> menu-item* {:id "duplicate"} "Duplicate"]
|
||||
[:> menu-separator*]
|
||||
[:> menu-item* {:id "delete"} "Delete"]])
|
||||
```
|
||||
|
||||
`is-open`/`on-open-change` must be driven by the caller: the trigger button opens the menu through its own `on-click`, and `on-open-change` reports back closes from selection, **Escape**, or an outside click. This mirrors the modal's usage — the menu (like the modal) doesn't assume its trigger is a react-aria-aware pressable element, so it can't wire an automatic click-to-open behavior on an arbitrary trigger by itself. For a context menu triggered by a right click, skip `trigger`/`on-click` and call `on-open-change`/set `is-open` directly from the `on-context-menu` handler instead.
|
||||
|
||||
---
|
||||
|
||||
# Menu props
|
||||
## trigger
|
||||
|
||||
Element that opens the menu.
|
||||
|
||||
Type: React element
|
||||
|
||||
## is-open
|
||||
|
||||
Controls whether the menu is open.
|
||||
|
||||
Type: boolean
|
||||
|
||||
## on-open-change
|
||||
|
||||
Callback invoked whenever the open state changes.
|
||||
|
||||
Type: function
|
||||
|
||||
## on-action
|
||||
|
||||
Callback invoked with the selected item's `id` when an item is chosen.
|
||||
|
||||
Type: function
|
||||
|
||||
## placement
|
||||
|
||||
Controls where the menu opens relative to its trigger.
|
||||
|
||||
Options
|
||||
|
||||
"top", "top start", "top end", "bottom", "bottom start" (default), "bottom end", "left", "left top", "left bottom", "right", "right top", "right bottom"
|
||||
|
||||
## class
|
||||
|
||||
Additional CSS class applied to the menu.
|
||||
|
||||
---
|
||||
|
||||
# MenuItem props
|
||||
## id
|
||||
|
||||
Unique identifier for the item, passed to `on-action` when selected.
|
||||
|
||||
Type: string | number
|
||||
|
||||
## is-disabled
|
||||
|
||||
Prevents the item from being selected.
|
||||
|
||||
Default: false
|
||||
|
||||
## on-action
|
||||
|
||||
Callback invoked when this specific item is selected.
|
||||
|
||||
Type: function
|
||||
|
||||
## text-value
|
||||
|
||||
Plain-text representation of the item, used for typeahead. Required when the item's content isn't a plain string.
|
||||
|
||||
Type: string
|
||||
|
||||
## class
|
||||
|
||||
Additional CSS class applied to the item.
|
||||
|
||||
---
|
||||
|
||||
# SubMenu props
|
||||
|
||||
A `sub-menu*` nests a further list of items behind one item, opening on hover or when navigated into with the keyboard. Use it in place of `menu-item*` for that item:
|
||||
|
||||
```clojure
|
||||
[:> sub-menu* {:trigger "Share" :on-action (fn [key] (handle-action key))}
|
||||
[:> menu-item* {:id "share-link"} "Copy link"]
|
||||
[:> menu-item* {:id "share-email"} "Send by email"]]
|
||||
```
|
||||
|
||||
Selecting any item inside a submenu closes the whole menu, not just that submenu.
|
||||
|
||||
## variant
|
||||
|
||||
`"flyout"` (default) opens a nested popover next to the trigger item, like a desktop context menu. `"drilldown"` instead replaces the parent menu's own content with this submenu's items and adds a back item above them — use it for a list too deep or too wide for a chain of flyouts, e.g. move-to-project's team → project nesting. A drilldown submenu nested inside another drilldown submenu keeps drilling into the same list, so arbitrarily deep trees stay navigable one screen at a time.
|
||||
|
||||
```clojure
|
||||
[:> sub-menu* {:trigger "Move to" :variant "drilldown"}
|
||||
[:> menu-item* {:id "project-a"} "Project A"]
|
||||
[:> sub-menu* {:trigger "Other team" :variant "drilldown"}
|
||||
[:> menu-item* {:id "project-b"} "Project B"]]]
|
||||
```
|
||||
|
||||
Options
|
||||
|
||||
"flyout" (default), "drilldown"
|
||||
|
||||
## trigger
|
||||
|
||||
Label shown for the item that opens the submenu.
|
||||
|
||||
Type: React element
|
||||
|
||||
## id
|
||||
|
||||
Unique identifier for the submenu trigger item.
|
||||
|
||||
Type: string | number
|
||||
|
||||
## is-disabled
|
||||
|
||||
Prevents the submenu from being opened.
|
||||
|
||||
Default: false
|
||||
|
||||
## on-action
|
||||
|
||||
Callback invoked with the selected item's `id` when an item inside this submenu is chosen. Separate from the parent menu's own `on-action` — it isn't called for submenu selections. Ignored when `variant` is `"drilldown"`: those items sit inside the same list as everything else, so the parent menu's own `on-action` already sees them selected.
|
||||
|
||||
Type: function
|
||||
|
||||
## text-value
|
||||
|
||||
Plain-text representation of the trigger, used for typeahead. Required when `trigger` isn't a plain string.
|
||||
|
||||
Type: string
|
||||
|
||||
## class
|
||||
|
||||
Additional CSS class applied to the submenu's item list.
|
||||
|
||||
---
|
||||
|
||||
# Accessibility
|
||||
|
||||
The menu automatically provides:
|
||||
|
||||
- Accessible `menu`/`menuitem` semantics
|
||||
- Full keyboard navigation (arrow keys, Home/End, typeahead)
|
||||
- Focus management and restoration when closed
|
||||
- Dismissal with **Escape** and outside click
|
||||
|
||||
---
|
||||
|
||||
# Best practices
|
||||
|
||||
Use a menu for:
|
||||
|
||||
- A list of actions tied to a trigger button (dropdown)
|
||||
- A contextual list of actions tied to a right click (context menu)
|
||||
|
||||
Avoid using a menu for:
|
||||
|
||||
- Long forms or free-text input
|
||||
- Navigation between pages (use a link/nav component instead)
|
||||
- A single toggleable option (use a switch or checkbox instead)
|
||||
@@ -0,0 +1,162 @@
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
import * as React from "react";
|
||||
import Components from "@target/components";
|
||||
|
||||
const { Menu, MenuItem, MenuSeparator, SubMenu, Button } = Components;
|
||||
|
||||
const MenuWrapper = ({ children, ...props }) => {
|
||||
const [open, setOpen] = React.useState(props.isOpen ?? false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setOpen(props.isOpen ?? false);
|
||||
}, [props.isOpen]);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
{...props}
|
||||
isOpen={open}
|
||||
onOpenChange={setOpen}
|
||||
trigger={
|
||||
<Button variant="secondary" onClick={() => setOpen(true)}>
|
||||
Open menu
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
title: "Layout/Menu",
|
||||
component: MenuWrapper,
|
||||
args: {
|
||||
placement: "bottom start",
|
||||
onAction: (key) => console.log("action", key),
|
||||
children: (
|
||||
<>
|
||||
<MenuItem id="rename">Rename</MenuItem>
|
||||
<MenuItem id="duplicate">Duplicate</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem id="delete">Delete</MenuItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
argTypes: {
|
||||
placement: {
|
||||
control: "select",
|
||||
options: [
|
||||
"top",
|
||||
"top start",
|
||||
"top end",
|
||||
"bottom",
|
||||
"bottom start",
|
||||
"bottom end",
|
||||
"left",
|
||||
"left top",
|
||||
"left bottom",
|
||||
"right",
|
||||
"right top",
|
||||
"right bottom",
|
||||
],
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
controls: { exclude: ["isOpen", "onOpenChange", "trigger", "children"] },
|
||||
},
|
||||
render: ({ ...args }) => <MenuWrapper {...args} />,
|
||||
};
|
||||
|
||||
export const Default = {};
|
||||
|
||||
export const WithDisabledItem = {
|
||||
args: {
|
||||
children: (
|
||||
<>
|
||||
<MenuItem id="rename">Rename</MenuItem>
|
||||
<MenuItem id="duplicate" isDisabled>
|
||||
Duplicate
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem id="delete">Delete</MenuItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSubMenu = {
|
||||
args: {
|
||||
children: (
|
||||
<>
|
||||
<MenuItem id="rename">Rename</MenuItem>
|
||||
<SubMenu
|
||||
trigger="Share"
|
||||
onAction={(key) => console.log("sub-menu action", key)}
|
||||
>
|
||||
<MenuItem id="share-link">Copy link</MenuItem>
|
||||
<MenuItem id="share-email">Send by email</MenuItem>
|
||||
</SubMenu>
|
||||
<MenuSeparator />
|
||||
<MenuItem id="delete">Delete</MenuItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDrilldownSubMenu = {
|
||||
args: {
|
||||
children: (
|
||||
<>
|
||||
<MenuItem id="rename">Rename</MenuItem>
|
||||
<MenuItem id="duplicate">Duplicate</MenuItem>
|
||||
<MenuSeparator />
|
||||
<SubMenu trigger="Move to" variant="drilldown">
|
||||
<MenuItem id="project-a">Project A</MenuItem>
|
||||
<MenuItem id="project-b">Project B</MenuItem>
|
||||
<SubMenu trigger="Other team" variant="drilldown">
|
||||
<SubMenu trigger="Team 1" variant="drilldown">
|
||||
<MenuItem id="team-1-project-a">Project A</MenuItem>
|
||||
<MenuItem id="team-1-project-b">Project B</MenuItem>
|
||||
</SubMenu>
|
||||
<SubMenu trigger="Team 2" variant="drilldown">
|
||||
<MenuItem id="team-2-project-a">Project A</MenuItem>
|
||||
</SubMenu>
|
||||
</SubMenu>
|
||||
</SubMenu>
|
||||
<MenuSeparator />
|
||||
<MenuItem id="delete">Delete</MenuItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const Placement = {
|
||||
args: {
|
||||
placement: "right",
|
||||
},
|
||||
decorators: [
|
||||
// Absolutely-positioned + transform centering, rather than flex
|
||||
// align-items, because the trigger's own align-self: start (needed so
|
||||
// it doesn't get stretched by a real flex/grid ancestor elsewhere)
|
||||
// would otherwise override a flex parent's centering here too.
|
||||
(Story) => (
|
||||
<div style={{ position: "relative", minHeight: "60vh" }}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -130,20 +130,6 @@
|
||||
(if-let [cached (get @optical-offset-cache key)]
|
||||
(p/resolved cached)
|
||||
(-> (fonts/ensure-loaded! font-id)
|
||||
(p/then
|
||||
(fn [_]
|
||||
;; ensure-loaded! only guarantees the @font-face CSS text has
|
||||
;; been injected, not that the browser has actually fetched and
|
||||
;; parsed the font file: that fetch is lazy, normally triggered
|
||||
;; by the browser laying out DOM text with the font. Canvas
|
||||
;; measureText doesn't reliably trigger it, so without this
|
||||
;; explicit wait it can silently measure the fallback font
|
||||
;; instead, and the resulting bogus offset then gets cached
|
||||
;; forever — pushing the sample glyphs outside the clipped
|
||||
;; sample box instead of just centering them slightly wrong.
|
||||
(let [spec (dm/str (or weight "400") " " (or style "normal") " 16px \"" family "\"")]
|
||||
(-> (.load js/document.fonts spec)
|
||||
(p/catch (constantly nil))))))
|
||||
(p/then
|
||||
(fn [_]
|
||||
(let [em (or (optical-offset-em family weight style text) 0)]
|
||||
@@ -153,67 +139,36 @@
|
||||
(defn- use-optical-offset
|
||||
"Lazily resolve the optical-centering offset (in `em`) for sample text in a
|
||||
given font, measuring once per font/sample and caching it. Falls back to 0
|
||||
when the font isn't available or the metrics can't be measured.
|
||||
|
||||
Returns `[offset ready?]`. `ready?` is true immediately when a value is
|
||||
already cached, and false only while the very first measurement of a given
|
||||
font/sample is still pending. Callers should keep the sample hidden until
|
||||
`ready?`: the offset (and so the sample's rendered position) jumps once
|
||||
that first, async measurement resolves, and painting the glyphs before
|
||||
then makes automated screenshot/position-based tests flaky — whether the
|
||||
test runs before or after the jump is a timing race, not a deterministic
|
||||
outcome."
|
||||
when the font isn't available or the metrics can't be measured."
|
||||
[font-id family weight style text]
|
||||
(let [key (optical-offset-key family weight style text)
|
||||
offset* (mf/use-state #(get @optical-offset-cache key 0))
|
||||
ready?* (mf/use-state #(contains? @optical-offset-cache key))]
|
||||
(let [offset* (mf/use-state 0)]
|
||||
(mf/use-effect
|
||||
(mf/deps font-id family weight style text)
|
||||
(fn []
|
||||
(let [cancelled? (volatile! false)]
|
||||
(let [cancelled? (volatile! false)
|
||||
key (optical-offset-key family weight style text)]
|
||||
(if (contains? @optical-offset-cache key)
|
||||
(do
|
||||
(reset! offset* (get @optical-offset-cache key))
|
||||
(reset! ready?* true))
|
||||
(reset! offset* (get @optical-offset-cache key))
|
||||
(let [task (tm/schedule-on-idle
|
||||
(fn []
|
||||
(-> (load-optical-offset font-id family weight style text)
|
||||
(p/then
|
||||
(fn [em]
|
||||
(when-not @cancelled?
|
||||
(reset! offset* em)
|
||||
(reset! ready?* true)))))))]
|
||||
(reset! offset* em)))))))]
|
||||
(fn []
|
||||
(vreset! cancelled? true)
|
||||
(tm/dispose! task)))))
|
||||
nil))
|
||||
[(deref offset*) (deref ready?*)]))
|
||||
(deref offset*)))
|
||||
|
||||
(defn- sample-container-style
|
||||
"Inline style that applies the typography font to the (clipped, fixed-height)
|
||||
sample container. Must be a real JS object (`#js`), not a ClojureScript map:
|
||||
the `:style` value here is a runtime expression, not a literal recognized by
|
||||
the hiccup macro, so it reaches React unconverted.
|
||||
|
||||
Falls back to `font-data` (the live fontsdb entry for the typography's
|
||||
`:font-id`) when the typography's own `:font-family` is blank: a font that
|
||||
was unloaded when a typography's font/variant was last changed can leave
|
||||
that field nil on the record (the same failure mode `remove-nil-style-attrs`
|
||||
repairs for shape text spans), and the sample would otherwise render in
|
||||
whatever fallback font the browser picks instead of the intended one.
|
||||
|
||||
The family name is quoted, matching `font-item-preview*` below: setting
|
||||
`style.fontFamily` to a raw, unquoted string parses it as CSS's
|
||||
`<family-name>` grammar, i.e. whitespace-separated `<custom-ident>`s. A
|
||||
family like \"Micro 5\" then tokenizes as the ident `Micro` followed by
|
||||
the *number* `5` — not a valid ident — so the whole property is invalid
|
||||
CSS and the browser silently drops it. A quoted `<string>` sidesteps that
|
||||
entirely, since it isn't tokenized as identifiers at all."
|
||||
[typography font-data]
|
||||
#js {:fontFamily (let [family (:font-family typography)
|
||||
family (if (str/blank? family) (:family font-data) family)]
|
||||
(when-not (str/blank? family)
|
||||
(dm/str "\"" family "\"")))
|
||||
the hiccup macro, so it reaches React unconverted."
|
||||
[typography]
|
||||
#js {:fontFamily (:font-family typography)
|
||||
:fontWeight (:font-weight typography)
|
||||
:fontStyle (:font-style typography)})
|
||||
|
||||
@@ -221,14 +176,10 @@
|
||||
"Inline style that optically centers the sample glyphs. Must be applied to
|
||||
the text node itself, not to the clipped container: a transform on an
|
||||
`overflow: hidden` element moves its own clip region along with it, so it
|
||||
would shift the whole box relative to the row instead of the glyphs inside it.
|
||||
|
||||
Hidden until `ready?` (see `use-optical-offset`), so the glyphs only ever
|
||||
appear already in their final, correctly centered position instead of
|
||||
visibly jumping there after the first paint."
|
||||
[em ready?]
|
||||
#js {:transform (when-not (zero? em) (dm/str "translateY(" em "em)"))
|
||||
:visibility (when-not ready? "hidden")})
|
||||
would shift the whole box relative to the row instead of the glyphs inside it."
|
||||
[em]
|
||||
(when-not (zero? em)
|
||||
#js {:transform (dm/str "translateY(" em "em)")}))
|
||||
|
||||
;; --- FONT SELECTOR --------------------------------------------------------
|
||||
|
||||
@@ -259,17 +210,11 @@
|
||||
;; the row, so shift it by the measured offset once the font is known.
|
||||
;; The label renders at `body-medium` (400/normal), which is the weight
|
||||
;; and style we measure against.
|
||||
;; The selector always shows the font's name text as-is, unlike the
|
||||
;; small "Ag" sample elsewhere in this file, so unlike there this
|
||||
;; doesn't need to hide anything until the offset is ready — a
|
||||
;; shifting label is a minor, acceptable visual nicety here, not
|
||||
;; the row's only content.
|
||||
[label-offset _label-ready?]
|
||||
(use-optical-offset font-id
|
||||
(:family font)
|
||||
"400"
|
||||
"normal"
|
||||
(:name font))]
|
||||
label-offset (use-optical-offset font-id
|
||||
(:family font)
|
||||
"400"
|
||||
"normal"
|
||||
(:name font))]
|
||||
(if in-sprite?
|
||||
;; `fill: currentColor` (scss) makes the sprite glyph follow the row color.
|
||||
[:svg {:class (stl/css :font-item-preview)
|
||||
@@ -757,12 +702,11 @@
|
||||
font-data (fonts/get-font-data (:font-id typography))
|
||||
typography-id (:id typography)
|
||||
show-actions? (and is-asset? is-editable)
|
||||
[offset offset-ready?]
|
||||
(use-optical-offset (:font-id typography)
|
||||
(:font-family typography)
|
||||
(:font-weight typography)
|
||||
(:font-style typography)
|
||||
"Ag")
|
||||
offset (use-optical-offset (:font-id typography)
|
||||
(:font-family typography)
|
||||
(:font-weight typography)
|
||||
(:font-style typography)
|
||||
"Ag")
|
||||
|
||||
on-delete
|
||||
(mf/use-fn
|
||||
@@ -797,8 +741,8 @@
|
||||
[:*
|
||||
[:div {:class (stl/css :font-name-wrapper)}
|
||||
[:div {:class (stl/css :typography-sample-input)
|
||||
:style (sample-container-style typography font-data)}
|
||||
[:span {:style (sample-text-style offset offset-ready?)}
|
||||
:style (sample-container-style typography)}
|
||||
[:span {:style (sample-text-style offset)}
|
||||
(tr "workspace.assets.typography.sample")]]
|
||||
|
||||
[:input
|
||||
@@ -833,8 +777,8 @@
|
||||
[:div {:class (stl/css :typography-info-wrapper)}
|
||||
[:div {:class (stl/css :typography-name-wrapper)}
|
||||
[:div {:class (stl/css :typography-sample)
|
||||
:style (sample-container-style typography font-data)}
|
||||
[:span {:style (sample-text-style offset offset-ready?)}
|
||||
:style (sample-container-style typography)}
|
||||
[:span {:style (sample-text-style offset)}
|
||||
(tr "workspace.assets.typography.sample")]]
|
||||
|
||||
[:div {:class (stl/css :typography-name)
|
||||
@@ -882,12 +826,11 @@
|
||||
open? (deref open*)
|
||||
font-data (fonts/get-font-data (:font-id typography))
|
||||
name-only? (= (:name typography) (:name font-data))
|
||||
[offset offset-ready?]
|
||||
(use-optical-offset (:font-id typography)
|
||||
(:font-family typography)
|
||||
(:font-weight typography)
|
||||
(:font-style typography)
|
||||
"Ag")
|
||||
offset (use-optical-offset (:font-id typography)
|
||||
(:font-family typography)
|
||||
(:font-weight typography)
|
||||
(:font-style typography)
|
||||
"Ag")
|
||||
|
||||
on-name-blur
|
||||
(mf/use-fn
|
||||
@@ -922,13 +865,6 @@
|
||||
(when ^boolean esc?
|
||||
(dom/blur! input-node)))))]
|
||||
|
||||
;; use-optical-offset only triggers a font load as a side effect of an
|
||||
;; uncached offset measurement, so on a cache hit (e.g. a `0` offset
|
||||
;; cached from before the font was ever loaded) the font itself never
|
||||
;; gets fetched and the sample silently renders in the fallback font.
|
||||
;; Load it unconditionally too, same as the advanced-options view does.
|
||||
(fonts/ensure-loaded! (:font-id typography))
|
||||
|
||||
(mf/with-effect [is-editing]
|
||||
(when is-editing
|
||||
(reset! open* is-editing)))
|
||||
@@ -952,8 +888,8 @@
|
||||
[:div {:class (stl/css :font-name-wrapper)}
|
||||
[:div
|
||||
{:class (stl/css :typography-sample-input)
|
||||
:style (sample-container-style typography font-data)}
|
||||
[:span {:style (sample-text-style offset offset-ready?)}
|
||||
:style (sample-container-style typography)}
|
||||
[:span {:style (sample-text-style offset)}
|
||||
(tr "workspace.assets.typography.sample")]]
|
||||
|
||||
[:input
|
||||
@@ -971,8 +907,8 @@
|
||||
:on-context-menu on-context-menu}
|
||||
[:div
|
||||
{:class (stl/css :typography-sample)
|
||||
:style (sample-container-style typography font-data)}
|
||||
[:span {:style (sample-text-style offset offset-ready?)}
|
||||
:style (sample-container-style typography)}
|
||||
[:span {:style (sample-text-style offset)}
|
||||
(tr "workspace.assets.typography.sample")]]
|
||||
|
||||
[:div {:class (stl/css :name-block)
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.data.workspace :as dw]
|
||||
[app.main.data.workspace.shapes :as dwsh]
|
||||
[app.main.data.workspace.undo :as dwu]
|
||||
[app.main.data.workspace.wasm-text :as dwwt]
|
||||
[app.main.features :as features]
|
||||
[app.main.refs :as refs]
|
||||
@@ -315,14 +314,11 @@
|
||||
:bottom :auto-height
|
||||
nil)]
|
||||
(when (some? grow-type)
|
||||
(let [uid (js/Symbol)]
|
||||
(st/emit! (dwu/start-undo-transaction uid)
|
||||
(dwsh/update-shapes [shape-id] #(assoc % :grow-type grow-type)))
|
||||
;; The WASM renderer needs an explicit reflow after the grow-type change
|
||||
(if (features/active-feature? @st/state "render-wasm/v1")
|
||||
(st/emit! (dwwt/resize-wasm-text-all [shape-id] {:undo-id uid})
|
||||
(ptk/data-event :layout/update {:ids [shape-id]}))
|
||||
(st/emit! (dwu/commit-undo-transaction uid)))))))))]
|
||||
(st/emit! (dwsh/update-shapes [shape-id] #(assoc % :grow-type grow-type)))
|
||||
;; The WASM renderer needs an explicit reflow after the grow-type change
|
||||
(when (features/active-feature? @st/state "render-wasm/v1")
|
||||
(st/emit! (dwwt/resize-wasm-text-all [shape-id])
|
||||
(ptk/data-event :layout/update {:ids [shape-id]}))))))))]
|
||||
|
||||
[:g.resize-handler
|
||||
(when ^boolean show-handler
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
[app.plugins.page :as page]
|
||||
[app.plugins.parser :as parser]
|
||||
[app.plugins.reflow :as wrfp]
|
||||
[app.plugins.register :as r]
|
||||
[app.plugins.shape :as shape]
|
||||
[app.plugins.system-events :as se]
|
||||
[app.plugins.user :as user]
|
||||
@@ -243,18 +242,15 @@
|
||||
|
||||
:getCurrentUser
|
||||
(fn []
|
||||
(when (r/check-permission plugin-id "user:read")
|
||||
(user/current-user-proxy plugin-id (:session-id @st/state))))
|
||||
(user/current-user-proxy plugin-id (:session-id @st/state)))
|
||||
|
||||
:getActiveUsers
|
||||
(fn []
|
||||
(if (r/check-permission plugin-id "user:read")
|
||||
(apply array
|
||||
(->> (:workspace-presence @st/state)
|
||||
(vals)
|
||||
(remove #(= (:id %) (:session-id @st/state)))
|
||||
(map #(user/active-user-proxy plugin-id (:id %)))))
|
||||
(array)))
|
||||
(apply array
|
||||
(->> (:workspace-presence @st/state)
|
||||
(vals)
|
||||
(remove #(= (:id %) (:session-id @st/state)))
|
||||
(map #(user/active-user-proxy plugin-id (:id %))))))
|
||||
|
||||
:uploadMediaUrl
|
||||
(fn [name url]
|
||||
|
||||
@@ -40,14 +40,12 @@
|
||||
|
||||
;; FIXME: inconsistent with comment-thread: owner
|
||||
:user
|
||||
{:get #(when (r/check-permission plugin-id "user:read")
|
||||
(->> (dc/get-owner data)
|
||||
(user/user-proxy plugin-id)))}
|
||||
{:get #(->> (dc/get-owner data)
|
||||
(user/user-proxy plugin-id))}
|
||||
|
||||
:owner
|
||||
{:get #(when (r/check-permission plugin-id "user:read")
|
||||
(->> (dc/get-owner data)
|
||||
(user/user-proxy plugin-id)))}
|
||||
{:get #(->> (dc/get-owner data)
|
||||
(user/user-proxy plugin-id))}
|
||||
|
||||
:date
|
||||
{:get
|
||||
@@ -118,9 +116,8 @@
|
||||
:board {:get #(shape/shape-proxy plugin-id file-id page-id (:frame-id data))}
|
||||
|
||||
:owner
|
||||
{:get #(when (r/check-permission plugin-id "user:read")
|
||||
(->> (dc/get-owner data)
|
||||
(user/user-proxy plugin-id)))}
|
||||
{:get #(->> (dc/get-owner data)
|
||||
(user/user-proxy plugin-id))}
|
||||
|
||||
:position
|
||||
{:get
|
||||
|
||||
@@ -61,9 +61,8 @@
|
||||
:createdBy
|
||||
{:get
|
||||
(fn []
|
||||
(when (r/check-permission plugin-id "user:read")
|
||||
(when-let [user-data (get users (:profile-id @data))]
|
||||
(user/user-proxy plugin-id user-data))))}
|
||||
(when-let [user-data (get users (:profile-id @data))]
|
||||
(user/user-proxy plugin-id user-data)))}
|
||||
|
||||
:createdAt
|
||||
{:get #(:created-at @data)}
|
||||
|
||||
@@ -325,12 +325,7 @@
|
||||
|
||||
:remove
|
||||
(fn []
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit! (dwsl/remove-layout #{id}))))
|
||||
(st/emit! (dwsl/remove-layout #{id})))
|
||||
|
||||
:appendChild
|
||||
(fn [child]
|
||||
@@ -355,9 +350,6 @@
|
||||
(u/changes-component-copy-structure? objects shape child-shape)
|
||||
(u/not-valid plugin-id :appendChild "Cannot change the structure of a component copy")
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :appendChild "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit!
|
||||
(dwsh/relocate-shapes #{child-id} id index)
|
||||
|
||||
@@ -698,37 +698,21 @@
|
||||
|
||||
:addVariant
|
||||
(fn []
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "library:write"))
|
||||
(u/not-valid plugin-id :addVariant "Plugin doesn't have 'library:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit!
|
||||
(se/event plugin-id "add-new-variant")
|
||||
(dwv/add-new-variant id))))
|
||||
(st/emit!
|
||||
(se/event plugin-id "add-new-variant")
|
||||
(dwv/add-new-variant id)))
|
||||
|
||||
:addProperty
|
||||
(fn []
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "library:write"))
|
||||
(u/not-valid plugin-id :addProperty "Plugin doesn't have 'library:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit!
|
||||
(se/event plugin-id "add-new-property")
|
||||
(dwv/add-new-property id {:property-value "Value 1"}))))
|
||||
(st/emit!
|
||||
(se/event plugin-id "add-new-property")
|
||||
(dwv/add-new-property id {:property-value "Value 1"})))
|
||||
|
||||
:removeProperty
|
||||
(fn [pos]
|
||||
(let [nprops (->> (get-variant-components file-id id) first :variant-properties count)]
|
||||
(cond
|
||||
(or (not (nat-int? pos)) (>= pos nprops))
|
||||
(if (or (not (nat-int? pos)) (>= pos nprops))
|
||||
(u/not-valid plugin-id :pos pos)
|
||||
|
||||
(not (r/check-permission plugin-id "library:write"))
|
||||
(u/not-valid plugin-id :removeProperty "Plugin doesn't have 'library:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit!
|
||||
(se/event plugin-id "remove-property")
|
||||
(dwv/remove-property id pos)))))
|
||||
@@ -743,9 +727,6 @@
|
||||
(not (string? name))
|
||||
(u/not-valid plugin-id :name name)
|
||||
|
||||
(not (r/check-permission plugin-id "library:write"))
|
||||
(u/not-valid plugin-id :renameProperty "Plugin doesn't have 'library:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit!
|
||||
(dwv/update-property-name id pos name {:trigger "plugin:rename-property"})))))))
|
||||
@@ -942,15 +923,8 @@
|
||||
:transformInVariant
|
||||
(fn []
|
||||
(let [component (u/locate-library-component file-id id)]
|
||||
(cond
|
||||
(or (nil? component)
|
||||
(ctk/is-variant? component))
|
||||
nil
|
||||
|
||||
(not (r/check-permission plugin-id "library:write"))
|
||||
(u/not-valid plugin-id :transformInVariant "Plugin doesn't have 'library:write' permission")
|
||||
|
||||
:else
|
||||
(when (and component
|
||||
(not (ctk/is-variant? component)))
|
||||
(st/emit!
|
||||
(se/event plugin-id "transform-in-variant")
|
||||
(dwv/transform-in-variant (:main-instance-id component))))))
|
||||
@@ -958,15 +932,8 @@
|
||||
:addVariant
|
||||
(fn []
|
||||
(let [component (u/locate-library-component file-id id)]
|
||||
(cond
|
||||
(or (nil? component)
|
||||
(not (ctk/is-variant? component)))
|
||||
nil
|
||||
|
||||
(not (r/check-permission plugin-id "library:write"))
|
||||
(u/not-valid plugin-id :addVariant "Plugin doesn't have 'library:write' permission")
|
||||
|
||||
:else
|
||||
(when (and component
|
||||
(ctk/is-variant? component))
|
||||
(st/emit!
|
||||
(se/event plugin-id "add-new-variant")
|
||||
(dwv/add-new-variant (:main-instance-id component))))))
|
||||
@@ -981,9 +948,6 @@
|
||||
(not (string? value))
|
||||
(u/not-valid plugin-id :name value)
|
||||
|
||||
(not (r/check-permission plugin-id "library:write"))
|
||||
(u/not-valid plugin-id :setVariantProperty "Plugin doesn't have 'library:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit!
|
||||
(se/event plugin-id "variant-edit-property-value")
|
||||
|
||||
@@ -64,9 +64,6 @@
|
||||
(or (not (string? value)) (empty? value))
|
||||
(u/not-valid plugin-id :name value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit! (dwi/update-flow page-id id #(assoc % :name value)))))}
|
||||
|
||||
@@ -82,20 +79,12 @@
|
||||
(not (shape/shape-proxy? value))
|
||||
(u/not-valid plugin-id :startingBoard value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :startingBoard "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit! (dwi/update-flow page-id id #(assoc % :starting-frame (obj/get value "$id"))))))}
|
||||
|
||||
:remove
|
||||
(fn []
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit! (dwi/remove-flow page-id id))))))
|
||||
(st/emit! (dwi/remove-flow page-id id)))))
|
||||
|
||||
(defn page-proxy? [proxy]
|
||||
(obj/type-of? proxy "PageProxy"))
|
||||
@@ -326,9 +315,6 @@
|
||||
(not (shape/shape-proxy? frame))
|
||||
(u/not-valid plugin-id :createFlow-frame frame)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :createFlow "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(let [flow-id (uuid/next)]
|
||||
(st/emit!
|
||||
@@ -342,9 +328,6 @@
|
||||
(not (flow-proxy? flow))
|
||||
(u/not-valid plugin-id :removeFlow-flow flow)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :removeFlow "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit!
|
||||
(dwi/remove-flow id (obj/get flow "$id"))
|
||||
|
||||
@@ -103,9 +103,6 @@
|
||||
(not (contains? ctsi/event-types value))
|
||||
(u/not-valid plugin-id :trigger value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :trigger "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit! (dwi/update-interaction
|
||||
(u/locate-shape file-id page-id shape-id)
|
||||
@@ -122,9 +119,6 @@
|
||||
(or (not (sm/valid-safe-int? value)) (neg? value))
|
||||
(u/not-valid plugin-id :delay value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :delay "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit! (dwi/update-interaction
|
||||
(u/locate-shape file-id page-id shape-id)
|
||||
@@ -145,9 +139,6 @@
|
||||
(not (sm/validate ctsi/schema:interaction interaction))
|
||||
(u/not-valid plugin-id :action interaction)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :action "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit! (dwi/update-interaction
|
||||
(u/locate-shape file-id page-id shape-id)
|
||||
@@ -157,12 +148,7 @@
|
||||
|
||||
:remove
|
||||
(fn []
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit! (dwi/remove-interaction {:id shape-id} index))))))
|
||||
(st/emit! (dwi/remove-interaction {:id shape-id} index)))))
|
||||
|
||||
(def lib-typography-proxy? nil)
|
||||
(def lib-component-proxy nil)
|
||||
@@ -214,15 +200,15 @@
|
||||
(not (sm/validate [:vector types.fills/schema:fill] value))
|
||||
(u/not-valid plugin-id :fills value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :fills "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? (obj/get self "$page")))
|
||||
(u/not-valid plugin-id :fills "Cannot modify a page that is not currently active")
|
||||
|
||||
(cfh/text-shape? shape)
|
||||
(st/emit! (dwt/update-attrs id {:fills value}))
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :fills "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit! (dwsh/update-shapes [id] #(assoc % :fills value))))))
|
||||
|
||||
@@ -1489,9 +1475,6 @@
|
||||
:detach
|
||||
(fn []
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :detach "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :detach "Cannot modify a page that is not currently active")
|
||||
|
||||
@@ -1502,12 +1485,12 @@
|
||||
(fn [component]
|
||||
(let [shape (u/locate-shape file-id page-id id)]
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :swapComponent "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :swapComponent "Cannot modify a page that is not currently active")
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :swapComponent "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (obj/type-of? component "LibraryComponentProxy"))
|
||||
(u/not-valid plugin-id :swapComponent "Component not valid")
|
||||
|
||||
@@ -1524,12 +1507,12 @@
|
||||
(fn []
|
||||
(let [shape (u/locate-shape file-id page-id id)]
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :resetOverrides "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :resetOverrides "Cannot modify a page that is not currently active")
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :resetOverrides "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (ctk/in-component-copy? shape))
|
||||
(u/not-valid plugin-id :resetOverrides "The shape is not a component copy instance")
|
||||
|
||||
@@ -1544,9 +1527,6 @@
|
||||
(not (sm/validate ctse/schema:export value))
|
||||
(u/not-valid plugin-id :export value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:read"))
|
||||
(u/not-valid plugin-id :export "Plugin doesn't have 'content:read' permission")
|
||||
|
||||
:else
|
||||
(if (and (contains? cf/flags :wasm-export)
|
||||
(contains? #{:jpeg :webp :png} (:type value :png)))
|
||||
@@ -1618,9 +1598,6 @@
|
||||
(not (sm/validate ctsi/schema:interaction interaction))
|
||||
(u/not-valid plugin-id :addInteraction interaction)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :addInteraction "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(let [index (-> (u/locate-shape file-id page-id id) (:interactions []) count)]
|
||||
(st/emit!
|
||||
@@ -1634,9 +1611,6 @@
|
||||
(not (interaction-proxy? interaction))
|
||||
(u/not-valid plugin-id :removeInteraction interaction)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :removeInteraction "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit!
|
||||
(dwi/remove-interaction {:id id} (obj/get interaction "$index"))
|
||||
@@ -1717,14 +1691,8 @@
|
||||
:fn (fn [token attrs]
|
||||
(let [token (u/locate-token file-id (obj/get token "$set-id") (obj/get token "$id"))
|
||||
kw-attrs (into #{} (map token-attr-plugin->token-attr attrs))]
|
||||
(cond
|
||||
(some #(not (token-attr? %)) kw-attrs)
|
||||
(if (some #(not (token-attr? %)) kw-attrs)
|
||||
(u/not-valid plugin-id :applyToken attrs)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :applyToken "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit!
|
||||
(-> (dwta/toggle-token {:token token
|
||||
:attrs kw-attrs
|
||||
@@ -1752,9 +1720,6 @@
|
||||
(not (string? value))
|
||||
(u/not-valid plugin-id :value value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :switchVariant "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(let [shape (u/locate-shape file-id page-id id)
|
||||
component (u/locate-library-component file-id (:component-id shape))]
|
||||
@@ -1768,9 +1733,6 @@
|
||||
(or (not (seq ids)) (not (every? uuid/parse* ids)))
|
||||
(u/not-valid plugin-id :ids ids)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :combineAsVariants "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(let [;; Keep the input order (head shape first): it determines
|
||||
;; the order of the resulting variant components (see
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
[app.main.data.workspace.tokens.application :as dwta]
|
||||
[app.main.data.workspace.tokens.library-edit :as dwtl]
|
||||
[app.main.store :as st]
|
||||
[app.plugins.register :as r]
|
||||
[app.plugins.system-events :as se]
|
||||
[app.plugins.utils :as u]
|
||||
[app.util.object :as obj]
|
||||
@@ -86,20 +85,16 @@
|
||||
|
||||
(defn- apply-token-to-shapes
|
||||
[plugin-id file-id set-id id shape-ids attrs]
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :applyToken "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(let [token (u/locate-token file-id set-id id)]
|
||||
(if (some #(not (token-attr? %)) attrs)
|
||||
(u/not-valid plugin-id :applyToSelected attrs)
|
||||
(st/emit!
|
||||
(-> (dwta/toggle-token {:token token
|
||||
:attrs (into #{} (map token-attr-plugin->token-attr) attrs)
|
||||
:shape-ids shape-ids
|
||||
:expand-with-children false})
|
||||
(se/add-event plugin-id)))))))
|
||||
(let [token (u/locate-token file-id set-id id)]
|
||||
(if (some #(not (token-attr? %)) attrs)
|
||||
(u/not-valid plugin-id :applyToSelected attrs)
|
||||
(st/emit!
|
||||
(-> (dwta/toggle-token {:token token
|
||||
:attrs (into #{} (map token-attr-plugin->token-attr) attrs)
|
||||
:shape-ids shape-ids
|
||||
:expand-with-children false})
|
||||
(se/add-event plugin-id))))))
|
||||
|
||||
(defn- typography-resolved-value->js
|
||||
"Converts a resolved typography composite (a Clojure map keyed by the
|
||||
@@ -209,13 +204,8 @@
|
||||
(ctob/get-tokens set-id)))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit! (-> (dwtl/update-token set-id id {:name value})
|
||||
(se/add-event plugin-id)))))}
|
||||
(st/emit! (-> (dwtl/update-token set-id id {:name value})
|
||||
(se/add-event plugin-id))))}
|
||||
|
||||
:type
|
||||
{:this true
|
||||
@@ -240,16 +230,11 @@
|
||||
base))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :value "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(let [token (u/locate-token file-id set-id id)
|
||||
value (cond-> value
|
||||
(= :font-family (:type token))
|
||||
(ctob/convert-dtcg-font-family))]
|
||||
(st/emit! (dwtl/update-token set-id id {:value value})))))}
|
||||
(let [token (u/locate-token file-id set-id id)
|
||||
value (cond-> value
|
||||
(= :font-family (:type token))
|
||||
(ctob/convert-dtcg-font-family))]
|
||||
(st/emit! (dwtl/update-token set-id id {:value value}))))}
|
||||
|
||||
:resolvedValue
|
||||
{:this true
|
||||
@@ -280,43 +265,28 @@
|
||||
:schema cfo/schema:token-description
|
||||
:set
|
||||
(fn [_ value]
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :description "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit! (-> (dwtl/update-token set-id id {:description value})
|
||||
(se/add-event plugin-id)))))}
|
||||
(st/emit! (-> (dwtl/update-token set-id id {:description value})
|
||||
(se/add-event :plugin-id))))}
|
||||
|
||||
:duplicate
|
||||
(fn []
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :duplicate "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
;; TODO:
|
||||
;; - add function duplicate-token in tokens-lib, that allows to specify the new id
|
||||
;; - use this function in dwtl/duplicate-token
|
||||
;; - return the new token proxy using the locally forced id
|
||||
;; - do the same with sets and themes
|
||||
(let [token (u/locate-token file-id set-id id)
|
||||
token' (ctob/make-token (-> (datafy token)
|
||||
(dissoc :id
|
||||
:modified-at)))]
|
||||
(st/emit! (-> (dwtl/create-token set-id token')
|
||||
(se/add-event plugin-id)))
|
||||
(token-proxy plugin-id file-id set-id (:id token')))))
|
||||
;; TODO:
|
||||
;; - add function duplicate-token in tokens-lib, that allows to specify the new id
|
||||
;; - use this function in dwtl/duplicate-token
|
||||
;; - return the new token proxy using the locally forced id
|
||||
;; - do the same with sets and themes
|
||||
(let [token (u/locate-token file-id set-id id)
|
||||
token' (ctob/make-token (-> (datafy token)
|
||||
(dissoc :id
|
||||
:modified-at)))]
|
||||
(st/emit! (-> (dwtl/create-token set-id token')
|
||||
(se/add-event plugin-id)))
|
||||
(token-proxy plugin-id file-id set-id (:id token'))))
|
||||
|
||||
:remove
|
||||
(fn []
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit! (-> (dwtl/delete-token set-id id)
|
||||
(se/add-event plugin-id)))))
|
||||
(st/emit! (-> (dwtl/delete-token set-id id)
|
||||
(se/add-event plugin-id))))
|
||||
|
||||
:applyToShapes
|
||||
{:enumerable false
|
||||
@@ -367,13 +337,8 @@
|
||||
id)
|
||||
:set
|
||||
(fn [_ name]
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(let [set (u/locate-token-set file-id id)]
|
||||
(st/emit! (dwtl/rename-token-set set name)))))}
|
||||
(let [set (u/locate-token-set file-id id)]
|
||||
(st/emit! (dwtl/rename-token-set set name))))}
|
||||
|
||||
:active
|
||||
{:this true
|
||||
@@ -386,23 +351,13 @@
|
||||
:schema ::sm/boolean
|
||||
:set
|
||||
(fn [_ value]
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :active "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(let [set (u/locate-token-set file-id id)]
|
||||
(st/emit! (dwtl/set-enabled-token-set (ctob/get-name set) value)))))}
|
||||
(let [set (u/locate-token-set file-id id)]
|
||||
(st/emit! (dwtl/set-enabled-token-set (ctob/get-name set) value))))}
|
||||
|
||||
:toggleActive
|
||||
(fn []
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :toggleActive "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(let [set (u/locate-token-set file-id id)]
|
||||
(st/emit! (dwtl/toggle-token-set (ctob/get-name set))))))
|
||||
(fn [_]
|
||||
(let [set (u/locate-token-set file-id id)]
|
||||
(st/emit! (dwtl/toggle-token-set (ctob/get-name set)))))
|
||||
|
||||
:tokens
|
||||
{:this true
|
||||
@@ -461,54 +416,39 @@
|
||||
(sm/update-properties assoc :decode/json cfo/convert-dtcg-token))]))
|
||||
:decode/options {:key-fn identity}
|
||||
:fn (fn [attrs]
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :addToken "Plugin doesn't have 'content:write' permission")
|
||||
(let [tokens-lib (u/locate-tokens-lib file-id)
|
||||
token (ctob/make-token attrs)
|
||||
;; Resolve against all tokens in the library (including those
|
||||
;; in inactive sets) so that references to structurally
|
||||
;; existing tokens resolve even if their set is not active.
|
||||
;; The target set's tokens take precedence over equally named
|
||||
;; tokens in other sets, and the new token takes precedence
|
||||
;; over all.
|
||||
tokens-tree (-> (merge (ctob/get-all-tokens-map tokens-lib)
|
||||
(ctob/get-tokens tokens-lib id))
|
||||
(assoc (:name token) token))
|
||||
resolved-tokens (ts/resolve-tokens tokens-tree)
|
||||
|
||||
:else
|
||||
(let [tokens-lib (u/locate-tokens-lib file-id)
|
||||
token (ctob/make-token attrs)
|
||||
;; Resolve against all tokens in the library (including those
|
||||
;; in inactive sets) so that references to structurally
|
||||
;; existing tokens resolve even if their set is not active.
|
||||
;; The target set's tokens take precedence over equally named
|
||||
;; tokens in other sets, and the new token takes precedence
|
||||
;; over all.
|
||||
tokens-tree (-> (merge (ctob/get-all-tokens-map tokens-lib)
|
||||
(ctob/get-tokens tokens-lib id))
|
||||
(assoc (:name token) token))
|
||||
resolved-tokens (ts/resolve-tokens tokens-tree)
|
||||
{:keys [errors resolved-value] :as resolved-token}
|
||||
(get resolved-tokens (:name token))]
|
||||
|
||||
{:keys [errors resolved-value] :as resolved-token}
|
||||
(get resolved-tokens (:name token))]
|
||||
|
||||
(if resolved-value
|
||||
(do (st/emit! (-> (dwtl/create-token id token)
|
||||
(se/add-event plugin-id)))
|
||||
(token-proxy plugin-id file-id id (:id token)))
|
||||
(do (u/not-valid plugin-id :addToken (str errors))
|
||||
nil)))))}
|
||||
(if resolved-value
|
||||
(do (st/emit! (-> (dwtl/create-token id token)
|
||||
(se/add-event plugin-id)))
|
||||
(token-proxy plugin-id file-id id (:id token)))
|
||||
(do (u/not-valid plugin-id :addToken (str errors))
|
||||
nil))))}
|
||||
|
||||
:duplicate
|
||||
(fn []
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :duplicate "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(let [id-ref (atom nil)]
|
||||
(st/emit! (dwtl/duplicate-token-set id {:id-ref id-ref}))
|
||||
(when (some? @id-ref)
|
||||
(token-set-proxy plugin-id file-id @id-ref)))))
|
||||
(let [id-ref (atom nil)]
|
||||
(st/emit! (dwtl/duplicate-token-set id {:id-ref id-ref}))
|
||||
(when (some? @id-ref)
|
||||
(token-set-proxy plugin-id file-id @id-ref))))
|
||||
|
||||
:remove
|
||||
(fn []
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit! (dwtl/delete-token-set id)))))))
|
||||
(st/emit! (dwtl/delete-token-set id))))))
|
||||
|
||||
(defn token-theme-proxy? [p]
|
||||
(obj/type-of? p "TokenThemeProxy"))
|
||||
@@ -561,13 +501,8 @@
|
||||
(:id theme)))
|
||||
:set
|
||||
(fn [_ group]
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :group "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(let [theme (u/locate-token-theme file-id id)]
|
||||
(st/emit! (dwtl/update-token-theme id (assoc theme :group group))))))}
|
||||
(let [theme (u/locate-token-theme file-id id)]
|
||||
(st/emit! (dwtl/update-token-theme id (assoc theme :group group)))))}
|
||||
|
||||
:name
|
||||
{:this true
|
||||
@@ -582,14 +517,9 @@
|
||||
(:group theme)))
|
||||
:set
|
||||
(fn [_ name]
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(let [theme (u/locate-token-theme file-id id)]
|
||||
(when name
|
||||
(st/emit! (dwtl/update-token-theme id (assoc theme :name name)))))))}
|
||||
(let [theme (u/locate-token-theme file-id id)]
|
||||
(when name
|
||||
(st/emit! (dwtl/update-token-theme id (assoc theme :name name))))))}
|
||||
|
||||
:active
|
||||
{:this true
|
||||
@@ -601,21 +531,11 @@
|
||||
:schema ::sm/boolean
|
||||
:set
|
||||
(fn [_ value]
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :active "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit! (dwtl/set-token-theme-active id value))))}
|
||||
(st/emit! (dwtl/set-token-theme-active id value)))}
|
||||
|
||||
:toggleActive
|
||||
(fn []
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :toggleActive "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit! (dwtl/toggle-token-theme-active id))))
|
||||
(fn [_]
|
||||
(st/emit! (dwtl/toggle-token-theme-active id)))
|
||||
|
||||
:activeSets
|
||||
{:this true
|
||||
@@ -634,52 +554,32 @@
|
||||
{:enumerable false
|
||||
:schema [:tuple [:or [:fn token-set-proxy?] ::sm/uuid]]
|
||||
:fn (fn [set-arg]
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :addSet "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(let [set-name (token-set-name (resolve-token-set file-id set-arg))
|
||||
theme (u/locate-token-theme file-id id)]
|
||||
(when (and set-name theme)
|
||||
(st/emit! (dwtl/update-token-theme id (ctob/enable-set theme set-name)))))))}
|
||||
(let [set-name (token-set-name (resolve-token-set file-id set-arg))
|
||||
theme (u/locate-token-theme file-id id)]
|
||||
(when (and set-name theme)
|
||||
(st/emit! (dwtl/update-token-theme id (ctob/enable-set theme set-name))))))}
|
||||
|
||||
:removeSet
|
||||
{:enumerable false
|
||||
:schema [:tuple [:or [:fn token-set-proxy?] ::sm/uuid]]
|
||||
:fn (fn [set-arg]
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :removeSet "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(let [set-name (token-set-name (resolve-token-set file-id set-arg))
|
||||
theme (u/locate-token-theme file-id id)]
|
||||
(when (and set-name theme)
|
||||
(st/emit! (dwtl/update-token-theme id (ctob/disable-set theme set-name)))))))}
|
||||
(let [set-name (token-set-name (resolve-token-set file-id set-arg))
|
||||
theme (u/locate-token-theme file-id id)]
|
||||
(when (and set-name theme)
|
||||
(st/emit! (dwtl/update-token-theme id (ctob/disable-set theme set-name))))))}
|
||||
|
||||
:duplicate
|
||||
(fn []
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :duplicate "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(let [theme (u/locate-token-theme file-id id)
|
||||
theme' (ctob/make-token-theme (-> (datafy theme)
|
||||
(dissoc :id
|
||||
:modified-at)))]
|
||||
(st/emit! (dwtl/create-token-theme theme'))
|
||||
(token-theme-proxy plugin-id file-id (:id theme')))))
|
||||
(let [theme (u/locate-token-theme file-id id)
|
||||
theme' (ctob/make-token-theme (-> (datafy theme)
|
||||
(dissoc :id
|
||||
:modified-at)))]
|
||||
(st/emit! (dwtl/create-token-theme theme'))
|
||||
(token-theme-proxy plugin-id file-id (:id theme'))))
|
||||
|
||||
:remove
|
||||
(fn []
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit! (dwtl/delete-token-theme id))))))
|
||||
(st/emit! (dwtl/delete-token-theme id)))))
|
||||
|
||||
(defn tokens-catalog
|
||||
[plugin-id file-id]
|
||||
@@ -719,14 +619,9 @@
|
||||
nil)
|
||||
(sm/dissoc-key :id))]) ;; We don't allow plugins to set the id
|
||||
:fn (fn [attrs]
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :addTheme "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(let [theme (ctob/make-token-theme attrs)]
|
||||
(st/emit! (dwtl/create-token-theme theme))
|
||||
(token-theme-proxy plugin-id file-id (:id theme)))))}
|
||||
(let [theme (ctob/make-token-theme attrs)]
|
||||
(st/emit! (dwtl/create-token-theme theme))
|
||||
(token-theme-proxy plugin-id file-id (:id theme))))}
|
||||
|
||||
:addSet
|
||||
{:enumerable false
|
||||
@@ -743,26 +638,21 @@
|
||||
(sm/merge [:map [:active {:optional true} ::sm/boolean]]))]
|
||||
|
||||
:fn (fn [attrs]
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :addSet "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(let [active? (boolean (:active attrs))
|
||||
attrs (-> attrs
|
||||
(dissoc :active)
|
||||
(update :name ctob/normalize-set-name))
|
||||
set (ctob/make-token-set attrs)]
|
||||
(st/emit! (dwtl/create-token-set set))
|
||||
;; Newly created sets are inactive by default; activate it when
|
||||
;; requested. Enabling only adds the set name to the hidden theme,
|
||||
;; so it does not depend on the create event having propagated yet.
|
||||
(when active?
|
||||
(st/emit! (dwtl/set-enabled-token-set (ctob/get-name set) true)))
|
||||
;; Pass the set name as `initial-name` so the proxy can resolve
|
||||
;; it immediately, before the async `st/emit!` above propagates
|
||||
;; the new set into `@st/state`.
|
||||
(token-set-proxy plugin-id file-id (ctob/get-id set) (ctob/get-name set)))))}
|
||||
(let [active? (boolean (:active attrs))
|
||||
attrs (-> attrs
|
||||
(dissoc :active)
|
||||
(update :name ctob/normalize-set-name))
|
||||
set (ctob/make-token-set attrs)]
|
||||
(st/emit! (dwtl/create-token-set set))
|
||||
;; Newly created sets are inactive by default; activate it when
|
||||
;; requested. Enabling only adds the set name to the hidden theme,
|
||||
;; so it does not depend on the create event having propagated yet.
|
||||
(when active?
|
||||
(st/emit! (dwtl/set-enabled-token-set (ctob/get-name set) true)))
|
||||
;; Pass the set name as `initial-name` so the proxy can resolve
|
||||
;; it immediately, before the async `st/emit!` above propagates
|
||||
;; the new set into `@st/state`.
|
||||
(token-set-proxy plugin-id file-id (ctob/get-id set) (ctob/get-name set))))}
|
||||
|
||||
:getThemeById
|
||||
{:enumerable false
|
||||
|
||||
@@ -880,25 +880,6 @@
|
||||
(h/call wasm/internal-module "_store_image")
|
||||
true)))))
|
||||
|
||||
(defn- store-image-url!
|
||||
"Registers the public URL an image was loaded from so SVG export can emit a
|
||||
linked `<image href>` instead of a Skia base64 embed."
|
||||
[image-id url]
|
||||
(when (and (wasm/live?) (some? url) (not (str/blank? url)))
|
||||
(let [buffer (uuid/get-u32 image-id)
|
||||
encoder (js/TextEncoder.)
|
||||
encoded (.encode encoder url)
|
||||
size (.-byteLength encoded)
|
||||
offset (mem/alloc size)
|
||||
heap (mem/get-heap-u8)]
|
||||
(.set heap encoded offset)
|
||||
(h/call wasm/internal-module "_store_image_url"
|
||||
(aget buffer 0)
|
||||
(aget buffer 1)
|
||||
(aget buffer 2)
|
||||
(aget buffer 3))
|
||||
true)))
|
||||
|
||||
(defn- store-image-texture
|
||||
"Creates a WebGL texture from a decoded image and passes the texture ID to
|
||||
WASM. This avoids decoding the image twice (once in browser, once in WASM)."
|
||||
@@ -941,7 +922,6 @@
|
||||
so Skia rasterizes them."
|
||||
[shape-id image-id thumbnail?]
|
||||
(let [url (cf/resolve-file-media {:id image-id} thumbnail?)]
|
||||
(store-image-url! image-id url)
|
||||
{:key url
|
||||
:thumbnail? thumbnail?
|
||||
:callback
|
||||
@@ -979,8 +959,6 @@
|
||||
(aget buffer 2)
|
||||
(aget buffer 3)
|
||||
thumbnail?)]
|
||||
;; Always register the URL (SVG export needs it even when bytes are cached).
|
||||
(store-image-url! id (cf/resolve-file-media {:id id} thumbnail?))
|
||||
(when (zero? cached-image?)
|
||||
(fetch-image shape-id id thumbnail?)))))
|
||||
|
||||
@@ -1015,7 +993,6 @@
|
||||
(aget buffer 2)
|
||||
(aget buffer 3)
|
||||
thumbnail?)]
|
||||
(store-image-url! id (cf/resolve-file-media {:id id} thumbnail?))
|
||||
(when (zero? cached-image?)
|
||||
(fetch-image shape-id id thumbnail?))))
|
||||
(types.fills/get-image-ids fills))))))
|
||||
@@ -1044,7 +1021,6 @@
|
||||
(aget buffer 2)
|
||||
(aget buffer 3)
|
||||
thumbnail?)]
|
||||
(store-image-url! image-id (cf/resolve-file-media {:id image-id} thumbnail?))
|
||||
(when (zero? cached-image?)
|
||||
(fetch-image shape-id image-id thumbnail?))))
|
||||
image-ids))))
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns frontend-tests.plugins.flex-test
|
||||
(:require
|
||||
[app.common.types.shape.layout :as ctl]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.store :as st]
|
||||
[app.plugins.flex :as flex]
|
||||
[app.plugins.register :as r]
|
||||
[app.plugins.shape :as shape]
|
||||
[app.plugins.utils :as u]
|
||||
[cljs.test :as t :include-macros true]
|
||||
[frontend-tests.helpers.mock :as mock]))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Permission checks (T9-F-05)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(t/deftest flex-remove-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [r/check-permission (constantly false)
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (flex/flex-layout-proxy plugin-id file-id page-id id)]
|
||||
(.remove proxy)
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"]
|
||||
(first @errors)))))))
|
||||
|
||||
;; TODO: flex-append-child-checks-permission test requires more complex mocking
|
||||
;; of u/locate-objects, u/locate-shape, ctl/reverse?, etc. The permission check
|
||||
;; is in place at flex.cljs line 358.
|
||||
@@ -6,11 +6,8 @@
|
||||
|
||||
(ns frontend-tests.plugins.library-test
|
||||
(:require
|
||||
[app.common.types.component :as ctk]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.workspace.libraries :as dwl]
|
||||
[app.main.data.workspace.texts :as dwt]
|
||||
[app.main.data.workspace.variants :as dwv]
|
||||
[app.main.store :as st]
|
||||
[app.plugins.library :as library]
|
||||
[app.plugins.register :as r]
|
||||
@@ -96,112 +93,3 @@
|
||||
(t/is (contains? (:color @captured) :image))
|
||||
(t/is (not (contains? (:color @captured) :color)))
|
||||
(t/is (not (contains? (:color @captured) :gradient))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Permission checks (T9-F-02)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(t/deftest variant-add-variant-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [r/check-permission (constantly false)
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (library/variant-proxy plugin-id file-id id)]
|
||||
(.addVariant proxy)
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :addVariant "Plugin doesn't have 'library:write' permission"]
|
||||
(first @errors)))))))
|
||||
|
||||
(t/deftest variant-add-property-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [r/check-permission (constantly false)
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (library/variant-proxy plugin-id file-id id)]
|
||||
(.addProperty proxy)
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :addProperty "Plugin doesn't have 'library:write' permission"]
|
||||
(first @errors)))))))
|
||||
|
||||
(t/deftest variant-remove-property-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [r/check-permission (constantly false)
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
library/get-variant-components (constantly [{:variant-properties [{:name "color" :value "red"}]}])
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (library/variant-proxy plugin-id file-id id)]
|
||||
(.removeProperty proxy 0)
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :removeProperty "Plugin doesn't have 'library:write' permission"]
|
||||
(first @errors)))))))
|
||||
|
||||
(t/deftest variant-rename-property-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [r/check-permission (constantly false)
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
library/get-variant-components (constantly [{:variant-properties [{:name "color" :value "red"}]}])
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (library/variant-proxy plugin-id file-id id)]
|
||||
(.renameProperty proxy 0 "newName")
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :renameProperty "Plugin doesn't have 'library:write' permission"]
|
||||
(first @errors)))))))
|
||||
|
||||
(t/deftest component-transform-in-variant-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [r/check-permission (constantly false)
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
u/locate-library-component (constantly {:id id :main-instance-id id})
|
||||
ctk/is-variant? (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (library/lib-component-proxy plugin-id file-id id)]
|
||||
(.transformInVariant proxy)
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :transformInVariant "Plugin doesn't have 'library:write' permission"]
|
||||
(first @errors)))))))
|
||||
|
||||
(t/deftest component-add-variant-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [r/check-permission (constantly false)
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
u/locate-library-component (constantly {:id id :main-instance-id id})
|
||||
ctk/is-variant? (constantly true)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (library/lib-component-proxy plugin-id file-id id)]
|
||||
(.addVariant proxy)
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :addVariant "Plugin doesn't have 'library:write' permission"]
|
||||
(first @errors)))))))
|
||||
|
||||
(t/deftest component-set-variant-property-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [r/check-permission (constantly false)
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
u/locate-library-component (constantly {:id id :variant-properties [{:name "color"}]})
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (library/lib-component-proxy plugin-id file-id id)]
|
||||
(.setVariantProperty proxy 0 "red")
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :setVariantProperty "Plugin doesn't have 'library:write' permission"]
|
||||
(first @errors)))))))
|
||||
@@ -9,17 +9,12 @@
|
||||
[app.common.test-helpers.files :as cthf]
|
||||
[app.common.test-helpers.ids-map :as thi]
|
||||
[app.common.test-helpers.shapes :as cths]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.workspace.pages :as dwpg]
|
||||
[app.main.store :as st]
|
||||
[app.plugins.api :as api]
|
||||
[app.plugins.page :as page]
|
||||
[app.plugins.register :as r]
|
||||
[app.plugins.shape :as shape]
|
||||
[app.plugins.utils :as u]
|
||||
[app.util.object :as obj]
|
||||
[cljs.test :as t :include-macros true]
|
||||
[frontend-tests.helpers.mock :as mock]
|
||||
[frontend-tests.helpers.state :as ths]
|
||||
[frontend-tests.helpers.wasm :as thw]
|
||||
[potok.v2.core :as ptk]))
|
||||
@@ -156,85 +151,3 @@
|
||||
(done))))
|
||||
(mock-page-initialized store page2-id))
|
||||
0))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Permission checks (T9-F-03)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(t/deftest flow-name-setter-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
flow-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [r/check-permission (constantly false)
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (page/flow-proxy plugin-id file-id page-id flow-id)]
|
||||
(set! (.-name proxy) "new-name")
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :name "Plugin doesn't have 'content:write' permission"]
|
||||
(first @errors)))))))
|
||||
|
||||
(t/deftest flow-starting-board-setter-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
flow-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [r/check-permission (constantly false)
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
st/emit! mock/noop
|
||||
shape/shape-proxy? (constantly true)]
|
||||
(let [proxy (page/flow-proxy plugin-id file-id page-id flow-id)]
|
||||
(set! (.-startingBoard proxy) #js {})
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :startingBoard "Plugin doesn't have 'content:write' permission"]
|
||||
(first @errors)))))))
|
||||
|
||||
(t/deftest flow-remove-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
flow-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [r/check-permission (constantly false)
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (page/flow-proxy plugin-id file-id page-id flow-id)]
|
||||
(.remove proxy)
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"]
|
||||
(first @errors)))))))
|
||||
|
||||
(t/deftest create-flow-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [r/check-permission (constantly false)
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
st/emit! mock/noop
|
||||
shape/shape-proxy? (constantly true)]
|
||||
(let [proxy (page/page-proxy plugin-id file-id page-id)
|
||||
frame #js {"$id" (uuid/next)}]
|
||||
(.createFlow proxy "flow-name" frame)
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :createFlow "Plugin doesn't have 'content:write' permission"]
|
||||
(first @errors)))))))
|
||||
|
||||
(t/deftest remove-flow-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
flow-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [r/check-permission (constantly false)
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
st/emit! mock/noop
|
||||
page/flow-proxy? (constantly true)]
|
||||
(let [proxy (page/page-proxy plugin-id file-id page-id)]
|
||||
(.removeFlow proxy (page/flow-proxy plugin-id file-id page-id flow-id))
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :removeFlow "Plugin doesn't have 'content:write' permission"]
|
||||
(first @errors)))))))
|
||||
@@ -11,15 +11,10 @@
|
||||
[app.common.types.component :as ctk]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.workspace :as dw]
|
||||
[app.main.data.workspace.interactions :as dwi]
|
||||
[app.main.data.workspace.libraries :as dwl]
|
||||
[app.main.data.workspace.texts :as dwt]
|
||||
[app.main.data.workspace.tokens.application :as dwta]
|
||||
[app.main.data.workspace.variants :as dwv]
|
||||
[app.main.store :as st]
|
||||
[app.plugins.api :as api]
|
||||
[app.plugins.public-utils :as public-utils]
|
||||
[app.plugins.register :as r]
|
||||
[app.plugins.shape :as shape]
|
||||
[app.plugins.utils :as u]
|
||||
[cljs.test :as t :include-macros true]
|
||||
@@ -209,202 +204,3 @@
|
||||
(t/deftest group-empty-input-returns-nil
|
||||
(let [context (api/create-context plugin-id)]
|
||||
(t/is (nil? (.group context #js [])))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Permission checks (T9-F-04)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(t/deftest commit-fills-text-shape-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
shape-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/proxy->shape (constantly {:id shape-id :type :text})
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)]
|
||||
(set! (.-fills proxy) #js [#js {:fillColor "#ff0000" :fillOpacity 1}])
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :fills "Plugin doesn't have 'content:write' permission"]
|
||||
(first @errors)))))))
|
||||
|
||||
(t/deftest interaction-trigger-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
shape-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)]
|
||||
(set! (.-trigger inter) "click")
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :trigger "Plugin doesn't have 'content:write' permission"]
|
||||
(first @errors)))))))
|
||||
|
||||
(t/deftest interaction-delay-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
shape-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)]
|
||||
(set! (.-delay inter) 100)
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :delay "Plugin doesn't have 'content:write' permission"]
|
||||
(first @errors)))))))
|
||||
|
||||
(t/deftest interaction-action-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
shape-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/proxy->interaction (constantly {:event-type :click :delay 0 :action-type :open-url :url "https://example.com"})
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)]
|
||||
(set! (.-action inter) #js {:type "open-url" :url "https://example.com"})
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :action "Plugin doesn't have 'content:write' permission"]
|
||||
(first @errors)))))))
|
||||
|
||||
(t/deftest interaction-remove-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
shape-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)]
|
||||
(.remove inter)
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"]
|
||||
(first @errors)))))))
|
||||
|
||||
(t/deftest add-interaction-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
shape-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/locate-shape (constantly {:id shape-id})
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)]
|
||||
(.addInteraction proxy "click" #js {:type "open-url" :url "https://example.com"})
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :addInteraction "Plugin doesn't have 'content:write' permission"]
|
||||
(first @errors)))))))
|
||||
|
||||
(t/deftest remove-interaction-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
shape-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)
|
||||
inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)]
|
||||
(.removeInteraction proxy inter)
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :removeInteraction "Plugin doesn't have 'content:write' permission"]
|
||||
(first @errors)))))))
|
||||
|
||||
(t/deftest detach-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
shape-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/page-active? (constantly true)
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)]
|
||||
(.detach proxy)
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :detach "Plugin doesn't have 'content:write' permission"]
|
||||
(first @errors)))))))
|
||||
|
||||
(t/deftest export-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
shape-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)]
|
||||
(let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)]
|
||||
(.export proxy #js {:type "png" :scale 1})
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :export "Plugin doesn't have 'content:read' permission"]
|
||||
(first @errors)))))))
|
||||
|
||||
(t/deftest apply-token-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
shape-id (uuid/next)
|
||||
set-id (uuid/next)
|
||||
token-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/locate-token (constantly {:id token-id :name "test" :type :color})
|
||||
shape/token-proxy? (constantly true)
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)
|
||||
token #js {"$set-id" (str set-id) "$id" (str token-id)}]
|
||||
(.applyToken proxy token #js [])
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :applyToken "Plugin doesn't have 'content:write' permission"]
|
||||
(first @errors)))))))
|
||||
|
||||
(t/deftest switch-variant-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
shape-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/locate-shape (constantly {:id shape-id :component-id shape-id})
|
||||
u/locate-library-component (constantly {:id (uuid/next)})
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)]
|
||||
(.switchVariant proxy 0 "value")
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :switchVariant "Plugin doesn't have 'content:write' permission"]
|
||||
(first @errors)))))))
|
||||
|
||||
(t/deftest combine-as-variants-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
shape-id (uuid/next)
|
||||
other-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/locate-shape (fn [_file _page id] {:id id :component-id id})
|
||||
u/locate-library-component (constantly {:id (uuid/next)})
|
||||
ctk/is-variant? (constantly false)
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)]
|
||||
(.combineAsVariants proxy #js [(str other-id)])
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :combineAsVariants "Plugin doesn't have 'content:write' permission"]
|
||||
(first @errors)))))))
|
||||
@@ -16,7 +16,6 @@
|
||||
[app.main.data.workspace.tokens.library-edit :as dwtl]
|
||||
[app.main.store :as st]
|
||||
[app.plugins.api :as api]
|
||||
[app.plugins.register :as r]
|
||||
[app.plugins.tokens :as ptok]
|
||||
[app.plugins.utils :as u]
|
||||
[cljs.test :as t :include-macros true]
|
||||
@@ -237,8 +236,7 @@
|
||||
set-id (cthi/new-id! :set)
|
||||
dup-id (cthi/new-id! :dup)
|
||||
proxy (ptok/token-set-proxy "plugin-id" file-id set-id)]
|
||||
(with-redefs [r/check-permission (constantly true)
|
||||
dwtl/duplicate-token-set
|
||||
(with-redefs [dwtl/duplicate-token-set
|
||||
(mock/stub (fn [id {:keys [id-ref]}]
|
||||
(t/is (= set-id id))
|
||||
(reset! id-ref dup-id)
|
||||
@@ -255,8 +253,7 @@
|
||||
set (ptok/token-set-proxy "plugin-id" file-id set-id "Primitives")
|
||||
theme (ptok/token-theme-proxy "plugin-id" file-id theme-id)
|
||||
captured (atom [])]
|
||||
(with-redefs [r/check-permission (constantly true)
|
||||
u/locate-token-theme
|
||||
(with-redefs [u/locate-token-theme
|
||||
(fn [_file _theme]
|
||||
(ctob/make-token-theme :id theme-id
|
||||
:name "Theme"
|
||||
@@ -277,8 +274,7 @@
|
||||
set-id (cthi/new-id! :set)
|
||||
token-id (cthi/new-id! :token)
|
||||
captured (atom nil)]
|
||||
(with-redefs [r/check-permission (constantly true)
|
||||
u/locate-token (constantly {:id token-id
|
||||
(with-redefs [u/locate-token (constantly {:id token-id
|
||||
:name "font.primary"
|
||||
:type :font-family
|
||||
:value ["Inter"]})
|
||||
@@ -351,8 +347,7 @@
|
||||
theme (ctob/make-token-theme :id theme-id :group "mode" :name "Light")
|
||||
emitted (atom [])
|
||||
invalid (atom [])]
|
||||
(with-redefs [r/check-permission (constantly true)
|
||||
u/locate-token-set (fn [_ id] (when (= id set-id) token-set))
|
||||
(with-redefs [u/locate-token-set (fn [_ id] (when (= id set-id) token-set))
|
||||
u/locate-token-theme (fn [_ id] (when (= id theme-id) theme))
|
||||
u/not-valid (fn [_ code value] (swap! invalid conj [code value]))
|
||||
dwtl/update-token-theme (fn [id theme] {:id id :theme theme})
|
||||
@@ -372,8 +367,7 @@
|
||||
theme (ctob/make-token-theme :id theme-id :group "mode" :name "Light")
|
||||
emitted (atom [])
|
||||
invalid (atom [])]
|
||||
(with-redefs [r/check-permission (constantly true)
|
||||
u/locate-token-set (fn [_ id] (when (= id set-id) token-set))
|
||||
(with-redefs [u/locate-token-set (fn [_ id] (when (= id set-id) token-set))
|
||||
u/locate-token-theme (fn [_ id] (when (= id theme-id) theme))
|
||||
u/not-valid (fn [_ code value] (swap! invalid conj [code value]))
|
||||
dwtl/update-token-theme (fn [id theme] {:id id :theme theme})
|
||||
@@ -406,304 +400,3 @@
|
||||
(t/is (= 2 (count @errors)))
|
||||
(t/is (every? #(instance? js/Error %) @errors))))))
|
||||
|
||||
;; ═══════════════════════════════════════════════════════════════
|
||||
;; Permission check tests (T9-F-01)
|
||||
;; ═══════════════════════════════════════════════════════════════
|
||||
|
||||
;; Note: token-proxy-name-setter-checks-permission test removed because
|
||||
;; schema validation runs before the permission check, making it impossible
|
||||
;; to test the permission check directly for setters with schemas.
|
||||
|
||||
(t/deftest token-proxy-value-setter-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
set-id (uuid/next)
|
||||
token-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/locate-token (constantly {:id token-id :name "test" :type :color})
|
||||
u/locate-tokens-lib (constantly nil)
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (ptok/token-proxy plugin-id file-id set-id token-id)]
|
||||
(set! (.-value proxy) "#ff0000")
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :value "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
|
||||
|
||||
(t/deftest token-proxy-description-setter-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
set-id (uuid/next)
|
||||
token-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/locate-token (constantly {:id token-id :name "test"})
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (ptok/token-proxy plugin-id file-id set-id token-id)]
|
||||
(set! (.-description proxy) "A description")
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :description "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
|
||||
|
||||
(t/deftest token-proxy-duplicate-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
set-id (uuid/next)
|
||||
token-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/locate-token (constantly {:id token-id :name "test" :type :color :value "#000"})
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (ptok/token-proxy plugin-id file-id set-id token-id)]
|
||||
(.duplicate proxy)
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :duplicate "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
|
||||
|
||||
(t/deftest token-proxy-remove-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
set-id (uuid/next)
|
||||
token-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (ptok/token-proxy plugin-id file-id set-id token-id)]
|
||||
(.remove proxy)
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
|
||||
|
||||
(t/deftest token-set-proxy-name-setter-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
set-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/locate-token-set (constantly {:id set-id :name "core"})
|
||||
u/locate-tokens-lib (constantly (ctob/make-tokens-lib))
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (ptok/token-set-proxy plugin-id file-id set-id "core")]
|
||||
(set! (.-name proxy) "new-core")
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :name "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
|
||||
|
||||
(t/deftest token-set-proxy-active-setter-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
set-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/locate-token-set (constantly {:id set-id :name "core"})
|
||||
u/locate-tokens-lib (constantly (ctob/make-tokens-lib))
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (ptok/token-set-proxy plugin-id file-id set-id "core")]
|
||||
(set! (.-active proxy) true)
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :active "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
|
||||
|
||||
(t/deftest token-set-proxy-toggle-active-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
set-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/locate-token-set (constantly {:id set-id :name "core"})
|
||||
u/locate-tokens-lib (constantly (ctob/make-tokens-lib))
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (ptok/token-set-proxy plugin-id file-id set-id)]
|
||||
(.toggleActive proxy)
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :toggleActive "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
|
||||
|
||||
(t/deftest token-set-proxy-add-token-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
set-id (uuid/next)
|
||||
tokens-lib (-> (ctob/make-tokens-lib)
|
||||
(ctob/add-set (ctob/make-token-set :id set-id :name "core")))
|
||||
errors (atom [])]
|
||||
(with-redefs [u/locate-token-set (constantly {:id set-id :name "core"})
|
||||
u/locate-tokens-lib (constantly tokens-lib)
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (ptok/token-set-proxy plugin-id file-id set-id "core")]
|
||||
(t/is (fn? (.-addToken proxy)))
|
||||
(.addToken proxy #js {"type" "color" "name" "color.test" "value" "#FF0000"})
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :addToken "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
|
||||
|
||||
(t/deftest token-set-proxy-duplicate-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
set-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (ptok/token-set-proxy plugin-id file-id set-id)]
|
||||
(.duplicate proxy)
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :duplicate "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
|
||||
|
||||
(t/deftest token-set-proxy-remove-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
set-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (ptok/token-set-proxy plugin-id file-id set-id)]
|
||||
(.remove proxy)
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
|
||||
|
||||
(t/deftest token-theme-proxy-group-setter-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
theme-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/locate-token-theme (constantly {:id theme-id :name "Light" :group "mode"})
|
||||
u/locate-tokens-lib (constantly nil)
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)]
|
||||
(set! (.-group proxy) "new-group")
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :group "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
|
||||
|
||||
(t/deftest token-theme-proxy-name-setter-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
theme-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/locate-token-theme (constantly {:id theme-id :name "Light" :group "mode"})
|
||||
u/locate-tokens-lib (constantly nil)
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)]
|
||||
(set! (.-name proxy) "Dark")
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :name "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
|
||||
|
||||
(t/deftest token-theme-proxy-active-setter-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
theme-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/locate-tokens-lib (constantly (ctob/make-tokens-lib))
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)]
|
||||
(set! (.-active proxy) true)
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :active "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
|
||||
|
||||
(t/deftest token-theme-proxy-toggle-active-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
theme-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)]
|
||||
(.toggleActive proxy)
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :toggleActive "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
|
||||
|
||||
(t/deftest token-theme-proxy-add-set-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
theme-id (uuid/next)
|
||||
set-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/locate-token-theme (constantly {:id theme-id :name "Light" :sets #{}})
|
||||
u/locate-token-set (constantly {:id set-id :name "core"})
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)
|
||||
set-proxy (ptok/token-set-proxy plugin-id file-id set-id "core")]
|
||||
(.addSet proxy set-proxy)
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :addSet "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
|
||||
|
||||
(t/deftest token-theme-proxy-remove-set-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
theme-id (uuid/next)
|
||||
set-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/locate-token-theme (constantly {:id theme-id :name "Light" :sets #{"core"}})
|
||||
u/locate-token-set (constantly {:id set-id :name "core"})
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)
|
||||
set-proxy (ptok/token-set-proxy plugin-id file-id set-id "core")]
|
||||
(.removeSet proxy set-proxy)
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :removeSet "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
|
||||
|
||||
(t/deftest token-theme-proxy-duplicate-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
theme-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/locate-token-theme (constantly {:id theme-id :name "Light" :group "mode"})
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)]
|
||||
(.duplicate proxy)
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :duplicate "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
|
||||
|
||||
(t/deftest token-theme-proxy-remove-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
theme-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)]
|
||||
(.remove proxy)
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
|
||||
|
||||
(t/deftest tokens-catalog-add-theme-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/locate-tokens-lib (constantly (ctob/make-tokens-lib))
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [catalog (ptok/tokens-catalog plugin-id file-id)]
|
||||
(.addTheme catalog #js {"name" "NewTheme" "group" "mode"})
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :addTheme "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
|
||||
|
||||
(t/deftest tokens-catalog-add-set-checks-permission
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
errors (atom [])]
|
||||
(with-redefs [u/locate-tokens-lib (constantly (ctob/make-tokens-lib))
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
r/check-permission (constantly false)
|
||||
st/emit! mock/noop]
|
||||
(let [catalog (ptok/tokens-catalog plugin-id file-id)]
|
||||
(.addSet catalog #js {"name" "NewSet"})
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= [plugin-id :addSet "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns frontend-tests.plugins.user-test
|
||||
(:require
|
||||
[app.main.data.comments :as dc]
|
||||
[app.main.store :as st]
|
||||
[app.plugins.api :as api]
|
||||
[app.plugins.comments :as comments]
|
||||
[app.plugins.file :as file]
|
||||
[app.plugins.register :as r]
|
||||
[cljs.test :as t :include-macros true]
|
||||
[frontend-tests.helpers.mock :as mock]))
|
||||
|
||||
(def ^:private plugin-id "00000000-0000-0000-0000-000000000000")
|
||||
|
||||
(t/deftest comment-thread-owner-returns-nil-without-user-read
|
||||
(let [owner-id (random-uuid)
|
||||
file-id (random-uuid)
|
||||
page-id (random-uuid)
|
||||
thread-id (random-uuid)
|
||||
thread (comments/comment-thread-proxy
|
||||
plugin-id
|
||||
file-id
|
||||
page-id
|
||||
{:id thread-id :owner-id owner-id})]
|
||||
(with-redefs [r/check-permission (constantly false)
|
||||
dc/get-owner (constantly {:id owner-id :fullname "Owner"})]
|
||||
(t/is (nil? (.-owner thread)))
|
||||
(t/is (nil? (.-user thread))))))
|
||||
|
||||
(t/deftest comment-reply-owner-returns-nil-without-user-read
|
||||
(let [owner-id (random-uuid)
|
||||
file-id (random-uuid)
|
||||
page-id (random-uuid)
|
||||
thread-id (random-uuid)
|
||||
reply-id (random-uuid)
|
||||
reply (comments/comment-proxy
|
||||
plugin-id
|
||||
file-id
|
||||
page-id
|
||||
thread-id
|
||||
{:id reply-id :owner-id owner-id})]
|
||||
(with-redefs [r/check-permission (constantly false)
|
||||
dc/get-owner (constantly {:id owner-id :fullname "Owner"})]
|
||||
(t/is (nil? (.-owner reply)))
|
||||
(t/is (nil? (.-user reply))))))
|
||||
|
||||
(t/deftest file-version-created-by-returns-nil-without-user-read
|
||||
(let [file-id (random-uuid)
|
||||
version-id (random-uuid)
|
||||
profile-id (random-uuid)
|
||||
version (file/file-version-proxy
|
||||
plugin-id
|
||||
file-id
|
||||
{profile-id {:id profile-id :fullname "User"}}
|
||||
{:id version-id
|
||||
:label "Version"
|
||||
:created-at (js/Date.)
|
||||
:profile-id profile-id})]
|
||||
(with-redefs [r/check-permission (constantly false)]
|
||||
(t/is (nil? (.-createdBy version))))))
|
||||
|
||||
(t/deftest get-current-user-returns-nil-without-user-read
|
||||
(let [ctx (api/create-context plugin-id)]
|
||||
(with-redefs [r/check-permission (constantly false)
|
||||
st/state (atom {:session-id (random-uuid)
|
||||
:profile {:id (random-uuid) :fullname "User"}})]
|
||||
(t/is (nil? (.getCurrentUser ctx))))))
|
||||
|
||||
(t/deftest get-active-users-returns-empty-without-user-read
|
||||
(let [ctx (api/create-context plugin-id)]
|
||||
(with-redefs [r/check-permission (constantly false)
|
||||
st/state (atom {:session-id (random-uuid)
|
||||
:profile {:id (random-uuid)}
|
||||
:workspace-presence {(random-uuid) {:id (random-uuid)}}})]
|
||||
(t/is (zero? (.-length (.getActiveUsers ctx)))))))
|
||||
@@ -49,7 +49,6 @@
|
||||
[frontend-tests.plugins.comments-test]
|
||||
[frontend-tests.plugins.context-shapes-test]
|
||||
[frontend-tests.plugins.file-test]
|
||||
[frontend-tests.plugins.flex-test]
|
||||
[frontend-tests.plugins.format-test]
|
||||
[frontend-tests.plugins.grid-test]
|
||||
[frontend-tests.plugins.interactions-test]
|
||||
@@ -61,7 +60,6 @@
|
||||
[frontend-tests.plugins.shape-bugfixes-test]
|
||||
[frontend-tests.plugins.text-test]
|
||||
[frontend-tests.plugins.tokens-test]
|
||||
[frontend-tests.plugins.user-test]
|
||||
[frontend-tests.plugins.utils-test]
|
||||
[frontend-tests.plugins.value-objects-test]
|
||||
[frontend-tests.render-dimensions-test]
|
||||
@@ -157,7 +155,6 @@
|
||||
'frontend-tests.plugins.comments-test
|
||||
'frontend-tests.plugins.context-shapes-test
|
||||
'frontend-tests.plugins.file-test
|
||||
'frontend-tests.plugins.flex-test
|
||||
'frontend-tests.plugins.format-test
|
||||
'frontend-tests.plugins.grid-test
|
||||
'frontend-tests.plugins.interactions-test
|
||||
@@ -169,7 +166,6 @@
|
||||
'frontend-tests.plugins.shape-bugfixes-test
|
||||
'frontend-tests.plugins.text-test
|
||||
'frontend-tests.plugins.tokens-test
|
||||
'frontend-tests.plugins.user-test
|
||||
'frontend-tests.plugins.utils-test
|
||||
'frontend-tests.plugins.value-objects-test
|
||||
'frontend-tests.render-wasm.process-objects-test
|
||||
|
||||
+1
-1
@@ -267,7 +267,7 @@ The Penpot MCP server can be configured using environment variables.
|
||||
| `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` |
|
||||
| `PENPOT_MCP_REPL_ENABLE` | Explicitly enable/disable the REPL server. Set to `true` to enable. When unset, defaults to the value of `PENPOT_MCP_DEVENV`. | (unset) |
|
||||
| `PENPOT_MCP_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` |
|
||||
| `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools in local single-user mode. Set to `true` to enable. | `false` |
|
||||
| `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools. Set to `true` to enable. | `false` |
|
||||
| `PENPOT_MCP_TOOL_TIMEOUT_S` | Timeout, in seconds, for tool calls dispatched to the Penpot plugin | `120` |
|
||||
| `PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS` | Maximum number of parallel export shape requests (multi-user mode only). | `0` (no limit) |
|
||||
| `PENPOT_MCP_REDIS_URI` | Redis connection URI (e.g. `redis://host:6379`) enabling multi-instance horizontal scaling via Redis pub/sub task routing (multi-user mode only). When unset, the server runs in single-instance mode, requiring the plugin and MCP client to connect to the same instance. | (unset) |
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { PenpotMcpServer, shouldRegisterDeveloperTools } from "./PenpotMcpServer";
|
||||
|
||||
test("registers developer tools in local devenv mode", () => {
|
||||
assert.equal(shouldRegisterDeveloperTools(true, false), true);
|
||||
});
|
||||
|
||||
test("does not register developer tools in multi-user devenv mode", () => {
|
||||
assert.equal(shouldRegisterDeveloperTools(true, true), false);
|
||||
});
|
||||
|
||||
test("does not register developer tools when devenv mode is disabled", () => {
|
||||
assert.equal(shouldRegisterDeveloperTools(false, false), false);
|
||||
});
|
||||
import { PenpotMcpServer } from "./PenpotMcpServer";
|
||||
|
||||
// ── Pure function tests ────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -50,13 +50,6 @@ class ToolInfo {
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether developer tools may be registered for the current server mode.
|
||||
*/
|
||||
export function shouldRegisterDeveloperTools(isDevEnv: boolean, isMultiUserMode: boolean): boolean {
|
||||
return isDevEnv && !isMultiUserMode;
|
||||
}
|
||||
|
||||
export class PenpotMcpServer {
|
||||
/**
|
||||
* Timeout, in minutes, for idle sessions (Streamable HTTP and SSE) before they are automatically closed and removed.
|
||||
@@ -266,7 +259,7 @@ export class PenpotMcpServer {
|
||||
if (this.isFileSystemAccessEnabled()) {
|
||||
toolInstances.push(new ImportImageTool(this));
|
||||
}
|
||||
if (shouldRegisterDeveloperTools(this.isDevEnv(), this.isMultiUserMode())) {
|
||||
if (this.isDevEnv()) {
|
||||
const nreplClient = new NreplClient();
|
||||
toolInstances.push(new CljsReplTool(this, nreplClient));
|
||||
toolInstances.push(new ImportPenpotFileTool(this, nreplClient));
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
- **plugins-runtime**: `Library.createComponent()` now rejects invalid input (an empty shape list, or a shape inside a component copy) with a validation error instead of returning a component proxy pointing at nothing.
|
||||
- **plugins-runtime**: Setting an individual padding/margin side (`leftPadding`, `topMargin`, …) now re-derives the padding/margin type, switching to `multiple` when the four sides stop being symmetric (so the value is actually painted) and back to `simple` once top/bottom and left/right are mirrored again.
|
||||
- **plugins-runtime**: Removed the premature deep-hardening of the host plugin context, which froze shared host functions (including `Function.prototype`) before SES override taming, causing `TypeError: Cannot assign to read only property 'toString'` on later host-side function extension. Related to #11001.
|
||||
|
||||
## 1.5.0 (2026-07-08)
|
||||
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { describe, it, vi, expect, beforeEach } from 'vitest';
|
||||
import { loadPlugin, setContextBuilder, getPlugins } from './load-plugin';
|
||||
import { createPlugin } from './create-plugin';
|
||||
import { ses } from './ses.js';
|
||||
import type { Context } from '@penpot/plugin-types';
|
||||
import type { Manifest } from './models/manifest.model.js';
|
||||
|
||||
vi.mock('./create-plugin', () => ({
|
||||
createPlugin: vi.fn(),
|
||||
}));
|
||||
|
||||
// NOTE: `./ses.js` is intentionally NOT mocked here: the test spies on the
|
||||
// real `ses.harden` to assert that `loadPlugin` never hardens the host
|
||||
// context.
|
||||
|
||||
describe('loadPlugin host context boundary (regression for #11001)', () => {
|
||||
let manifest: Manifest;
|
||||
|
||||
beforeEach(() => {
|
||||
manifest = {
|
||||
pluginId: 'test-plugin',
|
||||
name: 'Test Plugin',
|
||||
host: '',
|
||||
code: '',
|
||||
permissions: ['content:read'],
|
||||
};
|
||||
|
||||
vi.mocked(createPlugin).mockResolvedValue({
|
||||
plugin: {
|
||||
close: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
},
|
||||
} as unknown as Awaited<ReturnType<typeof createPlugin>>);
|
||||
});
|
||||
|
||||
it('does not freeze host-owned functions reachable through the context', async () => {
|
||||
const hostListener = function hostListener() {
|
||||
return 'host-value';
|
||||
};
|
||||
const nestedHostObject = {
|
||||
nestedFn() {
|
||||
return 'nested';
|
||||
},
|
||||
};
|
||||
const hostContext = {
|
||||
addListener: hostListener,
|
||||
nested: nestedHostObject,
|
||||
} as unknown as Context;
|
||||
|
||||
setContextBuilder(() => hostContext);
|
||||
|
||||
const hardenSpy = vi.spyOn(ses, 'harden');
|
||||
|
||||
await loadPlugin(manifest);
|
||||
|
||||
// The host context itself must be passed through untouched so the host
|
||||
// can keep modifying its own runtime objects (e.g. on page navigation).
|
||||
expect(createPlugin).toHaveBeenCalledWith(
|
||||
hostContext,
|
||||
manifest,
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Host-owned functions must remain extensible: page navigation and
|
||||
// runtime code may patch/augment them (e.g. assigning `toString` on a
|
||||
// wrapped listener). A deep `ses.harden(context)` here would freeze
|
||||
// them and turn such later assignments into
|
||||
// `TypeError: Cannot assign to read only property 'toString'`.
|
||||
expect(Object.isFrozen(hostListener)).toBe(false);
|
||||
expect(Object.isExtensible(hostListener)).toBe(true);
|
||||
expect(Object.isFrozen(nestedHostObject)).toBe(false);
|
||||
expect(() => {
|
||||
hostListener.toString = () => 'patched-by-host';
|
||||
}).not.toThrow();
|
||||
|
||||
expect(hardenSpy).not.toHaveBeenCalled();
|
||||
expect(getPlugins()).toHaveLength(1);
|
||||
|
||||
hardenSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,169 +0,0 @@
|
||||
import { describe, it, vi, expect, beforeAll } from 'vitest';
|
||||
import 'ses';
|
||||
import { loadPlugin, setContextBuilder, getPlugins } from './load-plugin';
|
||||
import type { Context } from '@penpot/plugin-types';
|
||||
import type { Manifest } from './models/manifest.model.js';
|
||||
|
||||
// Real initialization-path regression tests for #11001.
|
||||
//
|
||||
// NOTE: `./create-plugin`, `./plugin-manager` and
|
||||
// `./create-sandbox` are intentionally NOT mocked here. This spec exercises
|
||||
// the real `loadPlugin → createPlugin → createPluginManager → createSandbox`
|
||||
// path with the real SES implementation, mirroring the production
|
||||
// initialization order from `plugins-runtime/src/index.ts`:
|
||||
// repairIntrinsics (module load) → loadPlugin → createSandbox/hardenIntrinsics
|
||||
//
|
||||
// `hardenIntrinsics()` is deliberately NOT called up front: the first test
|
||||
// must run in the production window where only `repairIntrinsics` has run.
|
||||
// Tests run in declaration order; the later tests build on the locked-down
|
||||
// state the first real `loadPlugin` leaves behind (via `createSandbox`).
|
||||
//
|
||||
// Note on SES isolation: this suite depends on Vitest's default
|
||||
// file-level isolation. Each test file runs in a separate worker
|
||||
// process, so SES intrinsics frozen here do not leak into other
|
||||
// spec files.
|
||||
|
||||
const REPAIR_OPTIONS = {
|
||||
evalTaming: 'unsafeEval',
|
||||
stackFiltering: 'verbose',
|
||||
errorTaming: 'unsafe',
|
||||
consoleTaming: 'unsafe',
|
||||
errorTrapping: 'none',
|
||||
unhandledRejectionTrapping: 'none',
|
||||
};
|
||||
|
||||
function makeManifest(
|
||||
code: string,
|
||||
permissions: Manifest['permissions'],
|
||||
): Manifest {
|
||||
return {
|
||||
pluginId: 'test-plugin',
|
||||
name: 'Test Plugin',
|
||||
host: '',
|
||||
code,
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
|
||||
function makeHostFixture() {
|
||||
const listenerTypes: string[] = [];
|
||||
const listeners = new Map<symbol, string>();
|
||||
// Inline code (empty host + non-URL code) resolves without network, so no
|
||||
// fetch mock is needed. UI/modal APIs are never touched by the probe code.
|
||||
const createRectangle = vi.fn(() => ({ type: 'rectangle-marker' }));
|
||||
const selection: object[] = [{ id: 'shape-1' }];
|
||||
const context = {
|
||||
addListener: (type: string, _callback: (...args: unknown[]) => unknown) => {
|
||||
const id = Symbol(type);
|
||||
listeners.set(id, type);
|
||||
listenerTypes.push(type);
|
||||
return id;
|
||||
},
|
||||
removeListener: (id: symbol) => {
|
||||
listeners.delete(id);
|
||||
},
|
||||
theme: 'dark',
|
||||
createRectangle,
|
||||
selection,
|
||||
// Host-only member: present on the raw context but NOT part of the
|
||||
// public penpot API. Plugin code must never see it (see B-2 below).
|
||||
__internalSecret: 'host-internal',
|
||||
} as unknown as Context;
|
||||
return { context, listenerTypes, createRectangle, selection };
|
||||
}
|
||||
|
||||
function lastCompartmentGlobalThis(): Record<string, unknown> {
|
||||
const plugins = getPlugins();
|
||||
const last = plugins[plugins.length - 1] as unknown as {
|
||||
compartment: { compartment: { globalThis: Record<string, unknown> } };
|
||||
};
|
||||
return last.compartment.compartment.globalThis;
|
||||
}
|
||||
|
||||
describe('loadPlugin real initialization path (regression for #11001)', () => {
|
||||
beforeAll(() => {
|
||||
// Production module-load step only: repairs intrinsics WITHOUT
|
||||
// installing override taming, exactly like `index.ts` at import time.
|
||||
(
|
||||
globalThis as unknown as { repairIntrinsics(opts: object): void }
|
||||
).repairIntrinsics({ ...REPAIR_OPTIONS });
|
||||
});
|
||||
|
||||
it('loads through the real path and keeps host function augmentation working', async () => {
|
||||
const fixture = makeHostFixture();
|
||||
setContextBuilder(() => fixture.context);
|
||||
|
||||
await loadPlugin(
|
||||
makeManifest('penpot.on("finish", function () {});', ['content:read']),
|
||||
);
|
||||
|
||||
// The plugin code really ran inside the sandbox: the manager registers
|
||||
// `themechange` + `finish`, and the plugin code adds its own `finish`
|
||||
// listener through the public API.
|
||||
expect(fixture.listenerTypes).toEqual(['themechange', 'finish', 'finish']);
|
||||
expect(getPlugins()).toHaveLength(1);
|
||||
|
||||
// The user-facing behavior from #11001: host-side augmentation of a
|
||||
// fresh function (e.g. assigning `toString` during page navigation)
|
||||
// succeeds after a real plugin load.
|
||||
const freshWrapper = function freshWrapper() {
|
||||
return 'navigation-wrapper';
|
||||
};
|
||||
expect(() => {
|
||||
freshWrapper.toString = () => 'patched-by-runtime';
|
||||
}).not.toThrow();
|
||||
expect(fixture.listenerTypes.length).toBe(3);
|
||||
});
|
||||
|
||||
it('denies the write API without permission and leaves the host untouched', async () => {
|
||||
const fixture = makeHostFixture();
|
||||
setContextBuilder(() => fixture.context);
|
||||
|
||||
await expect(
|
||||
loadPlugin(makeManifest('penpot.createRectangle();', ['content:read'])),
|
||||
).rejects.toThrow(/content:write/);
|
||||
expect(fixture.createRectangle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows the same write API with permission', async () => {
|
||||
const fixture = makeHostFixture();
|
||||
setContextBuilder(() => fixture.context);
|
||||
|
||||
await loadPlugin(
|
||||
makeManifest('penpot.createRectangle();', [
|
||||
'content:read',
|
||||
'content:write',
|
||||
]),
|
||||
);
|
||||
expect(fixture.createRectangle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not expose raw host-only context members to plugin code', async () => {
|
||||
const fixture = makeHostFixture();
|
||||
setContextBuilder(() => fixture.context);
|
||||
|
||||
await loadPlugin(
|
||||
makeManifest('globalThis.__probe = typeof penpot.__internalSecret;', [
|
||||
'content:read',
|
||||
]),
|
||||
);
|
||||
// The public `penpot` object is a boundary proxy over a curated API, not
|
||||
// the raw host context, so host-only members are invisible inside.
|
||||
expect(lastCompartmentGlobalThis()['__probe']).toBe('undefined');
|
||||
});
|
||||
|
||||
it('keeps safeReturn protection on returned values without blocking allowed edits', async () => {
|
||||
const fixture = makeHostFixture();
|
||||
setContextBuilder(() => fixture.context);
|
||||
|
||||
await loadPlugin(
|
||||
makeManifest(
|
||||
'penpot.createRectangle(); ' +
|
||||
'globalThis.__selectionFrozen = Object.isFrozen(penpot.selection);',
|
||||
['content:read', 'content:write'],
|
||||
),
|
||||
);
|
||||
expect(fixture.createRectangle).toHaveBeenCalledTimes(1);
|
||||
expect(lastCompartmentGlobalThis()['__selectionFrozen']).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import type { Context } from '@penpot/plugin-types';
|
||||
import { loadManifest } from './parse-manifest.js';
|
||||
import { Manifest } from './models/manifest.model.js';
|
||||
import { createPlugin } from './create-plugin.js';
|
||||
import { ses } from './ses.js';
|
||||
|
||||
let plugins: Awaited<ReturnType<typeof createPlugin>>[] = [];
|
||||
|
||||
@@ -53,23 +54,8 @@ export const loadPlugin = async function (
|
||||
|
||||
closeAllPlugins();
|
||||
|
||||
// The host context is not deeply frozen at this load stage.
|
||||
//
|
||||
// The context still contains host-internal function objects and shared
|
||||
// prototypes that the host may legitimately extend after plugin load
|
||||
// (for example, by assigning custom properties). Deep-freezing here
|
||||
// would freeze those prototypes before SES override taming completes,
|
||||
// preventing later host-side mutations with a "Cannot assign to read
|
||||
// only property" TypeError.
|
||||
//
|
||||
// Responsibility boundary: this function forwards the context to the
|
||||
// sandbox layer without deep-freezing it. The public API that plugins
|
||||
// consume is constructed by the API module (`api/index.ts`), and
|
||||
// `createSandbox`'s proxy handler applies `ses.safeReturn` to values
|
||||
// crossing into the sandbox. Compartment isolation and intrinsics
|
||||
// hardening are performed by createSandbox, not here.
|
||||
const plugin = await createPlugin(
|
||||
context,
|
||||
ses.harden(context) as Context,
|
||||
manifest,
|
||||
() => {
|
||||
plugins = plugins.filter((api) => api !== plugin);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createPluginManager } from './plugin-manager';
|
||||
import { loadManifestCode, getValidUrl, prepareUrl } from './parse-manifest.js';
|
||||
import { PluginModalElement } from './modal/plugin-modal.js';
|
||||
import { openUIApi } from './api/openUI.api.js';
|
||||
import { validateUIUrl } from './validate-url.js';
|
||||
import type { Context, Theme } from '@penpot/plugin-types';
|
||||
import type { Manifest } from './models/manifest.model.js';
|
||||
|
||||
@@ -17,10 +16,6 @@ vi.mock('./api/openUI.api.js', () => ({
|
||||
openUIApi: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./validate-url.js', () => ({
|
||||
validateUIUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('createPluginManager', () => {
|
||||
let mockContext: Context;
|
||||
let manifest: Manifest;
|
||||
@@ -299,39 +294,4 @@ describe('createPluginManager', () => {
|
||||
expect(mockContext.removeListener).toHaveBeenCalled();
|
||||
expect(onCloseCallback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should validate the modal URL before opening', async () => {
|
||||
const pluginManager = await createPluginManager(
|
||||
mockContext,
|
||||
manifest,
|
||||
onCloseCallback,
|
||||
onReloadModal,
|
||||
);
|
||||
|
||||
pluginManager.openModal('Test Modal', '/test-url');
|
||||
|
||||
expect(validateUIUrl).toHaveBeenCalledWith(
|
||||
'https://example.com/plugin',
|
||||
manifest.host,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when URL validation fails', async () => {
|
||||
vi.mocked(validateUIUrl).mockImplementation(() => {
|
||||
throw new Error("Plugin UI URL must not point to Penpot's own domain");
|
||||
});
|
||||
|
||||
const pluginManager = await createPluginManager(
|
||||
mockContext,
|
||||
manifest,
|
||||
onCloseCallback,
|
||||
onReloadModal,
|
||||
);
|
||||
|
||||
expect(() => pluginManager.openModal('Test Modal', '/test-url')).toThrow(
|
||||
"Plugin UI URL must not point to Penpot's own domain",
|
||||
);
|
||||
|
||||
expect(openUIApi).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@ import { openUIApi } from './api/openUI.api.js';
|
||||
import { OpenUIOptions } from './models/open-ui-options.model.js';
|
||||
import { RegisterListener } from './models/plugin.model.js';
|
||||
import { openUISchema } from './models/open-ui-options.schema.js';
|
||||
import { validateUIUrl } from './validate-url.js';
|
||||
|
||||
export async function createPluginManager(
|
||||
context: Context,
|
||||
@@ -95,7 +94,6 @@ export async function createPluginManager(
|
||||
const openModal = (name: string, url: string, options?: OpenUIOptions) => {
|
||||
const theme = context.theme as Theme;
|
||||
const modalUrl = prepareUrl(manifest, url, { theme });
|
||||
validateUIUrl(modalUrl, manifest.host);
|
||||
|
||||
if (modal?.getAttribute('iframe-src') === modalUrl) {
|
||||
return;
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
getPenpotOrigin,
|
||||
isPenpotOrigin,
|
||||
validateUIUrl,
|
||||
} from './validate-url.js';
|
||||
|
||||
describe('validate-url', () => {
|
||||
const originalLocation = globalThis.location;
|
||||
const originalPenpotPublicURI = (globalThis as any).penpotPublicURI;
|
||||
const externalHost = 'https://example.com';
|
||||
|
||||
beforeEach(() => {
|
||||
delete (globalThis as any).penpotPublicURI;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPenpotPublicURI !== undefined) {
|
||||
(globalThis as any).penpotPublicURI = originalPenpotPublicURI;
|
||||
} else {
|
||||
delete (globalThis as any).penpotPublicURI;
|
||||
}
|
||||
});
|
||||
|
||||
describe('getPenpotOrigin', () => {
|
||||
it('should return location.origin when penpotPublicURI is not set', () => {
|
||||
expect(getPenpotOrigin()).toBe(originalLocation.origin);
|
||||
});
|
||||
|
||||
it('should return origin from penpotPublicURI when set', () => {
|
||||
(globalThis as any).penpotPublicURI = 'https://design.penpot.com/';
|
||||
expect(getPenpotOrigin()).toBe('https://design.penpot.com');
|
||||
});
|
||||
|
||||
it('should fall back to location.origin when penpotPublicURI is invalid', () => {
|
||||
(globalThis as any).penpotPublicURI = 'not-a-valid-url';
|
||||
expect(getPenpotOrigin()).toBe(originalLocation.origin);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPenpotOrigin', () => {
|
||||
it('should be true for a URL on Penpot origin', () => {
|
||||
expect(
|
||||
isPenpotOrigin(`${originalLocation.origin}/plugin/manifest.json`),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should be false for a URL on another origin', () => {
|
||||
expect(isPenpotOrigin(`${externalHost}/manifest.json`)).toBe(false);
|
||||
});
|
||||
|
||||
it('should be false for an unparseable URL', () => {
|
||||
expect(isPenpotOrigin('not-a-valid-url')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateUIUrl', () => {
|
||||
it('should throw when URL has same origin as location.origin', () => {
|
||||
const penpotOrigin = originalLocation.origin;
|
||||
expect(() =>
|
||||
validateUIUrl(`${penpotOrigin}/some/path`, externalHost),
|
||||
).toThrow("Plugin UI URL must not point to Penpot's own domain");
|
||||
});
|
||||
|
||||
it('should not throw when URL has different origin', () => {
|
||||
expect(() =>
|
||||
validateUIUrl('https://example.com/plugin-ui', externalHost),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw when URL matches penpotPublicURI origin', () => {
|
||||
(globalThis as any).penpotPublicURI = 'https://design.penpot.com/';
|
||||
expect(() =>
|
||||
validateUIUrl('https://design.penpot.com/some/path', externalHost),
|
||||
).toThrow("Plugin UI URL must not point to Penpot's own domain");
|
||||
});
|
||||
|
||||
it('should not throw when URL has same hostname but different port', () => {
|
||||
const url = new URL(originalLocation.origin);
|
||||
const differentPort = `${url.protocol}//${url.hostname}:9999`;
|
||||
expect(() =>
|
||||
validateUIUrl(`${differentPort}/path`, externalHost),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw even when URL has different path on same origin', () => {
|
||||
const penpotOrigin = originalLocation.origin;
|
||||
expect(() =>
|
||||
validateUIUrl(`${penpotOrigin}/deeply/nested/path`, externalHost),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should not throw when the manifest is served from Penpot origin', () => {
|
||||
const penpotOrigin = originalLocation.origin;
|
||||
expect(() =>
|
||||
validateUIUrl(`${penpotOrigin}/some/path`, `${penpotOrigin}/plugin`),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw when the manifest is served from penpotPublicURI origin', () => {
|
||||
(globalThis as any).penpotPublicURI = 'https://design.penpot.com/';
|
||||
expect(() =>
|
||||
validateUIUrl(
|
||||
'https://design.penpot.com/some/path',
|
||||
'https://design.penpot.com/plugin',
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should still throw when the manifest host is unparseable', () => {
|
||||
const penpotOrigin = originalLocation.origin;
|
||||
expect(() => validateUIUrl(`${penpotOrigin}/path`, '')).toThrow(
|
||||
"Plugin UI URL must not point to Penpot's own domain",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
export function getPenpotOrigin(): string {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const publicUri = (globalThis as any).penpotPublicURI;
|
||||
if (publicUri) {
|
||||
try {
|
||||
return new URL(publicUri).origin;
|
||||
} catch {
|
||||
// fall through to location.origin
|
||||
}
|
||||
}
|
||||
return globalThis.location.origin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the given URL is served from Penpot's own origin. Unparseable URLs
|
||||
* are considered external.
|
||||
*/
|
||||
export function isPenpotOrigin(url: string): boolean {
|
||||
try {
|
||||
return new URL(url).origin === getPenpotOrigin();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects UI URLs that resolve to Penpot's own origin, which would let the
|
||||
* plugin iframe escape its sandbox isolation.
|
||||
*
|
||||
* Plugins whose manifest is itself served from Penpot's origin are part of the
|
||||
* instance and are exempt from the check.
|
||||
*/
|
||||
export function validateUIUrl(url: string, manifestHost: string): void {
|
||||
if (isPenpotOrigin(manifestHost)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = new URL(url);
|
||||
if (parsed.origin === getPenpotOrigin()) {
|
||||
throw new Error(
|
||||
`Plugin UI URL must not point to Penpot's own domain: ${url}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@
|
||||
#
|
||||
# Text snapshots reference `fonts/sourcesanspro-regular.ttf`; this script copies
|
||||
# the bundled font into `target/svg-preview/fonts/` so the gallery renders text.
|
||||
# Image-fill snapshots reference `images/test-fill.svg`; same idea for fills.
|
||||
#
|
||||
# When a test produced a pending change there will be a `*.snap.new` next to the
|
||||
# accepted `*.snap`; the gallery then shows "accepted" vs "new" side by side.
|
||||
@@ -28,14 +27,10 @@ OUT_DIR="$SCRIPT_DIR/target/svg-preview"
|
||||
OUT="$OUT_DIR/index.html"
|
||||
FONT_SRC="$SCRIPT_DIR/src/fonts/sourcesanspro-regular.ttf"
|
||||
FONT_DIR="$OUT_DIR/fonts"
|
||||
IMAGE_SRC="$SCRIPT_DIR/src/render/svg/fixtures/test-fill.svg"
|
||||
IMAGE_DIR="$OUT_DIR/images"
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
mkdir -p "$FONT_DIR"
|
||||
mkdir -p "$IMAGE_DIR"
|
||||
cp "$FONT_SRC" "$FONT_DIR/"
|
||||
cp "$IMAGE_SRC" "$IMAGE_DIR/"
|
||||
|
||||
# Prints the SVG body of a snapshot file: everything after the second `---`
|
||||
# line (the YAML front matter insta writes).
|
||||
|
||||
@@ -720,12 +720,7 @@ impl RenderState {
|
||||
/// Renders background blur effect directly to the given target surface.
|
||||
/// Must be called BEFORE any save_layer for the shape's own opacity/blend,
|
||||
/// so that the backdrop blur is independent of the shape's visual properties.
|
||||
fn render_background_blur(
|
||||
&mut self,
|
||||
shape: &Shape,
|
||||
clip_bounds: Option<&ClipStack>,
|
||||
target_surface: SurfaceId,
|
||||
) {
|
||||
fn render_background_blur(&mut self, shape: &Shape, target_surface: SurfaceId) {
|
||||
if self.options.is_fast_mode() {
|
||||
return;
|
||||
}
|
||||
@@ -765,14 +760,8 @@ impl RenderState {
|
||||
matrix.post_translate(center);
|
||||
matrix.pre_translate(-center);
|
||||
|
||||
self.surfaces.canvas(target_surface).save();
|
||||
|
||||
if let Some(clips) = clip_bounds {
|
||||
let antialias = shape.should_use_antialias(scale, self.options.antialias_threshold);
|
||||
self.clip_target_surface_to_stack(clips, target_surface, scale, antialias);
|
||||
}
|
||||
|
||||
let canvas = self.surfaces.canvas(target_surface);
|
||||
canvas.save();
|
||||
|
||||
// Current/Export have no render context transform (identity canvas).
|
||||
// Apply scale + translate + shape transform so the clip maps
|
||||
@@ -3815,7 +3804,7 @@ impl RenderState {
|
||||
// Render background blur BEFORE save_layer so it modifies
|
||||
// the backdrop independently of the shape's opacity.
|
||||
if !node_render_state.is_root() && self.focus_mode.is_active() {
|
||||
self.render_background_blur(element, clip_bounds.as_ref(), target_surface);
|
||||
self.render_background_blur(element, target_surface);
|
||||
}
|
||||
|
||||
self.render_shape_enter(element, mask, clip_bounds.as_ref(), target_surface);
|
||||
|
||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user