Compare commits

..
Author SHA1 Message Date
Andrey Antukh 9e0a9905bd 🐛 Add bounds checking to remove-interaction to prevent crashes
The remove-interaction function used subvec without validating that
the index was within bounds, causing 'Index out of bounds' errors
when removing multiple interactions in rapid succession.

Add bounds validation to return the unchanged interactions vector
when an invalid index is provided. This prevents crashes while
maintaining data integrity and idempotent behavior.

Closes #11546

AI-assisted-by: qwen3.7-plus
2026-09-08 08:33:42 +00:00
Andrey Antukhandalonso.torres 937b3fc65f 🐛 Add missing permission checks to plugin API (tokens, shapes, variants, flows, layouts, user identity) (#11139)
* 🐛 Add content:write permission checks to Design Tokens plugin API

The Design Tokens API (tokens.cljs) had zero permission checks, allowing
any plugin to create, modify, and delete tokens, sets, and themes
regardless of granted permissions.

Add r/check-permission checks to all 22 write operations across:
- token-proxy: name, value, description, duplicate, remove, applyToken
- token-set-proxy: name, active, toggleActive, addToken, duplicate, remove
- token-theme-proxy: group, name, active, toggleActive, addSet, removeSet,
  duplicate, remove
- tokens-catalog: addTheme, addSet

Follows the established pattern from comments.cljs, file.cljs, page.cljs.

Closes #11137

AI-assisted-by: qwen3.7-plus

* 🐛 Add permission checks to shape proxy interactions, detach, export, and variants

The shape proxy (shape.cljs) had multiple operations missing permission
checks, plus a cond ordering bug that bypassed the existing content:write
check for text shapes in commit-fills!.

Fix commit-fills! cond ordering: move permission check before the
text-shape branch so text shapes are also protected.

Add content:write permission checks to:
- interaction-proxy: :trigger, :delay, :action setters, :remove method
- shape-proxy: :addInteraction, :removeInteraction, :detach
- shape-proxy: :applyToken, :switchVariant, :combineAsVariants

Add content:read permission check to:
- shape-proxy: :export (read/extraction operation)

Follows the established pattern from :resize, :rotate, :blocked setters.

Relates to #11137

AI-assisted-by: qwen3.7-plus

* 🐛 Add library:write permission checks to variant plugin API

The library.cljs variant operations (variant-proxy and
lib-component-proxy) had seven mutating operations that
did not check the library:write permission, allowing
any plugin to create, modify, and delete component
variants regardless of granted permissions.

Add r/check-permission checks to all 7 operations:
- variant-proxy: addVariant, addProperty,
  removeProperty, renameProperty
- lib-component-proxy: transformInVariant, addVariant,
  setVariantProperty

Follows the established pattern from the :name and
:path setters in the same file.

Relates to #11137

AI-assisted-by: qwen3.7-plus

* 🐛 Add content:write permission checks to flow and flex layout plugin API

Add permission checks to prototype flow and flex layout operations
that were missing them, allowing plugins to modify flows and layout
structure without explicit user permission.

Changes:
- page.cljs: Add content:write checks to flow-proxy (name,
  startingBoard setters, remove) and page-proxy (createFlow,
  removeFlow)
- flex.cljs: Add content:write checks to flex-layout-proxy
  (remove, appendChild)

Follows the established pattern from tokens.cljs, shape.cljs,
and library.cljs.

Relates to #11137

AI-assisted-by: qwen3.7-plus

* 🐛 Add user:read permission checks to plugin API

Add permission checks to user identity accessors that were bypassing
the consent model, allowing plugins to access user data regardless
of whether the user granted user:read permission.

Changes:
- api.cljs: Add user:read checks to getCurrentUser and getActiveUsers
- comments.cljs: Add user:read checks to comment-proxy and
  comment-thread-proxy owner/user getters
- file.cljs: Add user:read check to file-version-proxy createdBy getter

When user:read permission is not granted:
- getCurrentUser() returns null
- getActiveUsers() returns empty array
- owner/user/createdBy getters return null

Follows the established pattern from other permission checks in the
plugin API.

Relates to #11137

AI-assisted-by: qwen3.7-plus

* 🐛 Fix problem with token API

---------

Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
2026-09-08 09:24:45 +02:00
Andrey Antukhandalonso.torres 5f1e151e84 🐛 Validate plugin UI URLs cannot target Penpot's own domain (#11273)
* 🐛 Validate plugin UI URLs cannot target Penpot's own domain

The plugin UI iframe combines allow-scripts and allow-same-origin in
its sandbox. While necessary for plugins to use their own cookies and
storage, this creates a risk if a plugin's UI URL resolves to Penpot's
own origin, potentially allowing the iframe to escape sandbox isolation.

Add validateUIUrl() that checks the resolved URL against Penpot's
origin (from penpotPublicURI or location.origin) and throws if they
match. Called in openModal() after prepareUrl() resolves the URL.

Closes #11271

AI-assisted-by: qwen3.7-plus

* 💄 Fix prettier formatting in plugin-manager.spec.ts

Apply prettier formatting to fix format:check failure.

AI-assisted-by: qwen3.7-plus

* 🐛 Fix problem with penpot origin plugins

---------

Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
2026-09-08 09:23:20 +02:00
Pablo Alba ff63668c1e 🐛 Enforce SSRF checks and add timeouts to HTTP client (#11474) 2026-09-07 15:59:35 +02:00
Elena Torró 7b135b80b2 🐛 Fix background blur clipping (#11532) 2026-09-07 15:51:27 +02:00
Andrey Antukhandniwinz acc078064b 🐛 Do not register developer tools in multi-user MCP mode (#11310)
Prevent developer tools from being exposed when the MCP server runs in multi-user mode. Keep them available for local devenv usage and document the mode restriction. Add regression coverage for the registration policy.

Closes #11291

AI-assisted-by: gpt-5.6-luna

Co-authored-by: niwinz <843689+niwinz@users.noreply.github.com>
2026-09-07 13:19:32 +02:00
María Valderrama 77bf3ea419 🐛 Fix sso expiration time (#11528) 2026-09-07 11:56:25 +02:00
Eva Marco 5f6169e1d3 🐛 Fix typography sample errors (#11515)
* 🐛 Fix font-family sample not showing for numeric font names

Setting style.fontFamily to a raw, unquoted family name (e.g. "Micro
5") parses it against CSS's <family-name> grammar: a whitespace-
separated sequence of <custom-ident>s. "Micro" tokenizes fine, but a
bare "5" isn't a valid CSS identifier (idents can't start with a
digit) — it tokenizes as a number instead, so the whole property is
invalid CSS and the browser silently drops it. Every other font in
the list happened to avoid this because none of their names have a
token that's purely numeric.

Quote the family name, matching what font-item-preview* (the font
selector's own preview, a few lines down in the same file) already
does, so it's parsed as a CSS string instead of unquoted identifiers.

Also falls back to the live fontsdb entry's family when the
typography record'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 (the same failure mode remove-nil-style-attrs already
repairs for shape text spans) — and loads the font unconditionally in
the collapsed asset row, matching the expanded editor, since the
optical-offset cache can otherwise skip loading it entirely.

AI-assisted-by: claude-sonnet-5

* 🐛 Fix flaky typography sample position in automated tests

The optical-centering offset for the "Ag" sample (and the font
selector's fallback name label) resolves asynchronously: first paint
is unshifted, then an idle-scheduled Canvas measurement lands and the
sample jumps to its final position. Any test that checks position or
takes a screenshot shortly after paint races that jump — whether it
runs before or after is a timing accident, not a deterministic
outcome, which is exactly the "sometimes a few pixels up, sometimes
down" flakiness QA hit.

use-optical-offset now returns [offset ready?], with both lazily
initialized from the cache so a cache hit needs no async round-trip
at all. sample-text-style hides the glyphs until ready?, so the
sample only ever appears already in its final, correct position
instead of visibly moving there after the fact.

The font selector's own name label uses the same hook but always
shows real text content rather than a decorative sample, so it keeps
the old behavior instead: hiding it would blank out font names while
scrolling, worse than the minor positional nicety it's fixing.

AI-assisted-by: claude-sonnet-5
2026-09-07 11:29:36 +02:00
Andrey Antukh 9462543fb5 Add skip-onboarding option to create-demo-profile (#11526)
Add optional skip-onboarding param to create-demo-profile. When true, the demo profile is created with onboarding-viewed and release-notes-viewed set, so it skips the onboarding flow. Default keeps the current behavior. Cover both cases with RPC tests. AI-assisted-by: muse-spark-1.3-contributor
2026-09-07 11:15:17 +02:00
Andrey Antukh 5c10ea5bd6 📚 Document branch naming convention
The creating-prs memory described a branch format (issue/... with a
<type>/<short-description> fallback) that does not match actual repo
practice, where issue-driven branches are issue-NNNN. Replace it with
a Branch Naming section: issue-NNNN as primary, descriptive name
without slashes as fallback.

Add the matching public convention to CONTRIBUTING.md under Pull
Requests, which previously defined no branch naming at all.

AI-assisted-by: omen-alpha
2026-09-07 08:41:54 +00:00
Andrey Antukh 7ebd7cc0d5 Merge remote-tracking branch 'origin/staging' into staging 2026-09-07 09:52:07 +02:00
Andrey Antukh 2100ed29ea Merge remote-tracking branch 'origin/main' into staging 2026-09-07 09:45:06 +02:00
Andrey Antukh c8691d0cde 🐛 Fix crash when a nil shape id reaches WASM modifiers (#10896)
* 🐛 Fix crash when a nil shape id reaches WASM modifiers

A modif-tree containing a nil shape id crashed the WASM heap write
with "Cannot read properties of null (reading '__u32_buffer')" in
propagate-modifiers. Production builds elide the upstream asserts,
so a nil id (e.g. update-dimensions called with a missing shape's
parent id in the variants flow) reached the uuid heap write.

Drop nil-keyed modif-tree entries in apply-wasm-modifiers and
set-wasm-modifiers before they reach propagate-modifiers and
set-structure-modifiers, filter nil ids at the parse level, and
guard the variant reposition/creation events against missing shapes.

AI-assisted-by: hy3

* 🐛 Fix let scoping bugs in modifiers.cljs

Two let forms in set-wasm-modifiers and apply-wasm-modifiers
had their bindings vectors closed prematurely, causing undeclared
var warnings for snap-pixel?, translation?, ids, update-shape,
options, bool-ids, undo-id, and other bindings.

In set-wasm-modifiers: removed premature ) after ] on the let
bindings so the let body wraps snap-pixel? and translation?.

In apply-wasm-modifiers: the cond form was not explicitly closed,
so all subsequent bindings (ignore-tree, options, modif-tree, ids,
update-shape, bool-ids, undo-id) were parsed as cond clauses
instead of let bindings. Added ) to close cond after :else, and
moved ] to close the bindings vector after undo-id.

AI-assisted-by: mimo-v2.5-pro
2026-09-07 08:34:06 +02:00
Marina López 5452eb5c8a 🐛 Fix email sales (#11514) 2026-09-04 13:36:10 +02:00
Eva Marco df48c834e3 🐛 Fix create nil typography token (#11489)
* 🐛 Fix stroke width token dropdown closing mid-interaction

The stroke-row* key included a hash of applied-tokens, forcing a full
remount whenever the async token-resolution pipeline updated that
state (e.g. after a detach action settles). If the remount landed
while the width dropdown was open, it destroyed the dropdown and
reset its open state, permanently closing it before the user could
pick a token.

Drop the hash from the key so the row only remounts on actual
structural changes (add/remove/reorder), not on every token
resolution tick.

AI-assisted-by: claude-sonnet-5

* 🐛 Fix crash when a typography token has a nil value

A composite typography token saved with no fields filled in stores a
nil :value. That value reached StyleDictionary's tokens-studio
font-styles preprocessor, which assumes a typography value is never
null and threw, crashing token resolution for every other token in
the file.

Reject the nil value at the source in the typography token form
validation, and as defense in depth, filter nil-valued tokens out of
every resolve-tokens* entry point before they reach StyleDictionary,
tagging them with the existing empty-input error instead.

Also remove a stray debug prn left in find-refs.

AI-assisted-by: claude-sonnet-5

* 🔧 Gitignore local CLAUDE.md

Keep the commit-conventions instructions file local to each
contributor's checkout instead of tracking it in the repo.

AI-assisted-by: claude-sonnet-5

*  Use a single transducer to tag invalid-value tokens

merge-invalid-value-tokens ran three separate passes over the token
map (remove, map, into) and then merged the result back in. Combine
the remove/map steps into one ns-level transducer, defined once
instead of rebuilt on every call, and pass resolved as the seed to
into so the trailing merge isn't needed either.

AI-assisted-by: claude-sonnet-5

* ♻️ Drop redundant t/testing wrapper in nil-value token test

The outer t/testing just repeated the deftest's own name and added
nothing the two inner t/testing blocks (each covering one concrete
assertion group) don't already say.

AI-assisted-by: claude-sonnet-5

* 🐛 Fail the nil-value token test on a resolution error

rx/sub! only handles the success case, so if token resolution ever
errors instead, done is never called and the async test hangs
instead of failing. Switch to rx/subs! with an error handler that
reports the failure and calls done, matching the pattern already
used elsewhere in the tokens test suite.

AI-assisted-by: claude-sonnet-5
2026-09-04 12:31:26 +02:00
Elena Torró fdcc4666e3 🐛 Fix multiple shapes export on files rendered with webgl (#11506) 2026-09-04 11:00:35 +02:00
Eva Marco 064f200774 🐛 Fix crash pressing Ctrl+D with no shape selected (#11491)
AI-assisted-by: claude-sonnet-5
2026-09-04 10:21:31 +02:00
Eva Marco 214dbd0c8b 🐛 Stop opening Comments section from persisting display-comments (#11492)
AI-assisted-by: claude-sonnet-5
2026-09-04 10:15:38 +02:00
Marina López f2f352d06c Add props to invitations and organizations events (#11508) 2026-09-04 10:13:25 +02:00
Elena Torró 80dea409c6 🐛 Fix share-link viewers unable to load file fragments (#11484) 2026-09-03 12:15:35 +02:00
María Valderrama 7c762d8a98 🐛 Fix recommended plan (#11488) 2026-09-03 11:59:20 +02:00
Pablo Alba 15dff4a9e1 💄 Fix sales email (#11478) 2026-09-03 08:46:52 +02:00
Eva Marco c6a32a2f5a 🐛 Fix list on registration toast notification (#11479) 2026-09-02 17:19:37 +02:00
David Barragán Merino f633d82f51 ♻️ Switch penpot images to sha-<commit> tagging
Replaces the content-hash build key (bundle_version + docker/images tree hash) used to tag and dedupe the backend/frontend/exporter/storybook/mcp image set with sha-<commit>, matching the scheme already used by admin-console, licenses-manager and payments across the org. The check→build→promote pattern with the S3 marker is unchanged; only the key used for the marker, the immutable tag and the local bundle cache filename moves from the composite build key to the git commit sha (the bundle cache now keys on bundle_version alone, which is what it actually caches). devenv is intentionally left out of this pass, it has no versioned tagging today.

Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
2026-09-02 17:14:31 +02:00
andrés gonzález 88d715938f 📚 Add migration guide page (#11454)
Point First Steps at the enterprise migration PDF with a short
summary, without duplicating the Community post.
2026-09-02 14:06:06 +02:00
Eva Marco 7620386215 🐛 Fix font family preview sample (#11473)
* 🐛 Fix font family preview sample

* 🐛 Fix font line height inside font selector
2026-09-02 13:55:38 +02:00
andrés gonzález b6b1a47a7b 📚 Update MCP Quick demo video (#11453)
Replace the outdated Quick demo embed on the MCP docs with the
new recording.
2026-09-02 13:54:01 +02:00
Alejandro Alonso b46ed37141 🐛 Fix size-limiting-stream read arity on v3 binfile import (#11468)
The FilterInputStream proxy only implemented read() and
read(byte[], int, int). Buffered reads call read(byte[]) (and
read(byte[], int) via Clojure interop), causing ArityException
while hashing storage objects and breaking v3 imports.

Implement all read overloads and extract shared byte-count logic.
2026-09-02 10:28:16 +02:00
Luis de DiosandEva Marco 99378dc02d 🐛 Fix font preview in assets breaks the font row (#11428)
* 🐛 Fix font preview in assets breaks the font row

* 🐛 Fix font height problem also in the font dropdown

* 🐛 Fix a small bug within the changes

---------

Co-authored-by: Eva Marco <evamarcod@gmail.com>
2026-09-01 15:48:09 +02:00
David Barragán Merino 6f35348c7c 🐳 Pin docker images to 2.17
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
2026-08-27 10:51:08 +02:00
81 changed files with 2804 additions and 554 deletions

No files matched your search

+11 -15
View File
@@ -37,7 +37,7 @@ jobs:
outputs:
gh_ref: ${{ steps.vars.outputs.gh_ref }}
bundle_version: ${{ steps.vars.outputs.bundle_version }}
build_key: ${{ steps.vars.outputs.build_key }}
sha: ${{ steps.vars.outputs.sha }}
exists: ${{ steps.check.outputs.exists }}
steps:
@@ -55,6 +55,7 @@ jobs:
run: |
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
BUNDLE_VERSION=$(aws s3api head-object \
--bucket ${{ secrets.S3_BUCKET }} \
@@ -63,15 +64,10 @@ jobs:
--output text)
echo "bundle_version=$BUNDLE_VERSION" >> $GITHUB_OUTPUT
# Image content = bundle + docker build context, so the build key
# combines both.
CTX_HASH=$(git rev-parse "HEAD:docker/images" | cut -c1-12)
echo "build_key=${BUNDLE_VERSION}-${CTX_HASH}" >> $GITHUB_OUTPUT
# 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 build key.
# means there is nothing at all to do for this commit.
- name: Check if this image set is already built
id: check
env:
@@ -81,13 +77,13 @@ jobs:
run: |
if aws s3api head-object \
--bucket ${{ secrets.S3_BUCKET }} \
--key "markers/images-${{ steps.vars.outputs.build_key }}" \
--key "markers/images-sha-${{ steps.vars.outputs.sha }}" \
> /dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
{
echo "### ⏭️ Image set build skipped"
echo ""
echo "The whole set was already built and promoted for \`${{ steps.vars.outputs.build_key }}\`."
echo "The whole set was already built and promoted for \`sha-${{ steps.vars.outputs.sha }}\`."
} >> "$GITHUB_STEP_SUMMARY"
else
echo "exists=false" >> $GITHUB_OUTPUT
@@ -97,7 +93,7 @@ jobs:
# prune stale bundles while at it.
mkdir -p "$BUNDLE_CACHE"
find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true
ZIP="$BUNDLE_CACHE/penpot-${{ steps.vars.outputs.build_key }}.zip"
ZIP="$BUNDLE_CACHE/penpot-${{ steps.vars.outputs.bundle_version }}.zip"
if [ ! -f "$ZIP" ]; then
aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-${{ steps.vars.outputs.gh_ref }}.zip" "$ZIP.$$.tmp"
mv "$ZIP.$$.tmp" "$ZIP"
@@ -169,7 +165,7 @@ jobs:
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
run: |
ZIP="$BUNDLE_CACHE/penpot-${{ needs.prepare.outputs.build_key }}.zip"
ZIP="$BUNDLE_CACHE/penpot-${{ needs.prepare.outputs.bundle_version }}.zip"
if [ ! -f "$ZIP" ]; then
echo "Bundle not found in host cache; falling back to S3."
mkdir -p "$BUNDLE_CACHE"
@@ -209,7 +205,7 @@ jobs:
sbom: true
# Immutable tag only; branch tags are moved atomically for the
# whole image set by the `promote` job.
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:build-${{ needs.prepare.outputs.build_key }}
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:sha-${{ needs.prepare.outputs.sha }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache,mode=max
@@ -245,7 +241,7 @@ jobs:
for image in $ALL_IMAGES; do
docker buildx imagetools create \
-t "${{ secrets.DOCKER_REGISTRY }}/$image:${{ needs.prepare.outputs.gh_ref }}" \
"${{ secrets.DOCKER_REGISTRY }}/$image:build-${{ needs.prepare.outputs.build_key }}"
"${{ secrets.DOCKER_REGISTRY }}/$image:sha-${{ needs.prepare.outputs.sha }}"
done
# The marker is written LAST: its presence certifies that all five
@@ -257,11 +253,11 @@ jobs:
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
run: |
echo "${{ github.run_id }}" | aws s3 cp - \
"s3://${{ secrets.S3_BUCKET }}/markers/images-${{ needs.prepare.outputs.build_key }}"
"s3://${{ secrets.S3_BUCKET }}/markers/images-sha-${{ needs.prepare.outputs.sha }}"
{
echo "### ✅ Image set promoted"
echo ""
echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`build-${{ needs.prepare.outputs.build_key }}\`."
echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`sha-${{ needs.prepare.outputs.sha }}\`."
} >> "$GITHUB_STEP_SUMMARY"
# ── 4. Single failure notification for the whole workflow ─────────────
+1
View File
@@ -97,6 +97,7 @@ opencode.json
/.idea
*.iml
/.claude
/CLAUDE.md
/.playwright-mcp
/.devenv/mcp/
/opencode.json
+7 -1
View File
@@ -1,6 +1,12 @@
# Creating Pull Requests
PR only on explicit request. Branch: issue/feature-specific; fallback `<type>/<short-description>` (`fix/...`, `feat/...`, `refactor/...`, `docs/...`, `chore/...`, `perf/...`).
PR only on explicit request.
## Branch Naming
- Primary: `issue-NNNN` — one branch per GitHub issue (e.g. `issue-11525`).
- No issue: free-form descriptive name, dash-separated, no slashes (e.g. `fix-ellipse-icon-typo`, `feat-auto-link-libraries`).
- If the user already created the branch, use it as-is — never rename.
## Target Branch
+13
View File
@@ -14,6 +14,7 @@ Center](https://help.penpot.app/).
- [Reporting Bugs](#reporting-bugs)
- [Pull Requests](#pull-requests)
- [Workflow](#workflow)
- [Branch naming](#branch-naming)
- [Format](#format)
- [Title format](#title-format)
- [Description](#description)
@@ -73,6 +74,18 @@ Advisories](https://github.com/penpot/penpot/security/advisories)
4. **Format and lint** — run the checks described in
[Formatting and Linting](#formatting-and-linting) before submitting.
### Branch naming
Branch names are not enforced, but we recommend the following:
- **`issue-NNNN`** — when working from a GitHub issue, name the branch after
it (e.g. `issue-11525`). This makes each PR's origin self-evident.
- Otherwise, use a short, descriptive name with words separated by hyphens
and no slashes (e.g. `fix-ellipse-icon-typo`, `feat-auto-link-libraries`).
Since PRs are squash-merged, the branch name does not survive into the
commit history — what matters is the [PR title](#title-format).
### Format
#### Title
+1 -1
View 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 {:minutes 15})]
exp (ct/in-future {:hours 4})]
(when (and session organization-id)
(let [props (-> (or (:props session) {})
(update :sso assoc organization-id exp))]
+15 -14
View File
@@ -436,25 +436,26 @@
Raises :validation :max-file-size-reached when the limit is exceeded."
^InputStream
[^InputStream input ^long max-size]
(let [counter (atom 0)]
(let [counter (atom 0)
on-read (fn [n]
(when (pos? n)
(when (> (swap! counter + (long n)) max-size)
(ex/raise :type :validation
:code :max-file-size-reached
:hint (str "stream exceeded max size: " max-size))))
n)]
(proxy [FilterInputStream] [input]
(read
([]
(let [b (.read input)]
(when (pos? b)
(when (> (swap! counter inc) max-size)
(ex/raise :type :validation
:code :max-file-size-reached
:hint (str "stream exceeded max size: " max-size))))
(when (pos? b) (on-read 1))
b))
([buf off len]
(let [n (.read input buf off len)]
(when (pos? n)
(when (> (swap! counter + (long n)) max-size)
(ex/raise :type :validation
:code :max-file-size-reached
:hint (str "stream exceeded max size: " max-size))))
n))))))
([^bytes buf]
(on-read (.read input buf 0 (alength buf))))
([^bytes buf off]
(on-read (.read input buf (int off) (- (alength buf) (int off)))))
([^bytes buf off len]
(on-read (.read input buf (int off) (int len))))))))
(defn- zip-entry-reader
[^ZipFile input ^ZipEntry entry]
+8 -3
View File
@@ -15,6 +15,7 @@
(:require
[app.common.schema :as sm]
[app.util.ssrf :as ssrf]
[app.worker :as-alias wrk]
[cuerdas.core :as str]
[integrant.core :as ig]
[java-http-clj.core :as http])
@@ -23,6 +24,8 @@
java.net.URI))
(def default-max-redirects 5)
(def default-connect-timeout 30000)
(def default-request-timeout 30000)
(defn client?
[o]
@@ -33,15 +36,17 @@
:pred client?})
(defmethod ig/init-key ::client
[_ _]
(http/build-client {:connect-timeout 30000
[_ {:keys [::wrk/executor]}]
(http/build-client {:connect-timeout default-connect-timeout
:executor executor
:follow-redirects :never}))
(defn send!
([client req] (send! client req {}))
([client req {:keys [response-type] :or {response-type :string}}]
(assert (client? client) "expected valid http client")
(http/send req {:client client :as response-type})))
(http/send (merge {:timeout default-request-timeout} req)
{:client client :as response-type})))
(defn- resolve-client
[params]
+1 -1
View File
@@ -200,7 +200,7 @@
{::db/pool (ig/ref ::db/pool)}
::http.client/client
{}
{::wrk/executor (ig/ref ::wrk/executor)}
::session/manager
{::db/pool (ig/ref ::db/pool)}
+3 -3
View File
@@ -75,10 +75,10 @@
{:method method
:uri uri
:body body
:headers headers}
:headers headers
:timeout timeout}
{:response-type :input-stream
:skip-ssrf-check? true
:timeout timeout})
:skip-ssrf-check? true})
status (:status resp)]
(when (not (<= 200 status 299))
(let [body (:body resp)]
+1 -2
View File
@@ -68,8 +68,7 @@
"x-profile-id" (str profile-id)}
:uri uri
:version :http1.1}
(= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key)))
{:skip-ssrf-check? true})))
(= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key))))))
(defn- with-retries
[handler max-retries]
+17 -3
View File
@@ -9,6 +9,7 @@
(:require
[app.auth :refer [derive-password-weak]]
[app.common.exceptions :as ex]
[app.common.schema :as sm]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
@@ -21,14 +22,21 @@
[buddy.core.codecs :as bc]
[buddy.core.nonce :as bn]))
(def ^:private
schema:create-demo-profile
[:map
[:skip-onboarding {:optional true} ::sm/boolean]])
(sv/defmethod ::create-demo-profile
"A command that is responsible of creating a demo purpose
profile. It only works if the `demo-users` flag is enabled in the
configuration."
{::rpc/auth false
::doc/added "1.15"
::doc/changes ["1.15" "This method is migrated from mutations to commands."]}
[cfg _]
::doc/changes [["1.15" "This method is migrated from mutations to commands."]
["2.18" "Add optional `skip-onboarding` param. When true, the profile is created with `onboarding-viewed` and `release-notes-viewed` (current version) set, skipping the onboarding flow."]]
::sm/params schema:create-demo-profile}
[cfg {:keys [skip-onboarding]}]
(when-not (contains? cf/flags :demo-users)
(ex/raise :type :validation
@@ -48,7 +56,13 @@
:is-active true
:is-demo true
:password (derive-password-weak password)
:props {}}
:props (cond-> {}
skip-onboarding (assoc :onboarding-viewed true
;; Redundant today: auth/create-profile
;; overwrites this with the current
;; version, kept so the skip does not
;; depend on that default.
:release-notes-viewed (:main cf/version)))}
profile (db/tx-run! cfg (fn [cfg]
(->> (auth/create-profile cfg params)
(auth/create-profile-rels cfg))))]
+14 -4
View File
@@ -241,6 +241,18 @@
(some-> (db/get cfg :file-data {:file-id file-id :id fragment-id :type "fragment"})
(update :data blob/decode)))
(defn- check-fragment-scope!
"Checks that the fragment is reachable from the pages authorized by
the share-link. Raises a :not-found exception if the fragment is not reachable."
[cfg file-id fragment-id pages]
(let [fdata (-> (bfc/get-file cfg file-id :read-only? true)
(get :data)
(update :pages-index select-keys pages))]
(when-not (contains? (feat.fdata/get-used-pointer-ids fdata) fragment-id)
(ex/raise :type :not-found
:code :object-not-found
:hint "object not found"))))
(sv/defmethod ::get-file-fragment
"Retrieve a file fragment by its ID. Only authenticated users."
{::doc/added "1.17"
@@ -250,11 +262,9 @@
[cfg {:keys [::rpc/profile-id file-id fragment-id share-id]}]
(db/run! cfg (fn [cfg]
(let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)]
(when (= :share-link (:type perms))
(ex/raise :type :not-found
:code :object-not-found
:hint "object not found"))
(check-read-permissions! perms)
(when (= :share-link (:type perms))
(check-fragment-scope! cfg file-id fragment-id (:pages perms)))
(-> (get-file-fragment cfg file-id fragment-id)
(rph/with-http-cache long-cache-duration))))))
@@ -235,9 +235,8 @@
:organization-name (:name organization)
:member-email (:email-to invitation)
:member-id (:id member)
:role role}
organization
(assoc :user-who-send-invitation (str profile-id))
:role role
:user-who-send-invitation (str profile-id)}
(not organization)
(assoc :team-belongs-to-organization (boolean team-organization-id)
@@ -308,7 +308,9 @@
(assoc :name "accept-organization-invitation")
(assoc :props
(-> props
(assoc :organization-id organization-id-on-add)
(assoc :organization-id organization-id-on-add
:user-id (:id profile)
:user-who-send-invitation (:created-by invitation))
(audit/clean-props))))))
(cond-> (assoc claims :state :created)
@@ -325,6 +327,8 @@
(assoc :organization-id organization-id-on-add
:organization-member-add-source organization-add-source
:belongs-to-team-on-add (boolean team-id)
:user-id (:id profile)
:user-who-send-invitation (:created-by invitation)
:organization-member-count-before
organization-member-count-before)
(audit/clean-props))}))))))
@@ -0,0 +1,30 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.http-client-test
(:require
[app.http.client :as http]
[clojure.test :as t]
[java-http-clj.core :as jhttp]
[mockery.core :refer [with-mocks]]))
(t/deftest send-injects-default-timeout-when-absent
(with-mocks [mock {:target 'java-http-clj.core/send
:return {:status 200 :body ""}}]
(let [client (jhttp/build-client {})]
(http/send! client {:method :get :uri "https://example.com/"})
(let [[req _opts] (:call-args @mock)]
(t/is (= http/default-request-timeout (:timeout req)))))))
(t/deftest send-preserves-caller-supplied-timeout
(with-mocks [mock {:target 'java-http-clj.core/send
:return {:status 200 :body ""}}]
(let [client (jhttp/build-client {})]
(http/send! client {:method :get
:uri "https://example.com/"
:timeout 5000})
(let [[req _opts] (:call-args @mock)]
(t/is (= 5000 (:timeout req)))))))
@@ -8,6 +8,7 @@
(:require
[app.common.exceptions :as ex]
[app.config :as cf]
[app.http.client :as http]
[app.media.remote :as media.remote]
[app.setup :as-alias setup]
[app.util.json :as json]
@@ -500,6 +501,22 @@
:headers {}})]
(t/is (= 200 (:status resp))))))))
(t/deftest service-request-puts-configured-timeout-in-request
(t/testing "service-request puts media-processing-service-timeout on the http request"
(let [captured (atom nil)]
(with-redefs [cf/get (th/config-get-mock config-mock)
http/req (fn [_client request _opts]
(reset! captured request)
{:status 200
:body (json-stream {:width 100 :height 100})})]
(media.remote/service-request
(mk-system)
{:method :post
:uri "http://localhost:6065/api/image/info"
:body nil
:headers {}})
(t/is (= 5000 (:timeout @captured)))))))
;; ---------------------------------------------------------------------------
;; Shared key
;; ---------------------------------------------------------------------------
@@ -0,0 +1,70 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.nitrate-ssrf-test
(:require
[app.config :as cf]
[app.http.client :as http]
[app.nitrate :as nitrate]
[app.setup :as-alias setup]
[clojure.string :as str]
[clojure.test :as t]
[integrant.core :as ig]
[java-http-clj.core :as jhttp]))
(def ^:private private-admin-uri "http://127.0.0.1:9090")
(defn- mk-cfg
"Minimal nitrate cfg with a real HttpClient and nitrate client methods."
[]
(let [http-client (jhttp/build-client {})
base {::http/client http-client
::setup/shared-keys {:admin-console "test-shared-key"}}]
(assoc base ::nitrate/client (ig/init-key ::nitrate/client base))))
(defn- with-admin-console-uri
"Run `f` with :admin-console enabled and the given admin-console URI / allowlist."
[admin-uri allowed-hosts f]
(let [original-get cf/get]
(with-redefs [cf/flags #{:admin-console}
cf/get (fn [key & args]
(case key
:admin-console-uri admin-uri
:ssrf-allowed-hosts allowed-hosts
(apply original-get key args)))]
(f))))
(t/deftest nitrate-blocks-private-admin-console-uri
(let [sent? (atom false)]
(with-admin-console-uri
private-admin-uri
#{}
(fn []
(with-redefs [jhttp/send (fn [_req _opts]
(reset! sent? true)
{:status 200 :body "{\"licenses\":true}"})]
(try
(nitrate/call (mk-cfg) :connectivity {})
(t/is false "should have raised :nitrate-unavailable")
(catch Exception e
(t/is (= :nitrate-unavailable (:type (ex-data e))))
(t/is (false? @sent?)
"SSRF must stop the request before it reaches the network"))))))))
(t/deftest nitrate-proceeds-when-admin-console-host-allowlisted
(let [captured (atom nil)]
(with-admin-console-uri
private-admin-uri
#{"127.0.0.1"}
(fn []
(with-redefs [jhttp/send (fn [req _opts]
(reset! captured req)
{:status 200
:body "{\"licenses\":true}"})]
(let [result (nitrate/call (mk-cfg) :connectivity {})]
(t/is (= {:licenses true} result))
(t/is (some? @captured))
(t/is (str/starts-with? (str (:uri @captured)) private-admin-uri))))))))
@@ -8,6 +8,7 @@
(:require
[app.auth :as auth]
[app.config :as cf]
[app.rpc.commands.profile :as profile]
[backend-tests.helpers :as th]
[clojure.test :as t]))
@@ -38,3 +39,38 @@
(with-redefs [cf/flags (disj cf/flags :demo-users)]
(let [{:keys [error]} (th/command! {::th/type :create-demo-profile})]
(t/is (th/ex-of-code? error :demo-users-not-allowed)))))
(t/deftest create-demo-profile-keeps-onboarding-by-default
(with-redefs [cf/flags (conj cf/flags :demo-users)]
(let [{:keys [error result]} (th/command! {::th/type :create-demo-profile})]
(t/is (nil? error))
(let [saved (th/db-get :profile {:email (:email result)})
decoded (profile/decode-row saved)]
(t/is (nil? (get-in decoded [:props :onboarding-viewed])))))))
(t/deftest create-demo-profile-skips-onboarding-when-requested
(with-redefs [cf/flags (conj cf/flags :demo-users)]
(let [{:keys [error result]} (th/command! {::th/type :create-demo-profile
:skip-onboarding true})]
(t/is (nil? error))
(let [saved (th/db-get :profile {:email (:email result)})
decoded (profile/decode-row saved)]
(t/is (true? (get-in decoded [:props :onboarding-viewed])))
(t/is (= (:main cf/version)
(get-in decoded [:props :release-notes-viewed])))))))
(t/deftest create-demo-profile-explicit-false-keeps-onboarding
(with-redefs [cf/flags (conj cf/flags :demo-users)]
(let [{:keys [error result]} (th/command! {::th/type :create-demo-profile
:skip-onboarding false})]
(t/is (nil? error))
(let [saved (th/db-get :profile {:email (:email result)})
decoded (profile/decode-row saved)]
(t/is (nil? (get-in decoded [:props :onboarding-viewed])))))))
(t/deftest create-demo-profile-rejects-non-boolean-skip-onboarding
(with-redefs [cf/flags (conj cf/flags :demo-users)]
(let [{:keys [error]} (th/command! {::th/type :create-demo-profile
:skip-onboarding "yes"})]
(t/is (th/ex-of-type? error :validation))
(t/is (th/ex-of-code? error :params-validation)))))
@@ -154,10 +154,14 @@
(get-in % [:props :member-email])))
events))]
(doseq [event [create-organization update-organization]]
(t/is (= (str (:id owner))
(get-in event [:props :user-who-send-invitation])))
(t/is (true? (get-in event [:props :team-belongs-to-organization])))
(t/is (true? (get-in event [:props :adds-invitee-to-organization])))
(t/is (true? (get-in event [:props :invitee-already-organization-member]))))
(t/is (= (str (:id owner))
(get-in create-plain [:props :user-who-send-invitation])))
(t/is (false? (get-in create-plain [:props :team-belongs-to-organization])))
(t/is (false? (get-in create-plain [:props :adds-invitee-to-organization])))
(t/is (false? (get-in create-plain [:props :invitee-already-organization-member])))))))
@@ -521,6 +525,9 @@
(let [event (organization-event)]
(t/is (= organization-id (get-in event [:props :organization-id])))
(t/is (= (:id invitee) (get-in event [:props :user-id])))
(t/is (= (:id inviter)
(get-in event [:props :user-who-send-invitation])))
(t/is (not (contains? (:props event) :organization-member-add-source)))
(t/is (not (contains? (:props event) :belongs-to-team-on-add)))
(t/is (not (contains? (:props event) :organization-member-count-before)))
@@ -530,6 +537,10 @@
(:origin @frontend-event)))
(t/is (= organization-id
(get-in @frontend-event [:props :organization-id])))
(t/is (= (:id invitee)
(get-in @frontend-event [:props :user-id])))
(t/is (= (:id inviter)
(get-in @frontend-event [:props :user-who-send-invitation])))
(t/is (= "direct-organization-invitation"
(get-in @frontend-event [:props :organization-member-add-source])))
(t/is (false? (get-in @frontend-event [:props :belongs-to-team-on-add])))
@@ -570,6 +581,9 @@
(t/is (some #(= "accept-team-invitation-from" (:name %)) events))
(t/is (= (:id team) (get-in event [:props :team-id])))
(t/is (= organization-id (get-in event [:props :organization-id])))
(t/is (= (:id invitee) (get-in event [:props :user-id])))
(t/is (= (:id inviter)
(get-in event [:props :user-who-send-invitation])))
(t/is (not (contains? (:props event) :organization-member-add-source)))
(t/is (not (contains? (:props event) :belongs-to-team-on-add)))
(t/is (not (contains? (:props event) :organization-member-count-before)))
@@ -578,6 +592,10 @@
(t/is (= (:id team) (get-in @frontend-event [:props :team-id])))
(t/is (= organization-id
(get-in @frontend-event [:props :organization-id])))
(t/is (= (:id invitee)
(get-in @frontend-event [:props :user-id])))
(t/is (= (:id inviter)
(get-in @frontend-event [:props :user-who-send-invitation])))
(t/is (= "team-invitation"
(get-in @frontend-event [:props :organization-member-add-source])))
(t/is (true? (get-in @frontend-event [:props :belongs-to-team-on-add])))
-1
View File
@@ -166,7 +166,6 @@
(not (ctob/token-name-path-exists? token-name tokens-tree)))
new-tokens))))]])
(defn find-refs [value]
(prn value)
(cond
(string? value)
(cto/find-token-value-references value)
@@ -714,8 +714,10 @@
(defn remove-interaction
[interactions index]
(let [interactions (or interactions [])]
(into (subvec interactions 0 index)
(subvec interactions (inc index)))))
(if (and (>= index 0) (< index (count interactions)))
(into (subvec interactions 0 index)
(subvec interactions (inc index)))
interactions)))
(defn update-interaction
[interactions index update-fn]
@@ -861,6 +861,37 @@
(t/is (= (:action-type (last new-interactions)) :open-url))))))
(t/deftest remove-interaction-bounds-check
(let [i1 (ctsi/set-action-type ctsi/default-interaction :open-overlay)
i2 (ctsi/set-action-type ctsi/default-interaction :close-overlay)
i3 (ctsi/set-action-type ctsi/default-interaction :prev-screen)
interactions [i1 i2 i3]]
(t/testing "Remove interaction with valid index"
(let [new-interactions (ctsi/remove-interaction interactions 1)]
(t/is (= 2 (count new-interactions)))
(t/is (= :open-overlay (:action-type (first new-interactions))))
(t/is (= :prev-screen (:action-type (second new-interactions))))))
(t/testing "Remove interaction with index out of bounds (too high)"
(let [new-interactions (ctsi/remove-interaction interactions 10)]
(t/is (= 3 (count new-interactions)))
(t/is (= interactions new-interactions))))
(t/testing "Remove interaction with negative index"
(let [new-interactions (ctsi/remove-interaction interactions -1)]
(t/is (= 3 (count new-interactions)))
(t/is (= interactions new-interactions))))
(t/testing "Remove interaction from empty vector"
(let [new-interactions (ctsi/remove-interaction [] 0)]
(t/is (= 0 (count new-interactions)))))
(t/testing "Remove interaction from nil"
(let [new-interactions (ctsi/remove-interaction nil 0)]
(t/is (= 0 (count new-interactions)))))))
(t/deftest remap-interactions
(let [frame1 (cts/setup-shape {:type :frame})
frame2 (cts/setup-shape {:type :frame})
+4 -4
View File
@@ -78,7 +78,7 @@ services:
# - "443:443"
penpot-frontend:
image: "penpotapp/frontend:${PENPOT_VERSION:-2.16}"
image: "penpotapp/frontend:${PENPOT_VERSION:-2.17}"
restart: always
ports:
- 9001:8080
@@ -111,7 +111,7 @@ services:
# PENPOT_DISABLE_IPV6_LISTEN: "true"
penpot-backend:
image: "penpotapp/backend:${PENPOT_VERSION:-2.16}"
image: "penpotapp/backend:${PENPOT_VERSION:-2.17}"
restart: always
volumes:
@@ -180,13 +180,13 @@ services:
PENPOT_SMTP_SSL: "false"
penpot-mcp:
image: "penpotapp/mcp:${PENPOT_VERSION:-2.16}"
image: "penpotapp/mcp:${PENPOT_VERSION:-2.17}"
restart: always
networks:
- penpot
penpot-exporter:
image: "penpotapp/exporter:${PENPOT_VERSION:-2.16}"
image: "penpotapp/exporter:${PENPOT_VERSION:-2.17}"
restart: always
depends_on:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

+1 -1
View File
@@ -26,7 +26,7 @@ Penpot MCP enables **multi-directional workflows** between design and code. Beca
title="Quick demo: Penpot MCP server in action"
width="100%"
height="480"
src="https://www.youtube.com/embed/CfvcgMQEmLk?rel=0"
src="https://www.youtube.com/embed/7V01SKVG6PQ?rel=0"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
@@ -56,10 +56,6 @@ desc: Learn how to organize your work in Penpot. Create, manage and organize pro
</figure>
<p>When creating a file, you'll be asked to give it a name. The file will open in the workspace where you can start designing immediately.</p>
<h3 id="file-view">Grid and list view</h3>
<p>In the dashboard header you can switch how files are shown: a thumbnail <strong>Grid view</strong> or a compact <strong>List view</strong>. The choice is kept in the browser and applies both to the team files view and inside a project.</p>
<p>List view shows the file name, whether the file is a shared library, the last modification time, and the same options menu as the file cards.</p>
<h3 id="edit-file">Edit a file</h3>
<p>To rename a file, right-click on the file card in the dashboard and select <strong>Rename</strong>, or click on the three-dot menu on the file card. Enter the new name and confirm the change. You can also access file settings and other options from the file's context menu.</p>
+1 -7
View File
@@ -122,14 +122,8 @@ desc: Style your designs with Penpot's options! Learn about color fills, gradien
<img alt="Multiple strokes" src="/img/styling/stroke-multiple.webp"/>
</figure>
<h2 id="stroke-to-path">Stroke to path</h2>
<p>A path is the underlying geometry: a sequence of points and segments (straight lines or Bezier curves). A stroke is the visible line drawn along that path, with properties such as width, color, opacity, dashes, caps, and joins.</p>
<p><strong>Stroke to path</strong> turns the visible outline of a stroke into a separate, editable path. You can then reshape or style that outline independently of the original path. The new shape is usually closed. That matters for SVG, boolean operations, and editing the outline as geometry.</p>
<p>To convert a stroke, select a layer that has one, open the layer menu, and choose <strong>Stroke to path</strong>. Penpot creates a new path from each stroke (named with " (stroke)") and removes the strokes from the original layer.</p>
<p class="advice">Stroke to path is currently available when <a href="/user-guide/first-steps/troubleshooting-webgl/">WebGL rendering</a> is enabled.</p>
<h2 id="stroke-caps">Stroke Caps</h2>
<p>Ever needed an arrow to point to something? You can style the ends of any open paths selecting different styles for each end of an open path. You can also start from the <a href="/user-guide/designing/layers/#lines-arrows">Arrow tool</a>, which already applies a triangle cap.</p>
<p>Ever needed an arrow to point something? You can style the ends of any open paths selecting different styles for each end of an open path.</p>
<figure>
<video title="Stroke cap" muted="" playsinline="" controls="" width="100%" poster="/img/styling/stroke-cap.webp" height="auto">
<source src="/img/styling/stroke-cap.mp4" type="video/mp4">
+4 -15
View File
@@ -140,7 +140,7 @@ Penpot allows you to decide if the fill of an artboard will be shown in exports,
<h3 id="rectangles-ellipses">Rectangles and ellipses</h3>
<p>Rectangle and ellipses are two basic “primitive” geometric shapes that are useful when starting
a design.</p>
<p>The shortcut keys are <kbd>E</kbd> for ellipses and <kbd>R</kbd> for rectangles. Both tools live in the shapes flyout of the toolbar, together with line and arrow.</p>
<p>The shortcut keys are <kbd>E</kbd> for ellipses and <kbd>R</kbd> for rectangles.</p>
<p>To find out more about how to edit and modify these shapes go to <a href="/user-guide/designing/layers/#layer-actions">Layer basics</a>.</p>
<figure>
<video title="Rectangles and ellipses" muted="" playsinline="" controls="" width="auto" poster="/img/objects/rectangles-ellipses.webp" height="auto">
@@ -151,24 +151,13 @@ a design.</p>
<h3 id="text">Text</h3>
<p>Text layers are how you add copy to your designs in Penpot. If you want to go deeper into fonts, typography and advanced text options, check the dedicated <a href="/user-guide/designing/text-typo/">Text & Typography</a> section.</p>
<h3 id="lines-arrows">Lines and arrows</h3>
<p>Use the Line and Arrow tools to draw a two-point path in one drag. They live in the shapes flyout of the toolbar, together with rectangle and ellipse.</p>
<ul>
<li><strong>Line</strong> (<kbd>L</kbd>): click and drag. Hold <kbd>Shift/⇧</kbd> to snap the angle in 15 degree increments.</li>
<li><strong>Arrow</strong>: the same gesture, with a triangle arrow cap on the end. Pick it from the shapes flyout. You can change or remove the cap from the <a href="/user-guide/designing/color-stroke/#stroke-caps">stroke caps</a>.</li>
</ul>
<p>The result is a regular path, so you can edit its nodes and stroke like any other path.</p>
<figure>
<img src="/img/objects/line-arrow-tools.webp" alt="Line and Arrow tools in the shapes flyout of the toolbar" />
</figure>
<h3 id="curves">Curves (freehand)</h3>
<p>The curve tool allows a path to be created directly in a freehand mode.
Select the curve tool from the free-draw flyout in the toolbar or press <kbd>Shift/⇧</kbd> + <kbd>C</kbd>.</p>
Select the curve tool by clicking on the icon at the toolbar or pressing <kbd>Shift/⇧</kbd> + <kbd>c</kbd>.
<p>The path created will contain a lot of points, but it is edited the same way as any other curve.</p>
<h3 id="paths">Paths (bezier)</h3>
<p>A path is composed of two or more nodes and the line segments between them, which may also be curved. To draw a new path, select the path tool from the free-draw flyout in the toolbar or press <kbd>P</kbd>. Then you have two ways to create the path:</p>
<p>A path is composed of two or more nodes and the line segments between them, which may also be curved. To draw a new path you have to select the path tool by clicking on the icon at the toolbar or pressing <kbd>P</kbd>. Then you have two ways to create the path:</p>
<ol>
<li><strong>Click</strong> to create a new corner node.</li>
<li><strong>Click and drag</strong> to create a curved node.</li>
@@ -222,7 +211,7 @@ You can choose to edit individual nodes or create new ones. Press <kbd>Esc</kbd>
<h2 id="layer-actions">Layer actions</h2>
<h3 id="creating-layers">Create</h3>
<p>To create a layer, pick a tool from the toolbar. Board, text, and image stay on the toolbar. Rectangle, ellipse, line, and arrow are in the shapes flyout. Path and curve are in the free-draw flyout. Then you usually click and drag on the viewport.</p>
<p>To create a layer you have to select the type of layer by clicking the selected tool (board, rectangle, ellipse, text, image, path or curve) at the toolbar. Then you usually have to click and drag your mouse on the viewport. </p>
<p>Hold <kbd>Shift/⇧</kbd> while creating an ellipse or a rectangle to maintain equal width and height.</p>
<figure>
<video title="Layers create" muted="" playsinline="" controls="" width="auto" poster="/img/layers/layers-create.webp" height="auto">
+1 -1
View File
@@ -32,7 +32,7 @@ desc: Penpot's guide on custom fonts! Upload, manage, and use custom fonts in Pe
<img src="/img/objects/text-options.webp" alt="text options">
</figure>
<ol>
<li><strong>Font family.</strong> Penpot includes by default the <a href="https://fonts.google.com/" target=”_blank”>Google Fonts</a> cataloge. You can also <a href="/user-guide/designing/text-typo/#custom-fonts">install your own fonts</a>. In the font selector, each family name is shown in its own typeface so you can compare fonts before applying one. The name is the preview. While a font loads, you may briefly see the interface font instead. The preview uses a default variant, not every weight or style of the family.</li>
<li><strong>Font family.</strong> Penpot includes by default the <a href="https://fonts.google.com/" target=”_blank”>Google Fonts</a> cataloge. You can also <a href="/user-guide/designing/text-typo/#custom-fonts">install your own fonts</a>.</li>
<li><strong>Font size.</strong></li>
<li><strong>Font type.</strong></li>
<li><strong>Line height</strong> (in pixels).</li>
@@ -43,15 +43,6 @@ desc: Master Penpot's workspace basics! Learn interface navigation, zoom tools,
</video>
</figure>
<h4 id="select-delete-pages">Select and delete several pages</h4>
<p>In the Pages panel you can select more than one page and delete them in a single action.</p>
<ul>
<li>Click a page to open it. This selects only that page.</li>
<li><kbd>Shift/⇧</kbd> + click to select a range of pages. This does not change the page you are viewing.</li>
<li><kbd>Ctrl/⌘</kbd> + click to add or remove one page from the selection. This does not navigate.</li>
</ul>
<p>Right-click a selected page and choose <strong>Delete pages</strong>. Confirm to remove all selected pages in one step. A file always keeps at least one page. Page separators cannot be included in the selection.</p>
<h4 id="page-separators">Page separators</h4>
<p>You can add visual dividers in the page list to group pages without extra structure. Create an empty page, then rename it to <code>---</code>. The page appears as a horizontal line in the list.</p>
<p>A page only becomes a separator if its name is <code>---</code> and the page has no content on the canvas. A page with content that is named <code>---</code> stays a normal page. Separators cannot be opened or selected; you can reorder or delete them like other pages.</p>
+6
View File
@@ -31,4 +31,10 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial
<p>Useful resources to better understand Penpot</p>
</a>
</li>
<li>
<a href="/user-guide/first-steps/migration-guide">
<h2>Migration Guide →</h2>
<p>Move a design system from Figma to Penpot</p>
</a>
</li>
</ul>
@@ -0,0 +1,31 @@
---
title: Migration Guide
order: 6
desc: Move a design system from Figma to Penpot. Read a short summary of the enterprise migration guide and open the full PDF.
---
<h1 id="migration-guide">Migration Guide</h1>
<p class="main-paragraph">If you are moving a design system to Penpot, especially from Figma, start with the enterprise migration guide. It covers file and library migration, tokens, validation, dual-tool workflows, and how different roles can run a pilot.</p>
<div class="advice">
<p><strong>Open the full guide (PDF)</strong></p>
<p><a href="https://nextcloud.kaleidos.net/index.php/s/mKordyz62QF3PQ4?dir=/&amp;editing=false&amp;openfile=true" target="_blank" rel="noopener"><strong>The Enterprise Guide to Migrating Design Systems from Figma to Penpot</strong></a></p>
</div>
<h2 id="what-the-guide-covers">What the guide covers</h2>
<p>The document is written for teams that need to move more than a few mockups: libraries, tokens, variants, and the workflows around them. It focuses on Figma, but the same audit, pilot, and validation steps apply if you are coming from another tool.</p>
<ul>
<li><strong>Before you export:</strong> audit critical files, component chains, token usage, and plugins that will not come along. Split oversized files and clean unused libraries while you are still in Figma.</li>
<li><strong>Static assets:</strong> export SVG, PNG, or JPG from Figma and place them in Penpot.</li>
<li><strong>Complex files and libraries:</strong> use the Penpot Exporter plugin for Figma (design files, slides, components, variants, auto layout, styles, variables, and libraries). Expect some layout cleanup, Figma Auto Layout becomes Flex and Grid in Penpot.</li>
<li><strong>Tokens:</strong> if you already use Tokens Studio, export JSON and import it in Penpot. Native Figma Variables can go through Tokens Studio, or through the Exporter plugin.</li>
<li><strong>Validate before you scale:</strong> migrate one representative file (or a sandbox library), write down recurring cleanup, then roll the same checklist out to the rest of the workspace.</li>
<li><strong>People and pilots:</strong> the second half of the guide has paths for designers, frontend developers, DesignOps, design-system leads, and product/engineering pilots, including how Penpot MCP can help with post-import cleanup.</li>
</ul>
<p>The guide also covers running Figma and Penpot in parallel for a while. The exporter is for one-off migration, not continuous sync.</p>
<h2 id="discuss-the-guide">Questions and discussion</h2>
<p>If you want to ask about a migration, or share how yours is going, use the Community post <a href="https://community.penpot.app/t/the-enterprise-guide-to-migrating-design-systems-to-penpot/10768" target="_blank" rel="noopener">The Enterprise Guide to Migrating Design Systems to Penpot</a>.</p>
+2 -7
View File
@@ -681,8 +681,8 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface
</tr>
<tr>
<td>Curve</td>
<td style="text-align: center;"><kbd>Shift</kbd><kbd>C</kbd></td>
<td style="text-align: center;"><kbd></kbd><kbd>C</kbd></td>
<td style="text-align: center;"><kbd>Ctrl</kbd><kbd>C</kbd></td>
<td style="text-align: center;"><kbd></kbd><kbd>C</kbd></td>
</tr>
<tr>
<td>Ellipse</td>
@@ -694,11 +694,6 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface
<td style="text-align: center;"><kbd>Shift</kbd><kbd>K</kbd></td>
<td style="text-align: center;"><kbd>⇧</kbd><kbd>K</kbd></td>
</tr>
<tr>
<td>Line</td>
<td style="text-align: center;"><kbd>L</kbd></td>
<td style="text-align: center;"><kbd>L</kbd></td>
</tr>
<tr>
<td>Path</td>
<td style="text-align: center;"><kbd>P</kbd></td>
@@ -41,9 +41,9 @@ desc: Discover Penpot's free user guide! Learn the interface, workspace basics,
<ol>
<li><strong>Viewport:</strong> An infinite canvas where you can design without limits.</li>
<li><strong>Toolbar: </strong> Tools to create layers. Board, text, and image stay on the toolbar. Shapes (rectangle, ellipse, line, and arrow) and free-draw tools (path and curve) are grouped in flyouts. Open a flyout to pick a tool, or use its shortcut. <a href="/user-guide/designing/layers/">Learn more about layers.</a></li>
<li><strong>Toolbar: </strong> This is where youll find all the tools to quickly and easily create different types of layers: board, rectangle, ellipse, text, graphic, path, and free drawing. <a href="/user-guide/designing/layers/">Learn more about layers.</a></li>
<li><strong>Main menu:</strong> From the main menu, you can customize your workspace. Manage the visibility of grids, rulers, and panels. Enable or disable snapping and dynamic alignment. Add or remove the file as a Shared Library. Youll also find help resources here.</li>
<li><strong>Pages:</strong> A file can contain as many pages as you need. Each page has its own viewport (the almost infinite area where you design) and its own layers. You can create, delete, or reorder pages, and select several pages at once to delete them. <a href="/user-guide/designing/workspace-basics/#layer-basics">More about pages.</a></li>
<li><strong>Pages:</strong> A file can contain as many pages as you need. Each page has its own viewport (the almost infinite area where you design) and its own layers. You can create, delete, or reorder pages as needed.</li>
<li><strong>Layers:</strong> Layers are the different objects you can place in the design viewport. <a href="/user-guide/designing/workspace-basics/#layer-basics">More about Layers panel.</a></li>
<li><strong>Rulers:</strong> Rulers provide coordinates to help you design. You can also drag guides from them.</li>
<li><strong>Color palette:</strong> The color palette gives you quick access to a visible library of colors. Use the menu to easily switch between libraries. <a href="/user-guide/designing/color-stroke/#color-palette">Learn more about the color palette.</a>.</li>
@@ -137,7 +137,7 @@ desc: Discover Penpot's free user guide! Learn the interface, workspace basics,
<li><strong>User area:</strong> This must be you! Access your <a href="/user-guide/account-teams/your-account"> profile settings</a>, Penpot tutorials, the Penpot Community and more. You can also find here a way to leave us feedback. Wed love to read your thoughts :). </li>
<li><strong>Comments notifications:</strong> Here you will be able to see if you have unread comments inside the files of the team. There's also a button to mark all notifications as read.</li>
<li><strong>Create project:</strong> Create as many projects as you need to organize your designs.</li>
<li><strong>File card:</strong> Basic information about a file at plain sight. A preview, update info or if its added as a Shared Library. From there you can perform several actions over the file (rename, duplicate, move, download, delete). You can also switch the files area between a thumbnail grid and a compact list. <a href="/user-guide/account-teams/projects-files/#file-view">More about grid and list view.</a></li>
<li><strong>File card:</strong> Basic information about a file at plain sight. A preview, update info or if its added as a Shared Library. From there you can perform several actions over the file (rename, duplicate, move, download, delete).</li>
<li><strong>Libraries & Templates module:</strong> A curated selection of Libraries & Templates files ready to import.</li>
</ol>
@@ -1,6 +1,6 @@
---
title: Troubleshooting WebGL
order: 5
order: 7
desc: Diagnose and fix common WebGL issues in Penpot, enable WebGL rendering (Beta), and troubleshoot browser, GPU, and system checks.
---
+3 -5
View File
@@ -17,7 +17,7 @@
[promesa.core :as p]))
(defn render
[{:keys [file-id page-id share-id token scale type objects skip-children is-wasm] :as params} on-object]
[{:keys [file-id page-id share-id token scale type objects skip-children] :as params} on-object]
(letfn [(prepare-options [uri]
#js {:screen #js {:width bw/default-viewport-width
:height bw/default-viewport-height}
@@ -25,7 +25,7 @@
:height bw/default-viewport-height}
:locale "en-US"
:storageState #js {:cookies (bw/create-cookies uri {:token token})}
:deviceScaleFactor (if is-wasm 1 scale) ;; wasm won't use deviceScaleFactor
:deviceScaleFactor scale
:userAgent bw/default-user-agent})
(render-object [page {:keys [id] :as object}]
@@ -59,9 +59,7 @@
:share-id share-id
:object-id (mapv :id objects)
:route "objects"
:skip-children skip-children
:wasm (when is-wasm "true")
:scale scale}
:skip-children skip-children}
uri (-> (cf/get-internal-uri)
(u/ensure-path-slash)
(u/join "render.html")
@@ -0,0 +1,208 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"plugins/runtime",
"design-tokens/v1",
"variants/v1",
"layout/grid",
"styles/v2",
"fdata/objects-map",
"text-editor/v2",
"render-wasm/v1",
"text-editor-wasm/v1",
"components/v2",
"fdata/shape-data-type"
]
},
"~:team-id": "~u8b485740-3f39-8080-8008-400e7784f55a",
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "New File 3",
"~:revn": 1,
"~:modified-at": "~m1788780032767",
"~:vern": 0,
"~:id": "~u77d38721-22c1-81f4-8008-9a2a3e7ce674",
"~:is-shared": false,
"~:migrations": {
"~#ordered-set": [
"legacy-2",
"legacy-3",
"legacy-5",
"legacy-6",
"legacy-7",
"legacy-8",
"legacy-9",
"legacy-10",
"legacy-11",
"legacy-12",
"legacy-13",
"legacy-14",
"legacy-16",
"legacy-17",
"legacy-18",
"legacy-19",
"legacy-25",
"legacy-26",
"legacy-27",
"legacy-28",
"legacy-29",
"legacy-31",
"legacy-32",
"legacy-33",
"legacy-34",
"legacy-36",
"legacy-37",
"legacy-38",
"legacy-39",
"legacy-40",
"legacy-41",
"legacy-42",
"legacy-43",
"legacy-44",
"legacy-45",
"legacy-46",
"legacy-47",
"legacy-48",
"legacy-49",
"legacy-50",
"legacy-51",
"legacy-52",
"legacy-53",
"legacy-54",
"legacy-55",
"legacy-56",
"legacy-57",
"legacy-59",
"legacy-62",
"legacy-65",
"legacy-66",
"legacy-67",
"0001-remove-tokens-from-groups",
"0002-normalize-bool-content-v2",
"0002-clean-shape-interactions",
"0003-fix-root-shape",
"0003-convert-path-content-v2",
"0005-deprecate-image-type",
"0006-fix-old-texts-fills",
"0008-fix-library-colors-v4",
"0009-clean-library-colors",
"0009-add-partial-text-touched-flags",
"0010-fix-swap-slots-pointing-non-existent-shapes",
"0011-fix-invalid-text-touched-flags",
"0012-fix-position-data",
"0013-fix-component-path",
"0013-clear-invalid-strokes-and-fills",
"0014-fix-tokens-lib-duplicate-ids",
"0014-clear-components-nil-objects",
"0015-fix-text-attrs-blank-strings",
"0015-clean-shadow-color",
"0016-copy-fills-from-position-data-to-text-node",
"0017-fix-layout-flex-dir",
"0018-remove-unneeded-objects-from-components",
"0019-fix-missing-swap-slots",
"0020-sync-component-id-with-near-main",
"0021-fix-shape-svg-attrs",
"0022-normalize-component-root-and-resync",
"0023-repair-token-themes-with-inexistent-sets",
"0024b-fix-stroke-cap-placement",
"0025-repair-empty-text-content",
"0026-fix-svg-raw-shapes-uuids"
]
},
"~:version": 67,
"~:project-id": "~u8b485740-3f39-8080-8008-400e7786d1d0",
"~:created-at": "~m1788779992563",
"~:backend": "db",
"~:data": {
"~:pages": [
"~u77d38721-22c1-81f4-8008-9a2a3e7ce675"
],
"~:pages-index": {
"~u77d38721-22c1-81f4-8008-9a2a3e7ce675": {
"~:objects": {
"~#penpot/objects-map/v2": {
"~u00000000-0000-0000-0000-000000000000": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"Root Frame\",\"~:width\",0.01,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0.0,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.01]],[\"^:\",[\"^ \",\"~:x\",0.0,\"~:y\",0.01]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1.0,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",0.01,\"~:height\",0.01,\"~:x1\",0,\"~:y1\",0,\"~:x2\",0.01,\"~:y2\",0.01]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^H\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~u099a17ba-4c81-804d-8008-9a2a586a1217\",\"~u099a17ba-4c81-804d-8008-9a2a587927c9\",\"~u099a17ba-4c81-804d-8008-9a2a5886910d\",\"~u099a17ba-4c81-804d-8008-9a2a5894fa56\"]]]",
"~u099a17ba-4c81-804d-8008-9a2a57981d1d": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-15\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",300.0000008940697,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",320.0000011920929,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",320.0000011920929,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",300.0000008940697,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57981d1d\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",300.0000008940697,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",300.0000008940697,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",300.0000008940697,\"~:y1\",0,\"~:x2\",320.0000011920929,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a58059cfd": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-35\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",700.0000020861626,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",720.0000023841858,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",720.0000023841858,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",700.0000020861626,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a58059cfd\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",700.0000020861626,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",700.0000020861626,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",700.0000020861626,\"~:y1\",0,\"~:x2\",720.0000023841858,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a576c177d": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-9\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",180.0000005364418,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",200.00000083446503,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",200.00000083446503,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",180.0000005364418,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a576c177d\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",180.0000005364418,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",180.0000005364418,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",180.0000005364418,\"~:y1\",0,\"~:x2\",200.00000083446503,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a57d786dd": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-26\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",520.0000015497208,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",540.000001847744,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",540.000001847744,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",520.0000015497208,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57d786dd\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",520.0000015497208,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",520.0000015497208,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",520.0000015497208,\"~:y1\",0,\"~:x2\",540.000001847744,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a57b171fc": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-19\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",380.00000113248825,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",400.0000014305115,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",400.0000014305115,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",380.00000113248825,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57b171fc\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",380.00000113248825,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",380.00000113248825,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",380.00000113248825,\"~:y1\",0,\"~:x2\",400.0000014305115,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a578122dc": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-12\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",240.00000071525574,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",260.00000101327896,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",260.00000101327896,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",240.00000071525574,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a578122dc\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",240.00000071525574,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",240.00000071525574,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",240.00000071525574,\"~:y1\",0,\"~:x2\",260.00000101327896,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a58193cbf": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-39\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",780.0000023245811,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",800.0000026226044,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",800.0000026226044,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",780.0000023245811,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a58193cbf\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",780.0000023245811,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",780.0000023245811,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",780.0000023245811,\"~:y1\",0,\"~:x2\",800.0000026226044,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a5814337e": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-38\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",760.0000022649765,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",780.0000025629997,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",780.0000025629997,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",760.0000022649765,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5814337e\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",760.0000022649765,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",760.0000022649765,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",760.0000022649765,\"~:y1\",0,\"~:x2\",780.0000025629997,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a57e7221e": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-29\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",580.0000017285347,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",600.0000020265579,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",600.0000020265579,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",580.0000017285347,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57e7221e\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",580.0000017285347,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",580.0000017285347,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",580.0000017285347,\"~:y1\",0,\"~:x2\",600.0000020265579,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a580f6e3e": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-37\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",740.0000022053719,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",760.0000025033951,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",760.0000025033951,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",740.0000022053719,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a580f6e3e\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",740.0000022053719,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",740.0000022053719,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",740.0000022053719,\"~:y1\",0,\"~:x2\",760.0000025033951,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a577afc79": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-11\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",220.0000006556511,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",240.00000095367432,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",240.00000095367432,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",220.0000006556511,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a577afc79\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",220.0000006556511,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",220.0000006556511,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",220.0000006556511,\"~:y1\",0,\"~:x2\",240.00000095367432,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a57ec0a79": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-30\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",600.0000017881393,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",620.0000020861626,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",620.0000020861626,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",600.0000017881393,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57ec0a79\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",600.0000017881393,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",600.0000017881393,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",600.0000017881393,\"~:y1\",0,\"~:x2\",620.0000020861626,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a57f5d458": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-32\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",640.0000019073486,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",660.0000022053719,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",660.0000022053719,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",640.0000019073486,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57f5d458\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",640.0000019073486,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",640.0000019073486,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",640.0000019073486,\"~:y1\",0,\"~:x2\",660.0000022053719,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a5833c5bb": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-44\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",880.0000026226044,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",900.0000029206276,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",900.0000029206276,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",880.0000026226044,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5833c5bb\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",880.0000026226044,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",880.0000026226044,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",880.0000026226044,\"~:y1\",0,\"~:x2\",900.0000029206276,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a5772831b": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-10\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",200.00000059604645,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",220.00000089406967,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",220.00000089406967,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",200.00000059604645,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5772831b\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",200.00000059604645,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",200.00000059604645,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",200.00000059604645,\"~:y1\",0,\"~:x2\",220.00000089406967,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a5849a83a": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-48\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",960.000002861023,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",980.0000031590462,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",980.0000031590462,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",960.000002861023,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5849a83a\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",960.000002861023,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",960.000002861023,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",960.000002861023,\"~:y1\",0,\"~:x2\",980.0000031590462,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a57c1477a": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-22\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",440.0000013113022,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",460.0000016093254,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",460.0000016093254,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",440.0000013113022,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57c1477a\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",440.0000013113022,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",440.0000013113022,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",440.0000013113022,\"~:y1\",0,\"~:x2\",460.0000016093254,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a581f6595": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-40\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",800.0000023841858,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",820.000002682209,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",820.000002682209,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",800.0000023841858,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a581f6595\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",800.0000023841858,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",800.0000023841858,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",800.0000023841858,\"~:y1\",0,\"~:x2\",820.000002682209,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a5722f855": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-0\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",20.000000298023224,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",20.000000298023224,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",0,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5722f855\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",0,\"~:y1\",0,\"~:x2\",20.000000298023224,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a5853f075": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-50\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1000.0000029802322,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1020.0000032782555,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1020.0000032782555,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",1000.0000029802322,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5853f075\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",1000.0000029802322,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1000.0000029802322,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",1000.0000029802322,\"~:y1\",0,\"~:x2\",1020.0000032782555,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a58713434": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:background-blur\",[\"^ \",\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5878a273\",\"~:type\",\"^1\",\"~:value\",20,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"blurred-child\",\"~:width\",259.99999046325684,\"^3\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0,\"~:y\",0]],[\"^=\",[\"^ \",\"~:x\",259.99999046325684,\"~:y\",0]],[\"^=\",[\"^ \",\"~:x\",259.99999046325684,\"~:y\",320.0000047683716]],[\"^=\",[\"^ \",\"~:x\",0,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:r1\",0,\"^2\",\"~u099a17ba-4c81-804d-8008-9a2a58713434\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a586a1217\",\"~:frame-id\",\"~u099a17ba-4c81-804d-8008-9a2a586a1217\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^:\",259.99999046325684,\"~:height\",320.0000047683716,\"~:x1\",0,\"~:y1\",0,\"~:x2\",259.99999046325684,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.1]],\"~:flip-x\",null,\"^N\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a579e7ad4": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-16\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",320.0000009536743,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",340.00000125169754,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",340.00000125169754,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",320.0000009536743,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a579e7ad4\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",320.0000009536743,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",320.0000009536743,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",320.0000009536743,\"~:y1\",0,\"~:x2\",340.00000125169754,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a586a1217": "[\"~#shape\",[\"^ \",\"~:y\",40.000003814697266,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"clip-on-child-larger\",\"~:width\",179.99999523162842,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",39.99999809265137,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",219.99999332427979,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",219.99999332427979,\"~:y\",280.00001335144043]],[\"^:\",[\"^ \",\"~:x\",39.99999809265137,\"~:y\",280.00001335144043]]],\"~:r2\",24,\"~:show-content\",false,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",24,\"~:r1\",24,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a586a1217\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#ff00ff\",\"~:stroke-opacity\",1,\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",2]],\"~:x\",39.99999809265137,\"~:proportion\",1,\"~:r4\",24,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",39.99999809265137,\"~:y\",40.000003814697266,\"^6\",179.99999523162842,\"~:height\",240.00000953674316,\"~:x1\",39.99999809265137,\"~:y1\",40.000003814697266,\"~:x2\",219.99999332427979,\"~:y2\",280.00001335144043]],\"~:fills\",[],\"~:flip-x\",null,\"^N\",240.00000953674316,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a58713434\"]]]",
"~u099a17ba-4c81-804d-8008-9a2a588ce9f6": "[\"~#shape\",[\"^ \",\"~:y\",40.000003814697266,\"~:background-blur\",[\"^ \",\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a58944011\",\"~:type\",\"^1\",\"~:value\",20,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"blurred-child\",\"~:width\",179.99999523162842,\"^3\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",40.000003814697266]],[\"^=\",[\"^ \",\"~:x\",739.9999685287476,\"~:y\",40.000003814697266]],[\"^=\",[\"^ \",\"~:x\",739.9999685287476,\"~:y\",280.00001335144043]],[\"^=\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",280.00001335144043]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:r1\",0,\"^2\",\"~u099a17ba-4c81-804d-8008-9a2a588ce9f6\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5886910d\",\"~:frame-id\",\"~u099a17ba-4c81-804d-8008-9a2a5886910d\",\"~:strokes\",[],\"~:x\",559.9999732971191,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",40.000003814697266,\"^:\",179.99999523162842,\"~:height\",240.00000953674316,\"~:x1\",559.9999732971191,\"~:y1\",40.000003814697266,\"~:x2\",739.9999685287476,\"~:y2\",280.00001335144043]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.1]],\"~:flip-x\",null,\"^N\",240.00000953674316,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a587ff716": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:background-blur\",[\"^ \",\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5885fde3\",\"~:type\",\"^1\",\"~:value\",20,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"blurred-child\",\"~:width\",259.99999046325684,\"^3\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",259.9999752044678,\"~:y\",0]],[\"^=\",[\"^ \",\"~:x\",519.9999656677246,\"~:y\",0]],[\"^=\",[\"^ \",\"~:x\",519.9999656677246,\"~:y\",320.0000047683716]],[\"^=\",[\"^ \",\"~:x\",259.9999752044678,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:r1\",0,\"^2\",\"~u099a17ba-4c81-804d-8008-9a2a587ff716\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a587927c9\",\"~:frame-id\",\"~u099a17ba-4c81-804d-8008-9a2a587927c9\",\"~:strokes\",[],\"~:x\",259.9999752044678,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",259.9999752044678,\"~:y\",0,\"^:\",259.99999046325684,\"~:height\",320.0000047683716,\"~:x1\",259.9999752044678,\"~:y1\",0,\"~:x2\",519.9999656677246,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.1]],\"~:flip-x\",null,\"^N\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a5894fa56": "[\"~#shape\",[\"^ \",\"~:y\",40.000003814697266,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"clip-on-text-child\",\"~:width\",179.99999523162842,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",819.999960899353,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",999.9999561309814,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",999.9999561309814,\"~:y\",280.00001335144043]],[\"^:\",[\"^ \",\"~:x\",819.999960899353,\"~:y\",280.00001335144043]]],\"~:r2\",24,\"~:show-content\",false,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",24,\"~:r1\",24,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5894fa56\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#ff00ff\",\"~:stroke-opacity\",1,\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",2]],\"~:x\",819.999960899353,\"~:proportion\",1,\"~:r4\",24,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",819.999960899353,\"~:y\",40.000003814697266,\"^6\",179.99999523162842,\"~:height\",240.00000953674316,\"~:x1\",819.999960899353,\"~:y1\",40.000003814697266,\"~:x2\",999.9999561309814,\"~:y2\",280.00001335144043]],\"~:fills\",[],\"~:flip-x\",null,\"^N\",240.00000953674316,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a589b7051\"]]]",
"~u099a17ba-4c81-804d-8008-9a2a589b7051": "[\"~#shape\",[\"^ \",\"~:y\",60,\"~:background-blur\",[\"^ \",\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a58a8f6a4\",\"~:type\",\"^1\",\"~:value\",20,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-width\",\"~:content\",[\"^ \",\"^3\",\"root\",\"~:children\",[[\"^ \",\"^3\",\"paragraph-set\",\"^<\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^<\",[[\"^ \",\"^=\",\"1.2\",\"^>\",\"normal\",\"~:typography-ref-id\",null,\"~:text-transform\",\"none\",\"~:text-align\",\"left\",\"~:font-id\",\"sourcesanspro\",\"~:font-size\",\"110\",\"~:font-weight\",\"400\",\"~:typography-ref-file\",null,\"~:text-direction\",\"ltr\",\"~:font-variant-id\",\"regular\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.25]],\"~:font-family\",\"sourcesanspro\",\"~:text\",\"Aa\"]],\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"110\",\"^D\",\"400\",\"^E\",null,\"^F\",\"ltr\",\"^3\",\"paragraph\",\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0\",\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.25]],\"^M\",\"sourcesanspro\"],[\"^ \",\"^=\",\"1.2\",\"^>\",\"normal\",\"^<\",[[\"^ \",\"^=\",\"1.2\",\"^>\",\"normal\",\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"110\",\"^D\",\"400\",\"^E\",null,\"^F\",\"ltr\",\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0\",\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.25]],\"^M\",\"sourcesanspro\",\"^N\",\"Aa\"]],\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"110\",\"^D\",\"400\",\"^E\",null,\"^F\",\"ltr\",\"^3\",\"paragraph\",\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0\",\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.25]],\"^M\",\"sourcesanspro\"]]]]],\"~:name\",\"blurred-text-child\",\"~:width\",117,\"^3\",\"^N\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",790,\"~:y\",60]],[\"^R\",[\"^ \",\"~:x\",907,\"~:y\",60]],[\"^R\",[\"^ \",\"~:x\",907,\"~:y\",324]],[\"^R\",[\"^ \",\"~:x\",790,\"~:y\",324]]],\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"^2\",\"~u099a17ba-4c81-804d-8008-9a2a589b7051\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5894fa56\",\"~:position-data\",[[\"^ \",\"~:y\",197.22000122070312,\"^=\",\"1.2\",\"^>\",\"normal\",\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"110px\",\"^D\",\"400\",\"^E\",null,\"^F\",\"ltr\",\"^P\",116.1199951171875,\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0px\",\"~:x\",790,\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.25]],\"~:direction\",\"ltr\",\"^M\",\"sourcesanspro\",\"~:height\",142.44000244140625,\"^N\",\"Aa\"],[\"^ \",\"~:y\",329.2200012207031,\"^=\",\"1.2\",\"^>\",\"normal\",\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"110px\",\"^D\",\"400\",\"^E\",null,\"^F\",\"ltr\",\"^P\",116.1199951171875,\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0px\",\"~:x\",790,\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.25]],\"^Z\",\"ltr\",\"^M\",\"sourcesanspro\",\"^[\",142.44000244140625,\"^N\",\"Aa\"]],\"~:frame-id\",\"~u099a17ba-4c81-804d-8008-9a2a5894fa56\",\"~:x\",790,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",790,\"~:y\",60,\"^P\",117,\"^[\",264,\"~:x1\",790,\"~:y1\",60,\"~:x2\",907,\"~:y2\",324]],\"~:flip-x\",null,\"^[\",264,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a58248d30": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-41\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",820.0000024437904,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",840.0000027418137,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",840.0000027418137,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",820.0000024437904,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a58248d30\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",820.0000024437904,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",820.0000024437904,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",820.0000024437904,\"~:y1\",0,\"~:x2\",840.0000027418137,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a5800e610": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-34\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",680.0000020265579,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",700.0000023245811,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",700.0000023245811,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",680.0000020265579,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5800e610\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",680.0000020265579,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",680.0000020265579,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",680.0000020265579,\"~:y1\",0,\"~:x2\",700.0000023245811,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a585a7293": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-51\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1020.0000030398369,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1040.00000333786,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1040.00000333786,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",1020.0000030398369,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a585a7293\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",1020.0000030398369,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1020.0000030398369,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",1020.0000030398369,\"~:y1\",0,\"~:x2\",1040.00000333786,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a573259d2": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-1\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",20.000000059604645,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",40.00000035762787,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",40.00000035762787,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",20.000000059604645,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a573259d2\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",20.000000059604645,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",20.000000059604645,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",20.000000059604645,\"~:y1\",0,\"~:x2\",40.00000035762787,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a57b6beb2": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-20\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",400.0000011920929,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",420.0000014901161,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",420.0000014901161,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",400.0000011920929,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57b6beb2\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",400.0000011920929,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",400.0000011920929,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",400.0000011920929,\"~:y1\",0,\"~:x2\",420.0000014901161,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a5886910d": "[\"~#shape\",[\"^ \",\"~:y\",40.000003814697266,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"clip-on-child-exact\",\"~:width\",179.99999523162842,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",739.9999685287476,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",739.9999685287476,\"~:y\",280.00001335144043]],[\"^:\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",280.00001335144043]]],\"~:r2\",24,\"~:show-content\",false,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",24,\"~:r1\",24,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5886910d\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#ff00ff\",\"~:stroke-opacity\",1,\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",2]],\"~:x\",559.9999732971191,\"~:proportion\",1,\"~:r4\",24,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",40.000003814697266,\"^6\",179.99999523162842,\"~:height\",240.00000953674316,\"~:x1\",559.9999732971191,\"~:y1\",40.000003814697266,\"~:x2\",739.9999685287476,\"~:y2\",280.00001335144043]],\"~:fills\",[],\"~:flip-x\",null,\"^N\",240.00000953674316,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a588ce9f6\"]]]",
"~u099a17ba-4c81-804d-8008-9a2a584428ad": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-47\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",940.0000028014183,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",960.0000030994415,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",960.0000030994415,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",940.0000028014183,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a584428ad\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",940.0000028014183,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",940.0000028014183,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",940.0000028014183,\"~:y1\",0,\"~:x2\",960.0000030994415,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a5749400d": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-4\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",80.00000023841858,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",100.0000005364418,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",100.0000005364418,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",80.00000023841858,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5749400d\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",80.00000023841858,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",80.00000023841858,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",80.00000023841858,\"~:y1\",0,\"~:x2\",100.0000005364418,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a578dcf0d": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-14\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",280.000000834465,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",300.00000113248825,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",300.00000113248825,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",280.000000834465,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a578dcf0d\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",280.000000834465,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",280.000000834465,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",280.000000834465,\"~:y1\",0,\"~:x2\",300.00000113248825,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a5864694c": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:index\",53,\"~:name\",\"backdrop\",\"~:width\",1060.0000033974648,\"~:type\",\"~:group\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0,\"~:y\",0]],[\"^:\",[\"^ \",\"~:x\",1060.0000033974648,\"~:y\",0]],[\"^:\",[\"^ \",\"~:x\",1060.0000033974648,\"~:y\",320.0000047683716]],[\"^:\",[\"^ \",\"~:x\",0,\"~:y\",320.0000047683716]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",1060.0000033974648,\"~:height\",320.0000047683716,\"~:x1\",0,\"~:y1\",0,\"~:x2\",1060.0000033974648,\"~:y2\",320.0000047683716]],\"~:fills\",[],\"~:flip-x\",null,\"^D\",320.0000047683716,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a5722f855\",\"~u099a17ba-4c81-804d-8008-9a2a573259d2\",\"~u099a17ba-4c81-804d-8008-9a2a573b052e\",\"~u099a17ba-4c81-804d-8008-9a2a574272cb\",\"~u099a17ba-4c81-804d-8008-9a2a5749400d\",\"~u099a17ba-4c81-804d-8008-9a2a5750f0ef\",\"~u099a17ba-4c81-804d-8008-9a2a57590dc2\",\"~u099a17ba-4c81-804d-8008-9a2a575f6f84\",\"~u099a17ba-4c81-804d-8008-9a2a5765bd49\",\"~u099a17ba-4c81-804d-8008-9a2a576c177d\",\"~u099a17ba-4c81-804d-8008-9a2a5772831b\",\"~u099a17ba-4c81-804d-8008-9a2a577afc79\",\"~u099a17ba-4c81-804d-8008-9a2a578122dc\",\"~u099a17ba-4c81-804d-8008-9a2a578826e0\",\"~u099a17ba-4c81-804d-8008-9a2a578dcf0d\",\"~u099a17ba-4c81-804d-8008-9a2a57981d1d\",\"~u099a17ba-4c81-804d-8008-9a2a579e7ad4\",\"~u099a17ba-4c81-804d-8008-9a2a57a5a180\",\"~u099a17ba-4c81-804d-8008-9a2a57abfe20\",\"~u099a17ba-4c81-804d-8008-9a2a57b171fc\",\"~u099a17ba-4c81-804d-8008-9a2a57b6beb2\",\"~u099a17ba-4c81-804d-8008-9a2a57bc32eb\",\"~u099a17ba-4c81-804d-8008-9a2a57c1477a\",\"~u099a17ba-4c81-804d-8008-9a2a57c81962\",\"~u099a17ba-4c81-804d-8008-9a2a57cd8cae\",\"~u099a17ba-4c81-804d-8008-9a2a57d2c500\",\"~u099a17ba-4c81-804d-8008-9a2a57d786dd\",\"~u099a17ba-4c81-804d-8008-9a2a57dcb9a7\",\"~u099a17ba-4c81-804d-8008-9a2a57e26c66\",\"~u099a17ba-4c81-804d-8008-9a2a57e7221e\",\"~u099a17ba-4c81-804d-8008-9a2a57ec0a79\",\"~u099a17ba-4c81-804d-8008-9a2a57f0d9a7\",\"~u099a17ba-4c81-804d-8008-9a2a57f5d458\",\"~u099a17ba-4c81-804d-8008-9a2a57faee43\",\"~u099a17ba-4c81-804d-8008-9a2a5800e610\",\"~u099a17ba-4c81-804d-8008-9a2a58059cfd\",\"~u099a17ba-4c81-804d-8008-9a2a580a33cb\",\"~u099a17ba-4c81-804d-8008-9a2a580f6e3e\",\"~u099a17ba-4c81-804d-8008-9a2a5814337e\",\"~u099a17ba-4c81-804d-8008-9a2a58193cbf\",\"~u099a17ba-4c81-804d-8008-9a2a581f6595\",\"~u099a17ba-4c81-804d-8008-9a2a58248d30\",\"~u099a17ba-4c81-804d-8008-9a2a5829b348\",\"~u099a17ba-4c81-804d-8008-9a2a582f24ee\",\"~u099a17ba-4c81-804d-8008-9a2a5833c5bb\",\"~u099a17ba-4c81-804d-8008-9a2a583a7c6b\",\"~u099a17ba-4c81-804d-8008-9a2a583f7ca0\",\"~u099a17ba-4c81-804d-8008-9a2a584428ad\",\"~u099a17ba-4c81-804d-8008-9a2a5849a83a\",\"~u099a17ba-4c81-804d-8008-9a2a584e9da3\",\"~u099a17ba-4c81-804d-8008-9a2a5853f075\",\"~u099a17ba-4c81-804d-8008-9a2a585a7293\",\"~u099a17ba-4c81-804d-8008-9a2a585f48c3\"]]]",
"~u099a17ba-4c81-804d-8008-9a2a5750f0ef": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-5\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",100.00000029802322,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",120.00000059604645,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",120.00000059604645,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",100.00000029802322,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5750f0ef\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",100.00000029802322,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",100.00000029802322,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",100.00000029802322,\"~:y1\",0,\"~:x2\",120.00000059604645,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a573b052e": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-2\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",40.00000011920929,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",60.00000041723251,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",60.00000041723251,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",40.00000011920929,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a573b052e\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",40.00000011920929,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",40.00000011920929,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",40.00000011920929,\"~:y1\",0,\"~:x2\",60.00000041723251,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a582f24ee": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-43\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",860.0000025629997,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",880.000002861023,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",880.000002861023,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",860.0000025629997,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a582f24ee\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",860.0000025629997,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",860.0000025629997,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",860.0000025629997,\"~:y1\",0,\"~:x2\",880.000002861023,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a57cd8cae": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-24\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",480.0000014305115,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",500.0000017285347,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",500.0000017285347,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",480.0000014305115,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57cd8cae\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",480.0000014305115,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",480.0000014305115,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",480.0000014305115,\"~:y1\",0,\"~:x2\",500.0000017285347,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a5765bd49": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-8\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",160.00000047683716,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",180.00000077486038,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",180.00000077486038,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",160.00000047683716,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5765bd49\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",160.00000047683716,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",160.00000047683716,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",160.00000047683716,\"~:y1\",0,\"~:x2\",180.00000077486038,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a587927c9": "[\"~#shape\",[\"^ \",\"~:y\",40.000003814697266,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"clip-off-child-larger\",\"~:width\",179.99999523162842,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",299.99998569488525,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",479.9999809265137,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",479.9999809265137,\"~:y\",280.00001335144043]],[\"^:\",[\"^ \",\"~:x\",299.99998569488525,\"~:y\",280.00001335144043]]],\"~:r2\",24,\"~:show-content\",true,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",24,\"~:r1\",24,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a587927c9\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#ff00ff\",\"~:stroke-opacity\",1,\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",2]],\"~:x\",299.99998569488525,\"~:proportion\",1,\"~:r4\",24,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",299.99998569488525,\"~:y\",40.000003814697266,\"^6\",179.99999523162842,\"~:height\",240.00000953674316,\"~:x1\",299.99998569488525,\"~:y1\",40.000003814697266,\"~:x2\",479.9999809265137,\"~:y2\",280.00001335144043]],\"~:fills\",[],\"~:flip-x\",null,\"^N\",240.00000953674316,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a587ff716\"]]]",
"~u099a17ba-4c81-804d-8008-9a2a5829b348": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-42\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",840.0000025033951,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",860.0000028014183,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",860.0000028014183,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",840.0000025033951,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5829b348\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",840.0000025033951,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",840.0000025033951,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",840.0000025033951,\"~:y1\",0,\"~:x2\",860.0000028014183,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a583a7c6b": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-45\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",900.000002682209,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",920.0000029802322,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",920.0000029802322,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",900.000002682209,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a583a7c6b\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",900.000002682209,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",900.000002682209,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",900.000002682209,\"~:y1\",0,\"~:x2\",920.0000029802322,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a580a33cb": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-36\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",720.0000021457672,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",740.0000024437904,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",740.0000024437904,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",720.0000021457672,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a580a33cb\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",720.0000021457672,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",720.0000021457672,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",720.0000021457672,\"~:y1\",0,\"~:x2\",740.0000024437904,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a574272cb": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-3\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",60.000000178813934,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",80.00000047683716,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",80.00000047683716,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",60.000000178813934,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a574272cb\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",60.000000178813934,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",60.000000178813934,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",60.000000178813934,\"~:y1\",0,\"~:x2\",80.00000047683716,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a57bc32eb": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-21\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",420.00000125169754,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",440.00000154972076,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",440.00000154972076,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",420.00000125169754,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57bc32eb\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",420.00000125169754,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",420.00000125169754,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",420.00000125169754,\"~:y1\",0,\"~:x2\",440.00000154972076,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a575f6f84": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-7\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",140.0000004172325,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",160.00000071525574,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",160.00000071525574,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",140.0000004172325,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a575f6f84\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",140.0000004172325,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",140.0000004172325,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",140.0000004172325,\"~:y1\",0,\"~:x2\",160.00000071525574,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a57dcb9a7": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-27\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",540.0000016093254,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",560.0000019073486,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",560.0000019073486,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",540.0000016093254,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57dcb9a7\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",540.0000016093254,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",540.0000016093254,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",540.0000016093254,\"~:y1\",0,\"~:x2\",560.0000019073486,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a57f0d9a7": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-31\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",620.000001847744,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",640.0000021457672,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",640.0000021457672,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",620.000001847744,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57f0d9a7\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",620.000001847744,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",620.000001847744,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",620.000001847744,\"~:y1\",0,\"~:x2\",640.0000021457672,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a57e26c66": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-28\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",560.00000166893,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",580.0000019669533,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",580.0000019669533,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",560.00000166893,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57e26c66\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",560.00000166893,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",560.00000166893,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",560.00000166893,\"~:y1\",0,\"~:x2\",580.0000019669533,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a57a5a180": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-17\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",340.00000101327896,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",360.0000013113022,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",360.0000013113022,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",340.00000101327896,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57a5a180\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",340.00000101327896,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",340.00000101327896,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",340.00000101327896,\"~:y1\",0,\"~:x2\",360.0000013113022,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a57d2c500": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-25\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",500.0000014901161,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",520.0000017881393,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",520.0000017881393,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",500.0000014901161,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57d2c500\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",500.0000014901161,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",500.0000014901161,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",500.0000014901161,\"~:y1\",0,\"~:x2\",520.0000017881393,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a583f7ca0": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-46\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",920.0000027418137,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",940.0000030398369,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",940.0000030398369,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",920.0000027418137,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a583f7ca0\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",920.0000027418137,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",920.0000027418137,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",920.0000027418137,\"~:y1\",0,\"~:x2\",940.0000030398369,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a578826e0": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-13\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",260.0000007748604,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",280.0000010728836,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",280.0000010728836,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",260.0000007748604,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a578826e0\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",260.0000007748604,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",260.0000007748604,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",260.0000007748604,\"~:y1\",0,\"~:x2\",280.0000010728836,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a57abfe20": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-18\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",360.0000010728836,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",380.00000137090683,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",380.00000137090683,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",360.0000010728836,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57abfe20\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",360.0000010728836,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",360.0000010728836,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",360.0000010728836,\"~:y1\",0,\"~:x2\",380.00000137090683,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a584e9da3": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-49\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",980.0000029206276,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1000.0000032186508,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1000.0000032186508,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",980.0000029206276,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a584e9da3\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",980.0000029206276,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",980.0000029206276,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",980.0000029206276,\"~:y1\",0,\"~:x2\",1000.0000032186508,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a585f48c3": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-52\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1040.0000030994415,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1060.0000033974648,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1060.0000033974648,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",1040.0000030994415,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a585f48c3\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",1040.0000030994415,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1040.0000030994415,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",1040.0000030994415,\"~:y1\",0,\"~:x2\",1060.0000033974648,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a57faee43": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-33\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",660.0000019669533,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",680.0000022649765,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",680.0000022649765,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",660.0000019669533,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57faee43\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",660.0000019669533,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",660.0000019669533,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",660.0000019669533,\"~:y1\",0,\"~:x2\",680.0000022649765,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a57590dc2": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-6\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",120.00000035762787,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",140.0000006556511,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",140.0000006556511,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",120.00000035762787,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57590dc2\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",120.00000035762787,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",120.00000035762787,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",120.00000035762787,\"~:y1\",0,\"~:x2\",140.0000006556511,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
"~u099a17ba-4c81-804d-8008-9a2a57c81962": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-23\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",460.00000137090683,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",480.00000166893005,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",480.00000166893005,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",460.00000137090683,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57c81962\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",460.00000137090683,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",460.00000137090683,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",460.00000137090683,\"~:y1\",0,\"~:x2\",480.00000166893005,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]"
}
},
"~:id": "~u77d38721-22c1-81f4-8008-9a2a3e7ce675",
"~:name": "bg-blur-clip"
}
},
"~:id": "~u77d38721-22c1-81f4-8008-9a2a3e7ce674",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}
@@ -600,6 +600,12 @@ export class WorkspacePage extends BaseWebSocketPage {
.getByRole("button", { name: "Comments (C)" })
.click(clickOptions);
}
async toggleCommentsVisibilityFromMenu(clickOptions = {}) {
await this.page.getByRole("button", { name: "Main menu" }).click();
await this.page.getByText("view").last().click();
await this.page.locator("#file-menu-comments").click(clickOptions);
}
}
export default WorkspacePage;
@@ -602,4 +602,21 @@ 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();
});
@@ -35,3 +35,64 @@ test("Group bubbles when zooming out if they overlap", async ({ page }) => {
/unread/,
);
});
test("Opening the Comments section only temporarily overrides a disabled global comments setting", async ({
page,
}) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.setupFileWithComments();
await workspacePage.goToWorkspace();
const bubble = page.getByTestId("floating-thread-bubble-1");
// "Display comments" is enabled by default, so the bubble is already visible.
await expect(bubble).toBeVisible();
// Turn the global "Display comments" setting off from the main menu.
await workspacePage.toggleCommentsVisibilityFromMenu();
await expect(bubble).toBeHidden();
// Opening the Comments section shows comments regardless of the global setting.
await workspacePage.showComments();
await expect(bubble).toBeVisible();
// Closing the Comments section falls back to the (still disabled) global setting.
await workspacePage.showComments();
await expect(bubble).toBeHidden();
// The global setting itself must be untouched by opening/closing the section.
await page.getByRole("button", { name: "Main menu" }).click();
await page.getByText("view").last().click();
await expect(page.locator("#file-menu-comments")).toContainText(
"Show comments",
);
await page.keyboard.press("Escape");
});
test("Comments stay visible through opening and closing the Comments section when the global setting is enabled", async ({
page,
}) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.setupFileWithComments();
await workspacePage.goToWorkspace();
const bubble = page.getByTestId("floating-thread-bubble-1");
// "Display comments" is enabled by default.
await expect(bubble).toBeVisible();
await workspacePage.showComments();
await expect(bubble).toBeVisible();
await workspacePage.showComments();
await expect(bubble).toBeVisible();
await page.getByRole("button", { name: "Main menu" }).click();
await page.getByText("view").last().click();
await expect(page.locator("#file-menu-comments")).toContainText(
"Hide comments",
);
await page.keyboard.press("Escape");
});
@@ -584,11 +584,38 @@
(into {}))]
(merge resolved dropped)))
(defn- valid-token-value?
[[_ token]]
(some? (:value token)))
(def ^:private xform-invalid-value-tokens
(comp
(remove valid-token-value?)
(map (fn [[k token]]
[k (assoc token :errors [(wte/get-error-code :error.token/empty-input)])]))))
(defn- merge-invalid-value-tokens
"Tokens with a `nil` value (e.g. a composite typography token saved with
no fields filled in) must never reach StyleDictionary: some of its
preprocessors (`@tokens-studio/sd-transforms`'s font-styles preprocessor,
in particular) assume a typography token's value is never null and throw
an uncaught exception when it is, taking down token resolution for the
whole file.
`tokens` is the full, unfiltered token map; `resolved` only contains the
valid subset that was actually sent to StyleDictionary. Tag the invalid
ones with the same \"empty value\" error the token forms already use
instead of ever letting them reach the resolver."
[tokens resolved]
(into resolved xform-invalid-value-tokens tokens))
(defn resolve-tokens
[tokens]
(let [tokens-tree (ctob/tokens-tree tokens)]
(->> (resolve-tokens-tree tokens-tree #(get tokens (sd-token-name %)))
(rx/map #(merge-name-collisions tokens %)))))
(let [valid-tokens (into {} (filter valid-token-value?) tokens)
tokens-tree (ctob/tokens-tree valid-tokens)]
(->> (resolve-tokens-tree tokens-tree #(get valid-tokens (sd-token-name %)))
(rx/map #(merge-name-collisions valid-tokens %))
(rx/map #(merge-invalid-value-tokens tokens %)))))
(defn resolve-tokens-interactive
"Interactive check of resolving tokens.
@@ -610,15 +637,18 @@
computation we can restore any token, even clashing ones with the
same :name path by just looking up that :id in the ids map."
[tokens]
(let [{:keys [tokens-tree ids]} (ctob/backtrace-tokens-tree tokens)]
(->> (resolve-tokens-tree tokens-tree #(get ids (sd-token-uuid %)))
(rx/map #(merge-name-collisions tokens %)))))
(let [valid-tokens (into {} (filter valid-token-value?) tokens)
{:keys [tokens-tree ids]} (ctob/backtrace-tokens-tree valid-tokens)]
(->> (resolve-tokens-tree tokens-tree #(get ids (sd-token-uuid %)))
(rx/map #(merge-name-collisions valid-tokens %))
(rx/map #(merge-invalid-value-tokens tokens %)))))
(defn resolve-tokens-with-verbose-errors [tokens]
(resolve-tokens-tree
(ctob/tokens-tree tokens)
#(get tokens (sd-token-name %))
(StyleDictionary. (assoc default-config :log {:verbosity "verbose"}))))
(let [valid-tokens (into {} (filter valid-token-value?) tokens)]
(resolve-tokens-tree
(ctob/tokens-tree valid-tokens)
#(get valid-tokens (sd-token-name %))
(StyleDictionary. (assoc default-config :log {:verbosity "verbose"})))))
;; === Hooks
+6 -5
View File
@@ -326,11 +326,12 @@
(filter #(= page-id (:page-id %)))
(d/index-by :id)
(assoc state :comment-threads)))
(on-error [{:keys [type] :as err}]
(if (or (= :authentication type)
(= :not-found type))
(rx/empty)
(rx/throw err)))]
(on-error [cause]
(let [{:keys [type]} (ex-data cause)]
(if (or (= :authentication type)
(= :not-found type))
(rx/empty)
(rx/throw cause))))]
(ptk/reify ::fetch-comment-threads
ptk/WatchEvent
@@ -15,7 +15,6 @@
[app.main.data.workspace.drawing.common :as common]
[app.main.data.workspace.drawing.curve :as curve]
[app.main.data.workspace.drawing.line :as line]
[app.main.data.workspace.layout :as dwlo]
[app.main.data.workspace.path :as path]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
@@ -47,11 +46,6 @@
(when (= tool :path)
(rx/of (start-drawing :path)))
;; NOTE: comments are a special case and they manage they
;; own interrupt cycle.
(when (= tool :comments)
(rx/of (dwlo/toggle-layout-flag :display-comments :force? true)))
(when (and (not= tool :comments)
(not= tool :path))
(let [stopper (rx/filter (ptk/type? ::clear-drawing) stream)]
@@ -15,6 +15,7 @@
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc]
[app.common.geom.shapes :as gsh]
[app.common.logging :as log]
[app.common.math :as mth]
[app.common.types.component :as ctk]
[app.common.types.container :as ctn]
@@ -579,44 +580,57 @@
modifiers (calculate-modifiers state ignore-constraints ignore-snap-pixel modif-tree page-id params)]
(assoc state :workspace-modifiers modifiers))))))
(defn- without-nil-ids
"Drop nil-keyed entries from a modif-tree. A nil shape id (possible in
production builds, where the upstream asserts are elided) would crash
the WASM heap write with `uuid/get-u32` being called on nil."
[modif-tree]
(if (contains? modif-tree nil)
(do (log/warn :hint "modif-tree contains a nil shape id; ignoring entry")
(dissoc modif-tree nil))
modif-tree))
(defn- parse-structure-modifiers
[modif-tree]
(into
[]
(mapcat
(fn [[parent-id data]]
(when (ctm/has-structure? (:modifiers data))
(->> (concat
(get-in data [:modifiers :structure-parent])
(get-in data [:modifiers :structure-child]))
(mapcat
(fn [modifier]
(case (:type modifier)
:remove-children
(->> (:value modifier)
(map (fn [child-id]
{:type :remove-children
:parent parent-id
:id child-id
:index 0
:value 0})))
(comp
(mapcat
(fn [[parent-id data]]
(when (ctm/has-structure? (:modifiers data))
(->> (concat
(get-in data [:modifiers :structure-parent])
(get-in data [:modifiers :structure-child]))
(mapcat
(fn [modifier]
(case (:type modifier)
:remove-children
(->> (:value modifier)
(map (fn [child-id]
{:type :remove-children
:parent parent-id
:id child-id
:index 0
:value 0})))
:add-children
(->> (:value modifier)
(map (fn [child-id]
{:type :add-children
:parent parent-id
:id child-id
:index (:index modifier)
:value 0})))
:add-children
(->> (:value modifier)
(map (fn [child-id]
{:type :add-children
:parent parent-id
:id child-id
:index (:index modifier)
:value 0})))
:scale-content
[{:type :scale-content
:parent parent-id
:id parent-id
:index 0
:value (:value modifier)}]
nil)))))))
:scale-content
[{:type :scale-content
:parent parent-id
:id parent-id
:index 0
:value (:value modifier)}]
nil)))))))
(filter (fn [{:keys [id parent]}]
(and (some? id) (some? parent)))))
modif-tree))
@@ -624,7 +638,7 @@
(let [default-transform (gmt/matrix)]
(keep (fn [[id data]]
(cond
(= id uuid/zero)
(or (nil? id) (= id uuid/zero))
nil
(ctm/has-geometry? (:modifiers data))
@@ -693,65 +707,66 @@
subtree-ids-by-id selection-rect-cache]
:or {ignore-constraints false ignore-snap-pixel false}
:as params}]
(ptk/reify ::set-wasm-modifiers
ptk/UpdateEvent
(update [_ state]
(let [property-changes (extract-property-changes modif-tree)]
(if (d/not-empty? property-changes)
(-> state
(assoc :prev-wasm-props (:wasm-props state))
(assoc :wasm-props property-changes))
state)))
(let [modif-tree (without-nil-ids modif-tree)]
(ptk/reify ::set-wasm-modifiers
ptk/UpdateEvent
(update [_ state]
(let [property-changes (extract-property-changes modif-tree)]
(if (d/not-empty? property-changes)
(-> state
(assoc :prev-wasm-props (:wasm-props state))
(assoc :wasm-props property-changes))
state)))
ptk/WatchEvent
(watch [_ state _]
;; Entering an interactive transform (drag/resize/rotate). Flip
;; the renderer into fast + atlas-backdrop mode so the live
;; preview is cheap, tiles never appear sequentially and the main
;; thread is not blocked. The pair is closed in
;; `clear-local-transform`.
(ensure-interactive-transform-start!)
(let [snap-pixel? (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))
translation? (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))]
ptk/WatchEvent
(watch [_ state _]
;; Entering an interactive transform (drag/resize/rotate). Flip
;; the renderer into fast + atlas-backdrop mode so the live
;; preview is cheap, tiles never appear sequentially and the main
;; thread is not blocked. The pair is closed in
;; `clear-local-transform`.
(ensure-interactive-transform-start!)
(let [snap-pixel? (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))
translation? (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))]
(if translation?
;; Pure translation: no structure changes needed. If structure
;; modifiers were active from a previous non-translation frame
;; (e.g. shape hovered over a frame then dragged back out),
;; clear them now so the shape is not clipped by the old frame.
(when @wasm-structure-modifiers-active?
(wasm.api/clean-modifiers)
(vreset! wasm-structure-modifiers-active? false))
(let [objects (dsh/lookup-page-objects state)]
(set-wasm-props! objects (:prev-wasm-props state) (:wasm-props state))
(wasm.api/clean-modifiers)
(wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))
(vreset! wasm-structure-modifiers-active? true)))
(let [geometry-entries (parse-geometry-modifiers modif-tree)
root-modifiers (into [] (map (fn [[id data]] [id (:transform data)])) geometry-entries)
wasm-ready? (wasm.api/initialized?)
;; While the GL context is down (lost / mid-reload), keep the
;; root transforms so SVG selection/preview can still move.
;; `propagate-modifiers` returns [] when not ready, do not
;; treat that as "no modifiers".
modifiers
(cond
(or (not wasm-ready?)
(and translation? (not snap-pixel?)))
root-modifiers
(if translation?
;; Pure translation: no structure changes needed. If structure
;; modifiers were active from a previous non-translation frame
;; (e.g. shape hovered over a frame then dragged back out),
;; clear them now so the shape is not clipped by the old frame.
(when @wasm-structure-modifiers-active?
(wasm.api/clean-modifiers)
(vreset! wasm-structure-modifiers-active? false))
(let [objects (dsh/lookup-page-objects state)]
(set-wasm-props! objects (:prev-wasm-props state) (:wasm-props state))
(wasm.api/clean-modifiers)
(wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))
(vreset! wasm-structure-modifiers-active? true)))
(let [geometry-entries (parse-geometry-modifiers modif-tree)
root-modifiers (into [] (map (fn [[id data]] [id (:transform data)])) geometry-entries)
wasm-ready? (wasm.api/initialized?)
;; While the GL context is down (lost / mid-reload), keep the
;; root transforms so SVG selection/preview can still move.
;; `propagate-modifiers` returns [] when not ready, do not
;; treat that as "no modifiers".
modifiers
(cond
(or (not wasm-ready?)
(and translation? (not snap-pixel?)))
root-modifiers
:else
(let [propagated (wasm.api/propagate-modifiers geometry-entries snap-pixel?)]
(if (seq propagated) propagated root-modifiers)))]
(when wasm-ready?
(wasm.api/set-modifiers modifiers))
(let [ids (into [] xf:map-key geometry-entries)
selrect (when wasm-ready?
(if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers))
(cached-translation-selrect ids (second (first modifiers)) selection-rect-cache)
(wasm.api/get-selection-rect ids)))]
(rx/of (set-temporary-selrect selrect)
(set-temporary-modifiers modifiers))))))))
:else
(let [propagated (wasm.api/propagate-modifiers geometry-entries snap-pixel?)]
(if (seq propagated) propagated root-modifiers)))]
(when wasm-ready?
(wasm.api/set-modifiers modifiers))
(let [ids (into [] xf:map-key geometry-entries)
selrect (when wasm-ready?
(if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers))
(cached-translation-selrect ids (second (first modifiers)) selection-rect-cache)
(wasm.api/get-selection-rect ids)))]
(rx/of (set-temporary-selrect selrect)
(set-temporary-modifiers modifiers)))))))))
(defn propagate-structure-modifiers
[modif-tree objects]
@@ -782,58 +797,44 @@
subtree-ids-by-id]
:or {ignore-constraints false ignore-snap-pixel false snap-ignore-axis nil undo-transation? true}
:as params}]
(ptk/reify ::apply-wasm-modifiers
ptk/WatchEvent
(watch [_ state _]
(let [translation?
(every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))]
(wasm.api/clean-modifiers)
(when-not translation?
(wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)))
(let [modif-tree (without-nil-ids modif-tree)]
(ptk/reify ::apply-wasm-modifiers
ptk/WatchEvent
(watch [_ state _]
(let [translation?
(every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))]
(wasm.api/clean-modifiers)
(when-not translation?
(wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)))
;; Apply property changes (e.g. grow-type) to WASM shapes before
;; propagating geometry, so propagate_modifiers sees the updated state.
(doseq [[id {:keys [property value]}] (extract-property-changes modif-tree)]
(when (= property :grow-type)
(wasm.api/use-shape id)
(wasm.api/set-shape-grow-type value)))
;; Apply property changes (e.g. grow-type) to WASM shapes before
;; propagating geometry, so propagate_modifiers sees the updated state.
(doseq [[id {:keys [property value]}] (extract-property-changes modif-tree)]
(when (= property :grow-type)
(wasm.api/use-shape id)
(wasm.api/set-shape-grow-type value)))
(let [objects (dsh/lookup-page-objects state)
(let [objects (dsh/lookup-page-objects state)
geometry-entries
(parse-geometry-modifiers modif-tree)
geometry-entries
(parse-geometry-modifiers modif-tree)
snap-pixel?
(and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))
snap-pixel?
(and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))
transforms
(cond
(and translation? (not snap-pixel?))
;; Mirror WASM `propagate_modifiers` in CLJS: splat the
;; translation matrix onto every descendant. Without
;; this step the commit would only touch the dragged
;; primaries and descendants would snap back to their
;; pre-drag positions on drop.
;;
;; Skipped when `snap-pixel?` is on: WASM applies
;; per-shape pixel correction (different scale/translate
;; per descendant) which we can't replicate cheaply on
;; the CLJS side.
(reduce
(fn [acc [id data]]
(let [t (:transform data)
subtree-ids
(or (get subtree-ids-by-id id)
(cfh/get-children-ids-with-self objects id))]
(reduce (fn [a sid] (assoc a sid t)) acc subtree-ids)))
{}
geometry-entries)
;; Context lost / mid-reload: do not call into WASM. Use
;; root transforms (and splat translation onto descendants
;; when we can) so the commit still lands in file data.
(not (wasm.api/initialized?))
(if translation?
transforms
(cond
(and translation? (not snap-pixel?))
;; Mirror WASM `propagate_modifiers` in CLJS: splat the
;; translation matrix onto every descendant. Without
;; this step the commit would only touch the dragged
;; primaries and descendants would snap back to their
;; pre-drag positions on drop.
;;
;; Skipped when `snap-pixel?` is on: WASM applies
;; per-shape pixel correction (different scale/translate
;; per descendant) which we can't replicate cheaply on
;; the CLJS side.
(reduce
(fn [acc [id data]]
(let [t (:transform data)
@@ -843,71 +844,87 @@
(reduce (fn [a sid] (assoc a sid t)) acc subtree-ids)))
{}
geometry-entries)
(into {}
(map (fn [[id data]] [id (:transform data)]))
geometry-entries))
:else
(into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?)))
;; Context lost / mid-reload: do not call into WASM. Use
;; root transforms (and splat translation onto descendants
;; when we can) so the commit still lands in file data.
(not (wasm.api/initialized?))
(if translation?
(reduce
(fn [acc [id data]]
(let [t (:transform data)
subtree-ids
(or (get subtree-ids-by-id id)
(cfh/get-children-ids-with-self objects id))]
(reduce (fn [a sid] (assoc a sid t)) acc subtree-ids)))
{}
geometry-entries)
(into {}
(map (fn [[id data]] [id (:transform data)]))
geometry-entries))
ignore-tree
(calculate-ignore-tree-wasm transforms objects)
:else
(into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?)))
options
(-> params
(assoc :reg-objects? true)
(assoc :ignore-tree ignore-tree)
(assoc :translation? translation?)
;; Attributes that can change in the transform. This
;; way we don't have to check all the attributes
(assoc :attrs transform-attrs))
ignore-tree
(calculate-ignore-tree-wasm transforms objects)
modif-tree
(propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state))
options
(-> params
(assoc :reg-objects? true)
(assoc :ignore-tree ignore-tree)
(assoc :translation? translation?)
;; Attributes that can change in the transform. This
;; way we don't have to check all the attributes
(assoc :attrs transform-attrs))
ids
(into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms))
modif-tree
(propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state))
update-shape
(fn [shape]
(let [shape-id (dm/get-prop shape :id)
transform (get transforms shape-id)
modifiers (dm/get-in modif-tree [shape-id :modifiers])]
(-> shape
(gsh/apply-transform transform)
(ctm/apply-structure-modifiers modifiers))))
ids
(into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms))
bool-ids
(into #{}
(comp
(mapcat (partial cfh/get-parents-with-self objects))
(filter cfh/bool-shape?)
(map :id))
ids)
update-shape
(fn [shape]
(let [shape-id (dm/get-prop shape :id)
transform (get transforms shape-id)
modifiers (dm/get-in modif-tree [shape-id :modifiers])]
(-> shape
(gsh/apply-transform transform)
(ctm/apply-structure-modifiers modifiers))))
undo-id (js/Symbol)]
(rx/concat
(if undo-transation?
(rx/of (dwu/start-undo-transaction undo-id))
(rx/empty))
(rx/of
(clear-local-transform)
(ptk/event ::dwg/move-frame-guides {:ids ids :transforms transforms})
(ptk/event ::dwcm/move-frame-comment-threads transforms)
(dwsh/update-shapes ids update-shape options)
bool-ids
(into #{}
(comp
(mapcat (partial cfh/get-parents-with-self objects))
(filter cfh/bool-shape?)
(map :id))
ids)
;; The update to the bool path needs to be in a different operation because it
;; needs to have the updated children info.
;; `update-layout? false`: recalculating a bool path can never change
;; `:hidden`, and the layout check would recompute the whole boolean
;; path in WASM once per bool shape just to find that out.
(dwsh/update-shapes bool-ids path/update-bool-shape (assoc options
:with-objects? true
:update-layout? false)))
undo-id (js/Symbol)]
(if undo-transation?
(rx/of (dwu/commit-undo-transaction undo-id))
(rx/empty))))))))
(rx/concat
(if undo-transation?
(rx/of (dwu/start-undo-transaction undo-id))
(rx/empty))
(rx/of
(clear-local-transform)
(ptk/event ::dwg/move-frame-guides {:ids ids :transforms transforms})
(ptk/event ::dwcm/move-frame-comment-threads transforms)
(dwsh/update-shapes ids update-shape options)
;; The update to the bool path needs to be in a different operation because it
;; needs to have the updated children info.
;; `update-layout? false`: recalculating a bool path can never change
;; `:hidden`, and the layout check would recompute the whole boolean
;; path in WASM once per bool shape just to find that out.
(dwsh/update-shapes bool-ids path/update-bool-shape (assoc options
:with-objects? true
:update-layout? false)))
(if undo-transation?
(rx/of (dwu/commit-undo-transaction undo-id))
(rx/empty)))))))))
(def ^:private
xf-rotation-shape
@@ -335,14 +335,15 @@
(let [page-id (:current-page-id state)
objects (dsh/lookup-page-objects state page-id)
shape (get objects shape-id)
container (get objects (:parent-id shape))
width (+ (:width container) (:width shape) 20) ;; 20 is the default gap for variants
x (- width (+ (:width shape) 30))] ;; 30 is the default margin for variants
(rx/of
(dwt/update-dimensions [(:parent-id shape)] :width width)
(dwt/update-position shape-id
{:x x}
{:absolute? false}))))))
container (get objects (:parent-id shape))]
(when (and (some? shape) (some? container))
(let [width (+ (:width container) (:width shape) 20) ;; 20 is the default gap for variants
x (- width (+ (:width shape) 30))] ;; 30 is the default margin for variants
(rx/of
(dwt/update-dimensions [(:parent-id shape)] :width width)
(dwt/update-position shape-id
{:x x}
{:absolute? false}))))))))
(defn add-new-variant
"Create a new variant and add it to the variant-container"
@@ -359,39 +360,40 @@
shape (get objects shape-id)
shape (if (ctc/is-variant-container? shape)
(get objects (last (:shapes shape)))
shape)
component-id (:component-id shape)
component (ctkl/get-component data component-id)
shape)]
(when (some? shape)
(let [component-id (:component-id shape)
component (ctkl/get-component data component-id)
container-id (:parent-id shape)
variant-container (get objects container-id)
has-layout? (ctsl/any-layout? variant-container)
container-id (:parent-id shape)
variant-container (get objects container-id)
has-layout? (ctsl/any-layout? variant-container)
new-component-id (uuid/next)
new-shape-id (uuid/next)
new-component-id (uuid/next)
new-shape-id (uuid/next)
prop-num (dec (count (:variant-properties component)))
prop-num (dec (count (:variant-properties component)))
changes (-> (pcb/empty-changes it page-id)
(pcb/with-library-data data)
(pcb/with-objects objects)
(pcb/with-page-id page-id)
(clv/generate-add-new-variant shape (:variant-id component) new-component-id new-shape-id prop-num))
changes (-> (pcb/empty-changes it page-id)
(pcb/with-library-data data)
(pcb/with-objects objects)
(pcb/with-page-id page-id)
(clv/generate-add-new-variant shape (:variant-id component) new-component-id new-shape-id prop-num))
undo-id (js/Symbol)]
(rx/concat
(rx/of
(dwu/start-undo-transaction undo-id)
(dch/commit-changes changes)
(when-not has-layout?
(resposition-and-resize-variant new-shape-id))
(dwu/commit-undo-transaction undo-id)
(ptk/data-event :layout/update {:ids [(:parent-id shape)]})
(if multiselect?
(dws/shift-select-shapes new-shape-id)
(dws/select-shape new-shape-id)))
(->> (rx/of (focus-property (:id variant-container)))
(rx/delay 250))))))))
undo-id (js/Symbol)]
(rx/concat
(rx/of
(dwu/start-undo-transaction undo-id)
(dch/commit-changes changes)
(when-not has-layout?
(resposition-and-resize-variant new-shape-id))
(dwu/commit-undo-transaction undo-id)
(ptk/data-event :layout/update {:ids [(:parent-id shape)]})
(if multiselect?
(dws/shift-select-shapes new-shape-id)
(dws/select-shape new-shape-id)))
(->> (rx/of (focus-property (:id variant-container)))
(rx/delay 250))))))))))
(defn transform-in-variant
"Given the id of a main shape of a component, creates a variant structure for
@@ -539,9 +541,10 @@
(let [objects (dsh/lookup-page-objects state)
selected-ids (dsh/lookup-selected state)
selected-shapes (map (d/getf objects) selected-ids)
add-new-variant? (every? ctc/is-variant? selected-shapes)
add-new-variant? (and (seq selected-shapes) (every? ctc/is-variant? selected-shapes))
undo-id (js/Symbol)]
(if add-new-variant?
(cond
add-new-variant?
(rx/concat
(rx/of
(ev/event {::ev/name "add-new-variant" ::ev/origin "workspace:shortcut-duplicate"})
@@ -549,7 +552,12 @@
(add-new-variant (first selected-ids) false))
(rx/from (map #(add-new-variant % true) (rest selected-ids)))
(rx/of (dwu/commit-undo-transaction undo-id)))
(rx/of (dws/duplicate-selected true)))))))
(seq selected-ids)
(rx/of (dws/duplicate-selected true))
:else
(rx/empty))))))
(defn rename-variant
"Rename the variant container and all components belonging to this variant"
@@ -114,6 +114,7 @@
(str "<ul>" items "</ul>"))]
(st/emit! (ntf/show {:content (tr "errors.weak-password")
:detail detail
:is-html true
:type :toast
:level :error})))
@@ -419,9 +419,14 @@ $thumbnail-default-height: px2rem(168);
}
.library-typography-sample {
display: flex;
justify-content: center;
align-items: center;
block-size: px2rem(20);
line-height: 1;
margin-inline-end: var(--sp-xs);
inline-size: px2rem(20);
overflow: hidden;
}
// ─── MISC ──────────────────────────────────────
@@ -120,7 +120,7 @@
[:div
(tr "nitrate.code-activation.footer-after") " "
[:a {:class (stl/css :link)
:href "mailto:sales@nitrate.com"}
"sales@nitrate.com"]
:href "mailto:sales@penpot.app"}
"sales@penpot.app"]
" "
(tr "nitrate.code-activation.footer-before")]]]]]))
@@ -745,7 +745,6 @@
:cta-link (if (and (contains? cf/flags :admin-console) nitrate?) #(open-contact-sales-modal subscription-type "Unlimited") #(open-subscription-modal "unlimited" subscription))
:cta-text-with-icon (tr "subscription.settings.more-information")
:cta-link-with-icon go-to-pricing-page
:recommended (= subscription-type "professional")
:show-button-cta (= subscription-type "professional")
:current-plan false}])
@@ -783,6 +782,7 @@
:cta-text-with-icon (tr "subscription.settings.more-information")
:cta-link-with-icon go-to-pricing-page
:code-action :activate
:recommended (= subscription-type "professional")
:show-button-cta (not nitrate-license)
:current-plan false
:inline-error nitrate-start-error-message}])]]]))
@@ -908,7 +908,7 @@
(js/encodeURIComponent email)
mailto-url
(dm/str "mailto:sales@penpot.net"
(dm/str "mailto:sales@penpot.app"
"?subject=Request%20to%20Cancel%20Enterprise%20Subscription"
"&body=Hello%2C%0A%0A"
"I%20would%20like%20to%20cancel%20my%20Enterprise%20subscription.%0A"
@@ -930,8 +930,8 @@
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-text-medium)}
(tr "nitrate.subscription.settings.manual-contact-us")]
[:a {:class (stl/css :cta-link) :href "mailto:sales@penpot.net"}
"sales@penpot.net"]
[:a {:class (stl/css :cta-link) :href "mailto:sales@penpot.app"}
"sales@penpot.app"]
[:div {:class (stl/css :action-buttons)}
[:> button* {:class (stl/css :button-full-width)
:variant "primary"
@@ -276,7 +276,7 @@
(seq strokes)
[:> h/sortable-container* {}
(for [[index value] (d/enumerate (:strokes values []))]
[:> stroke-row* {:key (dm/str "stroke-" index "-" (hash applied-tokens))
[:> stroke-row* {:key (dm/str "stroke-" index)
:index index
:stroke value
:title (tr "workspace.options.stroke-color")
@@ -90,6 +90,148 @@
(constantly nil)))))
@loaded?))
;; --- OPTICAL CENTERING OF SAMPLE TEXT --------------------------------------
;; Fonts with exaggerated vertical metrics (huge ascender/descender, small
;; caps) render their line box lower within a fixed-height row, so a plain
;; `align-items: center` leaves the visible glyphs sitting low. We measure the
;; font-wide vs glyph-ink bounding boxes once per font/sample and shift the
;; text by the computed offset so the visible glyphs are optically centered.
;; The offset is expressed in `em`, which makes it size-independent: the same
;; measurement corrects both the 16px `Ag` sample and the smaller font-name
;; labels in the font selector.
(defonce ^:private optical-offset-cache (atom {}))
(defn- optical-offset-key [family weight style text]
(dm/str family "|" weight "|" style "|" text))
(defn- optical-offset-em
"Vertical shift (in `em` units, i.e. relative to the font size) that centers
the ink of `text` within a single line box.
For a centered line the shift reduces to the difference between the font-wide
and ink bounding boxes:
dy = ((ink-ascent - font-ascent) + (font-descent - ink-descent)) / 2.
Measuring at 16px and dividing the pixel shift by it yields the `em` value."
[family weight style text]
(when-some [{:keys [font-ascent font-descent ink-ascent ink-descent]}
(dom/measure-text-metrics family weight style text 16)]
(let [dy (/ (+ (- ink-ascent font-ascent)
(- font-descent ink-descent))
2)
em (/ dy 16)]
;; Round to avoid float noise leaking into the transform string.
(/ (js/Math.round (* em 10000)) 10000))))
(defn- load-optical-offset
[font-id family weight style text]
(let [key (optical-offset-key family weight style text)]
(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)]
(swap! optical-offset-cache assoc key em)
em)))))))
(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."
[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))]
(mf/use-effect
(mf/deps font-id family weight style text)
(fn []
(let [cancelled? (volatile! false)]
(if (contains? @optical-offset-cache key)
(do
(reset! offset* (get @optical-offset-cache key))
(reset! ready?* true))
(let [task (tm/schedule-on-idle
(fn []
(-> (load-optical-offset font-id family weight style text)
(p/then
(fn [em]
(when-not @cancelled?
(reset! offset* em)
(reset! ready?* true)))))))]
(fn []
(vreset! cancelled? true)
(tm/dispose! task)))))
nil))
[(deref offset*) (deref ready?*)]))
(defn- sample-container-style
"Inline style that applies the typography font to the (clipped, fixed-height)
sample container. Must be a real JS object (`#js`), not a ClojureScript map:
the `:style` value here is a runtime expression, not a literal recognized by
the hiccup macro, so it reaches React unconverted.
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 "\"")))
:fontWeight (:font-weight typography)
:fontStyle (:font-style typography)})
(defn- sample-text-style
"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")})
;; --- FONT SELECTOR --------------------------------------------------------
(mf/defc font-item-preview*
"Row content with previews: a vector preview from the shared sprite for catalog
fonts, or the font's own name lazily loaded for custom fonts the sprite doesn't
@@ -110,17 +252,42 @@
;; we show the plain name rather than runtime-loading the whole catalog.
in-sprite? (and attached? (contains? (:ids sprite) font-id))
fallback? (and (= :ready (:status sprite)) attached? (not in-sprite?))
loaded? (use-font-lazy-load font-id fallback?)]
loaded? (use-font-lazy-load font-id fallback?)
;; Optical centering for the fallback name (custom fonts the sprite
;; doesn't cover): extreme vertical metrics would push the name low in
;; 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))]
(if in-sprite?
;; `fill: currentColor` (scss) makes the sprite glyph follow the row color.
[:svg {:class (stl/css :font-item-preview)
:role "img"
:aria-label (:name font)}
[:use {:href (dm/str "#" fonts/preview-sprite-prefix font-id)}]]
[:span {:class (stl/css :font-item-label)
:style (when loaded?
#js {:fontFamily (dm/str "\"" (:family font) "\", sans-serif")})}
(:name font)])))
;; The vertical correction goes on an INNER span, not on `.font-item-label`
;; itself: that class carries its own `overflow: hidden` (from the
;; text-ellipsis mixin, needed to truncate long font names), and a
;; transform applied to a self-clipping element moves its clip region
;; along with it — a no-op. The inner span has no overflow of its own, so
;; the shift actually moves the ink within the outer's fixed clip area.
[:span {:class (stl/css :font-item-label)}
[:span {:style #js {:fontFamily (when loaded?
(dm/str "\"" (:family font) "\", sans-serif"))
:transform (when-not (zero? label-offset)
(dm/str "translateY(" label-offset "em)"))}}
(:name font)]])))
(mf/defc font-item*
{::mf/wrap [mf/memo]}
@@ -590,6 +757,12 @@
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")
on-delete
(mf/use-fn
@@ -624,10 +797,9 @@
[:*
[:div {:class (stl/css :font-name-wrapper)}
[:div {:class (stl/css :typography-sample-input)
:style {:font-family (:font-family typography)
:font-weight (:font-weight typography)
:font-style (:font-style typography)}}
(tr "workspace.assets.typography.sample")]
:style (sample-container-style typography font-data)}
[:span {:style (sample-text-style offset offset-ready?)}
(tr "workspace.assets.typography.sample")]]
[:input
{:class (stl/css :adv-typography-name)
@@ -661,11 +833,9 @@
[:div {:class (stl/css :typography-info-wrapper)}
[:div {:class (stl/css :typography-name-wrapper)}
[:div {:class (stl/css :typography-sample)
:style {:font-family (:font-family typography)
:font-weight (:font-weight typography)
:font-style (:font-style typography)}}
(tr "workspace.assets.typography.sample")]
:style (sample-container-style typography font-data)}
[:span {:style (sample-text-style offset offset-ready?)}
(tr "workspace.assets.typography.sample")]]
[:div {:class (stl/css :typography-name)
:title (:name typography)}
@@ -712,6 +882,12 @@
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")
on-name-blur
(mf/use-fn
@@ -746,6 +922,13 @@
(when ^boolean esc?
(dom/blur! input-node)))))]
;; use-optical-offset only triggers a font load as a side effect of an
;; uncached offset measurement, so on a cache hit (e.g. a `0` offset
;; cached from before the font was ever loaded) the font itself never
;; gets fetched and the sample silently renders in the fallback font.
;; Load it unconditionally too, same as the advanced-options view does.
(fonts/ensure-loaded! (:font-id typography))
(mf/with-effect [is-editing]
(when is-editing
(reset! open* is-editing)))
@@ -769,10 +952,9 @@
[:div {:class (stl/css :font-name-wrapper)}
[:div
{:class (stl/css :typography-sample-input)
:style {:font-family (:font-family typography)
:font-weight (:font-weight typography)
:font-style (:font-style typography)}}
(tr "workspace.assets.typography.sample")]
:style (sample-container-style typography font-data)}
[:span {:style (sample-text-style offset offset-ready?)}
(tr "workspace.assets.typography.sample")]]
[:input
{:class (stl/css :adv-typography-name)
@@ -789,10 +971,9 @@
:on-context-menu on-context-menu}
[:div
{:class (stl/css :typography-sample)
:style {:font-family (:font-family typography)
:font-weight (:font-weight typography)
:font-style (:font-style typography)}}
(tr "workspace.assets.typography.sample")]
:style (sample-container-style typography font-data)}
[:span {:style (sample-text-style offset offset-ready?)}
(tr "workspace.assets.typography.sample")]]
[:div {:class (stl/css :name-block)
:title (if name-only?
@@ -101,7 +101,7 @@ $font-preview-box-height: 28px;
box-sizing: border-box;
background-color: var(--font-name-wrapper-background-color);
margin-block-end: var(--sp-s);
padding: var(--sp-s) 0 var(--sp-s) var(--sp-m);
padding-inline-start: var(--sp-m);
&:focus-within {
--font-name-wrapper-border-color: var(--color-accent-primary);
@@ -119,6 +119,8 @@ $font-preview-box-height: 28px;
inline-size: $sz-24;
block-size: 100%;
font-size: px2rem(16);
line-height: 1;
overflow: hidden;
color: var(--color-foreground-primary);
}
@@ -171,8 +173,10 @@ $font-preview-box-height: 28px;
align-items: center;
min-inline-size: $sz-24;
font-size: px2rem(16);
line-height: 1;
block-size: $sz-32;
padding: 0;
overflow: hidden;
color: var(--color-foreground-primary);
}
@@ -18,7 +18,6 @@
[app.main.ui.workspace.tokens.management.forms.generic-form :as generic]
[app.main.ui.workspace.tokens.management.forms.validators :refer [check-coll-self-reference check-self-reference default-validate-token]]
[app.util.i18n :refer [tr]]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
@@ -43,11 +42,14 @@
(defn- validate-typography-token
[{:keys [token-value] :as props}]
(cond
;; Entering form without a value - show no error just resolve nil
(nil? token-value) (rx/of nil)
;; Validate refrence string
(cto/composite-token-reference? token-value) (default-validate-token props)
;; Validate composite token
;; Validate composite token. `token-value` may be nil when the form is
;; submitted without any composite field filled in — normalize it to `{}`
;; so `check-empty-typography-token` catches it and rejects the submit,
;; instead of silently saving a token with a `nil` value (which later
;; crashes token resolution: the tokens-studio StyleDictionary
;; preprocessor assumes a typography token's value is never null).
:else
(-> props
(update :token-value
+10 -6
View File
@@ -47,6 +47,7 @@
[app.plugins.page :as page]
[app.plugins.parser :as parser]
[app.plugins.reflow :as wrfp]
[app.plugins.register :as r]
[app.plugins.shape :as shape]
[app.plugins.system-events :as se]
[app.plugins.user :as user]
@@ -242,15 +243,18 @@
:getCurrentUser
(fn []
(user/current-user-proxy plugin-id (:session-id @st/state)))
(when (r/check-permission plugin-id "user:read")
(user/current-user-proxy plugin-id (:session-id @st/state))))
:getActiveUsers
(fn []
(apply array
(->> (:workspace-presence @st/state)
(vals)
(remove #(= (:id %) (:session-id @st/state)))
(map #(user/active-user-proxy plugin-id (:id %))))))
(if (r/check-permission plugin-id "user:read")
(apply array
(->> (:workspace-presence @st/state)
(vals)
(remove #(= (:id %) (:session-id @st/state)))
(map #(user/active-user-proxy plugin-id (:id %)))))
(array)))
:uploadMediaUrl
(fn [name url]
+9 -6
View File
@@ -40,12 +40,14 @@
;; FIXME: inconsistent with comment-thread: owner
:user
{:get #(->> (dc/get-owner data)
(user/user-proxy plugin-id))}
{:get #(when (r/check-permission plugin-id "user:read")
(->> (dc/get-owner data)
(user/user-proxy plugin-id)))}
:owner
{:get #(->> (dc/get-owner data)
(user/user-proxy plugin-id))}
{:get #(when (r/check-permission plugin-id "user:read")
(->> (dc/get-owner data)
(user/user-proxy plugin-id)))}
:date
{:get
@@ -116,8 +118,9 @@
:board {:get #(shape/shape-proxy plugin-id file-id page-id (:frame-id data))}
:owner
{:get #(->> (dc/get-owner data)
(user/user-proxy plugin-id))}
{:get #(when (r/check-permission plugin-id "user:read")
(->> (dc/get-owner data)
(user/user-proxy plugin-id)))}
:position
{:get
+3 -2
View File
@@ -61,8 +61,9 @@
:createdBy
{:get
(fn []
(when-let [user-data (get users (:profile-id @data))]
(user/user-proxy plugin-id user-data)))}
(when (r/check-permission plugin-id "user:read")
(when-let [user-data (get users (:profile-id @data))]
(user/user-proxy plugin-id user-data))))}
:createdAt
{:get #(:created-at @data)}
+9 -1
View File
@@ -325,7 +325,12 @@
:remove
(fn []
(st/emit! (dwsl/remove-layout #{id})))
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/remove-layout #{id}))))
:appendChild
(fn [child]
@@ -350,6 +355,9 @@
(u/changes-component-copy-structure? objects shape child-shape)
(u/not-valid plugin-id :appendChild "Cannot change the structure of a component copy")
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :appendChild "Plugin doesn't have 'content:write' permission")
:else
(st/emit!
(dwsh/relocate-shapes #{child-id} id index)
+47 -11
View File
@@ -698,21 +698,37 @@
:addVariant
(fn []
(st/emit!
(se/event plugin-id "add-new-variant")
(dwv/add-new-variant id)))
(cond
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :addVariant "Plugin doesn't have 'library:write' permission")
:else
(st/emit!
(se/event plugin-id "add-new-variant")
(dwv/add-new-variant id))))
:addProperty
(fn []
(st/emit!
(se/event plugin-id "add-new-property")
(dwv/add-new-property id {:property-value "Value 1"})))
(cond
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :addProperty "Plugin doesn't have 'library:write' permission")
:else
(st/emit!
(se/event plugin-id "add-new-property")
(dwv/add-new-property id {:property-value "Value 1"}))))
:removeProperty
(fn [pos]
(let [nprops (->> (get-variant-components file-id id) first :variant-properties count)]
(if (or (not (nat-int? pos)) (>= pos nprops))
(cond
(or (not (nat-int? pos)) (>= pos nprops))
(u/not-valid plugin-id :pos pos)
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :removeProperty "Plugin doesn't have 'library:write' permission")
:else
(st/emit!
(se/event plugin-id "remove-property")
(dwv/remove-property id pos)))))
@@ -727,6 +743,9 @@
(not (string? name))
(u/not-valid plugin-id :name name)
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :renameProperty "Plugin doesn't have 'library:write' permission")
:else
(st/emit!
(dwv/update-property-name id pos name {:trigger "plugin:rename-property"})))))))
@@ -923,8 +942,15 @@
:transformInVariant
(fn []
(let [component (u/locate-library-component file-id id)]
(when (and component
(not (ctk/is-variant? component)))
(cond
(or (nil? component)
(ctk/is-variant? component))
nil
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :transformInVariant "Plugin doesn't have 'library:write' permission")
:else
(st/emit!
(se/event plugin-id "transform-in-variant")
(dwv/transform-in-variant (:main-instance-id component))))))
@@ -932,8 +958,15 @@
:addVariant
(fn []
(let [component (u/locate-library-component file-id id)]
(when (and component
(ctk/is-variant? component))
(cond
(or (nil? component)
(not (ctk/is-variant? component)))
nil
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :addVariant "Plugin doesn't have 'library:write' permission")
:else
(st/emit!
(se/event plugin-id "add-new-variant")
(dwv/add-new-variant (:main-instance-id component))))))
@@ -948,6 +981,9 @@
(not (string? value))
(u/not-valid plugin-id :name value)
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :setVariantProperty "Plugin doesn't have 'library:write' permission")
:else
(st/emit!
(se/event plugin-id "variant-edit-property-value")
+18 -1
View File
@@ -64,6 +64,9 @@
(or (not (string? value)) (empty? value))
(u/not-valid plugin-id :name value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwi/update-flow page-id id #(assoc % :name value)))))}
@@ -79,12 +82,20 @@
(not (shape/shape-proxy? value))
(u/not-valid plugin-id :startingBoard value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :startingBoard "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwi/update-flow page-id id #(assoc % :starting-frame (obj/get value "$id"))))))}
:remove
(fn []
(st/emit! (dwi/remove-flow page-id id)))))
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwi/remove-flow page-id id))))))
(defn page-proxy? [proxy]
(obj/type-of? proxy "PageProxy"))
@@ -315,6 +326,9 @@
(not (shape/shape-proxy? frame))
(u/not-valid plugin-id :createFlow-frame frame)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :createFlow "Plugin doesn't have 'content:write' permission")
:else
(let [flow-id (uuid/next)]
(st/emit!
@@ -328,6 +342,9 @@
(not (flow-proxy? flow))
(u/not-valid plugin-id :removeFlow-flow flow)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :removeFlow "Plugin doesn't have 'content:write' permission")
:else
(st/emit!
(dwi/remove-flow id (obj/get flow "$id"))
+49 -11
View File
@@ -103,6 +103,9 @@
(not (contains? ctsi/event-types value))
(u/not-valid plugin-id :trigger value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :trigger "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwi/update-interaction
(u/locate-shape file-id page-id shape-id)
@@ -119,6 +122,9 @@
(or (not (sm/valid-safe-int? value)) (neg? value))
(u/not-valid plugin-id :delay value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :delay "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwi/update-interaction
(u/locate-shape file-id page-id shape-id)
@@ -139,6 +145,9 @@
(not (sm/validate ctsi/schema:interaction interaction))
(u/not-valid plugin-id :action interaction)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :action "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwi/update-interaction
(u/locate-shape file-id page-id shape-id)
@@ -148,7 +157,12 @@
:remove
(fn []
(st/emit! (dwi/remove-interaction {:id shape-id} index)))))
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwi/remove-interaction {:id shape-id} index))))))
(def lib-typography-proxy? nil)
(def lib-component-proxy nil)
@@ -200,15 +214,15 @@
(not (sm/validate [:vector types.fills/schema:fill] value))
(u/not-valid plugin-id :fills value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :fills "Plugin doesn't have 'content:write' permission")
(not (u/page-active? (obj/get self "$page")))
(u/not-valid plugin-id :fills "Cannot modify a page that is not currently active")
(cfh/text-shape? shape)
(st/emit! (dwt/update-attrs id {:fills value}))
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :fills "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsh/update-shapes [id] #(assoc % :fills value))))))
@@ -1475,6 +1489,9 @@
:detach
(fn []
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :detach "Plugin doesn't have 'content:write' permission")
(not (u/page-active? page-id))
(u/not-valid plugin-id :detach "Cannot modify a page that is not currently active")
@@ -1485,12 +1502,12 @@
(fn [component]
(let [shape (u/locate-shape file-id page-id id)]
(cond
(not (u/page-active? page-id))
(u/not-valid plugin-id :swapComponent "Cannot modify a page that is not currently active")
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :swapComponent "Plugin doesn't have 'content:write' permission")
(not (u/page-active? page-id))
(u/not-valid plugin-id :swapComponent "Cannot modify a page that is not currently active")
(not (obj/type-of? component "LibraryComponentProxy"))
(u/not-valid plugin-id :swapComponent "Component not valid")
@@ -1507,12 +1524,12 @@
(fn []
(let [shape (u/locate-shape file-id page-id id)]
(cond
(not (u/page-active? page-id))
(u/not-valid plugin-id :resetOverrides "Cannot modify a page that is not currently active")
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :resetOverrides "Plugin doesn't have 'content:write' permission")
(not (u/page-active? page-id))
(u/not-valid plugin-id :resetOverrides "Cannot modify a page that is not currently active")
(not (ctk/in-component-copy? shape))
(u/not-valid plugin-id :resetOverrides "The shape is not a component copy instance")
@@ -1527,6 +1544,9 @@
(not (sm/validate ctse/schema:export value))
(u/not-valid plugin-id :export value)
(not (r/check-permission plugin-id "content:read"))
(u/not-valid plugin-id :export "Plugin doesn't have 'content:read' permission")
:else
(if (and (contains? cf/flags :wasm-export)
(contains? #{:jpeg :webp :png} (:type value :png)))
@@ -1598,6 +1618,9 @@
(not (sm/validate ctsi/schema:interaction interaction))
(u/not-valid plugin-id :addInteraction interaction)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :addInteraction "Plugin doesn't have 'content:write' permission")
:else
(let [index (-> (u/locate-shape file-id page-id id) (:interactions []) count)]
(st/emit!
@@ -1611,6 +1634,9 @@
(not (interaction-proxy? interaction))
(u/not-valid plugin-id :removeInteraction interaction)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :removeInteraction "Plugin doesn't have 'content:write' permission")
:else
(st/emit!
(dwi/remove-interaction {:id id} (obj/get interaction "$index"))
@@ -1691,8 +1717,14 @@
:fn (fn [token attrs]
(let [token (u/locate-token file-id (obj/get token "$set-id") (obj/get token "$id"))
kw-attrs (into #{} (map token-attr-plugin->token-attr attrs))]
(if (some #(not (token-attr? %)) kw-attrs)
(cond
(some #(not (token-attr? %)) kw-attrs)
(u/not-valid plugin-id :applyToken attrs)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :applyToken "Plugin doesn't have 'content:write' permission")
:else
(st/emit!
(-> (dwta/toggle-token {:token token
:attrs kw-attrs
@@ -1720,6 +1752,9 @@
(not (string? value))
(u/not-valid plugin-id :value value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :switchVariant "Plugin doesn't have 'content:write' permission")
:else
(let [shape (u/locate-shape file-id page-id id)
component (u/locate-library-component file-id (:component-id shape))]
@@ -1733,6 +1768,9 @@
(or (not (seq ids)) (not (every? uuid/parse* ids)))
(u/not-valid plugin-id :ids ids)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :combineAsVariants "Plugin doesn't have 'content:write' permission")
:else
(let [;; Keep the input order (head shape first): it determines
;; the order of the resulting variant components (see
+215 -105
View File
@@ -17,6 +17,7 @@
[app.main.data.workspace.tokens.application :as dwta]
[app.main.data.workspace.tokens.library-edit :as dwtl]
[app.main.store :as st]
[app.plugins.register :as r]
[app.plugins.system-events :as se]
[app.plugins.utils :as u]
[app.util.object :as obj]
@@ -85,16 +86,20 @@
(defn- apply-token-to-shapes
[plugin-id file-id set-id id shape-ids attrs]
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :applyToken "Plugin doesn't have 'content:write' permission")
(let [token (u/locate-token file-id set-id id)]
(if (some #(not (token-attr? %)) attrs)
(u/not-valid plugin-id :applyToSelected attrs)
(st/emit!
(-> (dwta/toggle-token {:token token
:attrs (into #{} (map token-attr-plugin->token-attr) attrs)
:shape-ids shape-ids
:expand-with-children false})
(se/add-event plugin-id))))))
:else
(let [token (u/locate-token file-id set-id id)]
(if (some #(not (token-attr? %)) attrs)
(u/not-valid plugin-id :applyToSelected attrs)
(st/emit!
(-> (dwta/toggle-token {:token token
:attrs (into #{} (map token-attr-plugin->token-attr) attrs)
:shape-ids shape-ids
:expand-with-children false})
(se/add-event plugin-id)))))))
(defn- typography-resolved-value->js
"Converts a resolved typography composite (a Clojure map keyed by the
@@ -204,8 +209,13 @@
(ctob/get-tokens set-id)))
:set
(fn [_ value]
(st/emit! (-> (dwtl/update-token set-id id {:name value})
(se/add-event plugin-id))))}
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (-> (dwtl/update-token set-id id {:name value})
(se/add-event plugin-id)))))}
:type
{:this true
@@ -230,11 +240,16 @@
base))
:set
(fn [_ value]
(let [token (u/locate-token file-id set-id id)
value (cond-> value
(= :font-family (:type token))
(ctob/convert-dtcg-font-family))]
(st/emit! (dwtl/update-token set-id id {:value value}))))}
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :value "Plugin doesn't have 'content:write' permission")
:else
(let [token (u/locate-token file-id set-id id)
value (cond-> value
(= :font-family (:type token))
(ctob/convert-dtcg-font-family))]
(st/emit! (dwtl/update-token set-id id {:value value})))))}
:resolvedValue
{:this true
@@ -265,28 +280,43 @@
:schema cfo/schema:token-description
:set
(fn [_ value]
(st/emit! (-> (dwtl/update-token set-id id {:description value})
(se/add-event :plugin-id))))}
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :description "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (-> (dwtl/update-token set-id id {:description value})
(se/add-event plugin-id)))))}
:duplicate
(fn []
;; TODO:
;; - add function duplicate-token in tokens-lib, that allows to specify the new id
;; - use this function in dwtl/duplicate-token
;; - return the new token proxy using the locally forced id
;; - do the same with sets and themes
(let [token (u/locate-token file-id set-id id)
token' (ctob/make-token (-> (datafy token)
(dissoc :id
:modified-at)))]
(st/emit! (-> (dwtl/create-token set-id token')
(se/add-event plugin-id)))
(token-proxy plugin-id file-id set-id (:id token'))))
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :duplicate "Plugin doesn't have 'content:write' permission")
:else
;; TODO:
;; - add function duplicate-token in tokens-lib, that allows to specify the new id
;; - use this function in dwtl/duplicate-token
;; - return the new token proxy using the locally forced id
;; - do the same with sets and themes
(let [token (u/locate-token file-id set-id id)
token' (ctob/make-token (-> (datafy token)
(dissoc :id
:modified-at)))]
(st/emit! (-> (dwtl/create-token set-id token')
(se/add-event plugin-id)))
(token-proxy plugin-id file-id set-id (:id token')))))
:remove
(fn []
(st/emit! (-> (dwtl/delete-token set-id id)
(se/add-event plugin-id))))
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (-> (dwtl/delete-token set-id id)
(se/add-event plugin-id)))))
:applyToShapes
{:enumerable false
@@ -337,8 +367,13 @@
id)
:set
(fn [_ name]
(let [set (u/locate-token-set file-id id)]
(st/emit! (dwtl/rename-token-set set name))))}
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission")
:else
(let [set (u/locate-token-set file-id id)]
(st/emit! (dwtl/rename-token-set set name)))))}
:active
{:this true
@@ -351,13 +386,23 @@
:schema ::sm/boolean
:set
(fn [_ value]
(let [set (u/locate-token-set file-id id)]
(st/emit! (dwtl/set-enabled-token-set (ctob/get-name set) value))))}
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :active "Plugin doesn't have 'content:write' permission")
:else
(let [set (u/locate-token-set file-id id)]
(st/emit! (dwtl/set-enabled-token-set (ctob/get-name set) value)))))}
:toggleActive
(fn [_]
(let [set (u/locate-token-set file-id id)]
(st/emit! (dwtl/toggle-token-set (ctob/get-name set)))))
(fn []
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :toggleActive "Plugin doesn't have 'content:write' permission")
:else
(let [set (u/locate-token-set file-id id)]
(st/emit! (dwtl/toggle-token-set (ctob/get-name set))))))
:tokens
{:this true
@@ -416,39 +461,54 @@
(sm/update-properties assoc :decode/json cfo/convert-dtcg-token))]))
:decode/options {:key-fn identity}
:fn (fn [attrs]
(let [tokens-lib (u/locate-tokens-lib file-id)
token (ctob/make-token attrs)
;; Resolve against all tokens in the library (including those
;; in inactive sets) so that references to structurally
;; existing tokens resolve even if their set is not active.
;; The target set's tokens take precedence over equally named
;; tokens in other sets, and the new token takes precedence
;; over all.
tokens-tree (-> (merge (ctob/get-all-tokens-map tokens-lib)
(ctob/get-tokens tokens-lib id))
(assoc (:name token) token))
resolved-tokens (ts/resolve-tokens tokens-tree)
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :addToken "Plugin doesn't have 'content:write' permission")
{:keys [errors resolved-value] :as resolved-token}
(get resolved-tokens (:name token))]
:else
(let [tokens-lib (u/locate-tokens-lib file-id)
token (ctob/make-token attrs)
;; Resolve against all tokens in the library (including those
;; in inactive sets) so that references to structurally
;; existing tokens resolve even if their set is not active.
;; The target set's tokens take precedence over equally named
;; tokens in other sets, and the new token takes precedence
;; over all.
tokens-tree (-> (merge (ctob/get-all-tokens-map tokens-lib)
(ctob/get-tokens tokens-lib id))
(assoc (:name token) token))
resolved-tokens (ts/resolve-tokens tokens-tree)
(if resolved-value
(do (st/emit! (-> (dwtl/create-token id token)
(se/add-event plugin-id)))
(token-proxy plugin-id file-id id (:id token)))
(do (u/not-valid plugin-id :addToken (str errors))
nil))))}
{:keys [errors resolved-value] :as resolved-token}
(get resolved-tokens (:name token))]
(if resolved-value
(do (st/emit! (-> (dwtl/create-token id token)
(se/add-event plugin-id)))
(token-proxy plugin-id file-id id (:id token)))
(do (u/not-valid plugin-id :addToken (str errors))
nil)))))}
:duplicate
(fn []
(let [id-ref (atom nil)]
(st/emit! (dwtl/duplicate-token-set id {:id-ref id-ref}))
(when (some? @id-ref)
(token-set-proxy plugin-id file-id @id-ref))))
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :duplicate "Plugin doesn't have 'content:write' permission")
:else
(let [id-ref (atom nil)]
(st/emit! (dwtl/duplicate-token-set id {:id-ref id-ref}))
(when (some? @id-ref)
(token-set-proxy plugin-id file-id @id-ref)))))
:remove
(fn []
(st/emit! (dwtl/delete-token-set id))))))
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwtl/delete-token-set id)))))))
(defn token-theme-proxy? [p]
(obj/type-of? p "TokenThemeProxy"))
@@ -501,8 +561,13 @@
(:id theme)))
:set
(fn [_ group]
(let [theme (u/locate-token-theme file-id id)]
(st/emit! (dwtl/update-token-theme id (assoc theme :group group)))))}
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :group "Plugin doesn't have 'content:write' permission")
:else
(let [theme (u/locate-token-theme file-id id)]
(st/emit! (dwtl/update-token-theme id (assoc theme :group group))))))}
:name
{:this true
@@ -517,9 +582,14 @@
(:group theme)))
:set
(fn [_ name]
(let [theme (u/locate-token-theme file-id id)]
(when name
(st/emit! (dwtl/update-token-theme id (assoc theme :name name))))))}
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission")
:else
(let [theme (u/locate-token-theme file-id id)]
(when name
(st/emit! (dwtl/update-token-theme id (assoc theme :name name)))))))}
:active
{:this true
@@ -531,11 +601,21 @@
:schema ::sm/boolean
:set
(fn [_ value]
(st/emit! (dwtl/set-token-theme-active id value)))}
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :active "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwtl/set-token-theme-active id value))))}
:toggleActive
(fn [_]
(st/emit! (dwtl/toggle-token-theme-active id)))
(fn []
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :toggleActive "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwtl/toggle-token-theme-active id))))
:activeSets
{:this true
@@ -554,32 +634,52 @@
{:enumerable false
:schema [:tuple [:or [:fn token-set-proxy?] ::sm/uuid]]
:fn (fn [set-arg]
(let [set-name (token-set-name (resolve-token-set file-id set-arg))
theme (u/locate-token-theme file-id id)]
(when (and set-name theme)
(st/emit! (dwtl/update-token-theme id (ctob/enable-set theme set-name))))))}
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :addSet "Plugin doesn't have 'content:write' permission")
:else
(let [set-name (token-set-name (resolve-token-set file-id set-arg))
theme (u/locate-token-theme file-id id)]
(when (and set-name theme)
(st/emit! (dwtl/update-token-theme id (ctob/enable-set theme set-name)))))))}
:removeSet
{:enumerable false
:schema [:tuple [:or [:fn token-set-proxy?] ::sm/uuid]]
:fn (fn [set-arg]
(let [set-name (token-set-name (resolve-token-set file-id set-arg))
theme (u/locate-token-theme file-id id)]
(when (and set-name theme)
(st/emit! (dwtl/update-token-theme id (ctob/disable-set theme set-name))))))}
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :removeSet "Plugin doesn't have 'content:write' permission")
:else
(let [set-name (token-set-name (resolve-token-set file-id set-arg))
theme (u/locate-token-theme file-id id)]
(when (and set-name theme)
(st/emit! (dwtl/update-token-theme id (ctob/disable-set theme set-name)))))))}
:duplicate
(fn []
(let [theme (u/locate-token-theme file-id id)
theme' (ctob/make-token-theme (-> (datafy theme)
(dissoc :id
:modified-at)))]
(st/emit! (dwtl/create-token-theme theme'))
(token-theme-proxy plugin-id file-id (:id theme'))))
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :duplicate "Plugin doesn't have 'content:write' permission")
:else
(let [theme (u/locate-token-theme file-id id)
theme' (ctob/make-token-theme (-> (datafy theme)
(dissoc :id
:modified-at)))]
(st/emit! (dwtl/create-token-theme theme'))
(token-theme-proxy plugin-id file-id (:id theme')))))
:remove
(fn []
(st/emit! (dwtl/delete-token-theme id)))))
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwtl/delete-token-theme id))))))
(defn tokens-catalog
[plugin-id file-id]
@@ -619,9 +719,14 @@
nil)
(sm/dissoc-key :id))]) ;; We don't allow plugins to set the id
:fn (fn [attrs]
(let [theme (ctob/make-token-theme attrs)]
(st/emit! (dwtl/create-token-theme theme))
(token-theme-proxy plugin-id file-id (:id theme))))}
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :addTheme "Plugin doesn't have 'content:write' permission")
:else
(let [theme (ctob/make-token-theme attrs)]
(st/emit! (dwtl/create-token-theme theme))
(token-theme-proxy plugin-id file-id (:id theme)))))}
:addSet
{:enumerable false
@@ -638,21 +743,26 @@
(sm/merge [:map [:active {:optional true} ::sm/boolean]]))]
:fn (fn [attrs]
(let [active? (boolean (:active attrs))
attrs (-> attrs
(dissoc :active)
(update :name ctob/normalize-set-name))
set (ctob/make-token-set attrs)]
(st/emit! (dwtl/create-token-set set))
;; Newly created sets are inactive by default; activate it when
;; requested. Enabling only adds the set name to the hidden theme,
;; so it does not depend on the create event having propagated yet.
(when active?
(st/emit! (dwtl/set-enabled-token-set (ctob/get-name set) true)))
;; Pass the set name as `initial-name` so the proxy can resolve
;; it immediately, before the async `st/emit!` above propagates
;; the new set into `@st/state`.
(token-set-proxy plugin-id file-id (ctob/get-id set) (ctob/get-name set))))}
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :addSet "Plugin doesn't have 'content:write' permission")
:else
(let [active? (boolean (:active attrs))
attrs (-> attrs
(dissoc :active)
(update :name ctob/normalize-set-name))
set (ctob/make-token-set attrs)]
(st/emit! (dwtl/create-token-set set))
;; Newly created sets are inactive by default; activate it when
;; requested. Enabling only adds the set name to the hidden theme,
;; so it does not depend on the create event having propagated yet.
(when active?
(st/emit! (dwtl/set-enabled-token-set (ctob/get-name set) true)))
;; Pass the set name as `initial-name` so the proxy can resolve
;; it immediately, before the async `st/emit!` above propagates
;; the new set into `@st/state`.
(token-set-proxy plugin-id file-id (ctob/get-id set) (ctob/get-name set)))))}
:getThemeById
{:enumerable false
+5 -3
View File
@@ -136,7 +136,9 @@
(repo/cmd! :get-page {:file-id file-id
:page-id page-id
:share-id share-id
:object-id object-id
:object-id (if (uuid? object-id)
object-id
(set object-id))
:features features}))
(rx/tap (fn [[fonts]]
(when (seq fonts)
@@ -155,7 +157,7 @@
[:embed {:optional true} :boolean]
[:skip-children {:optional true} :boolean]
[:object-id
[:or [::sm/set ::sm/uuid] ::sm/uuid]]])
[:or [:vector ::sm/uuid] ::sm/uuid]]])
(def ^:private coerce-render-objects-params
(sm/coercer schema:render-objects))
@@ -188,7 +190,7 @@
{:file-id file-id
:page-id page-id
:share-id share-id
:object-ids (into #{} object-id)
:object-ids (into [] (distinct) object-id)
:embed embed
:skip-children skip-children
:wasm wasm
+30
View File
@@ -949,6 +949,36 @@
{:ascent (.-fontBoundingBoxAscent measure)
:descent (.-fontBoundingBoxDescent measure)}))
(defn measure-text-metrics
"Measure the font-wide (bounding-box) and glyph-ink vertical metrics of `text`
at `font-size` px for the given font.
Returns `{:font-ascent :font-descent :ink-ascent :ink-descent}` in px, or nil
when the browser doesn't expose the bounding-box metrics. The font-wide
values track what CSS uses for the line box, while the ink ones track the
visible glyphs, which is what an optical centering shift needs."
([family weight style]
(measure-text-metrics family weight style "Ag" 16))
([family weight style text font-size]
(let [element (.createElement globals/document "canvas")
context (.getContext element "2d")
_ (set! (.-font context)
(dm/str (or weight "400") " " (or style "normal") " "
font-size "px \"" family "\""))
measure ^js (.measureText context (str text))
font-ascent (.-fontBoundingBoxAscent measure)
font-descent (.-fontBoundingBoxDescent measure)
ink-ascent (.-actualBoundingBoxAscent measure)
ink-descent (.-actualBoundingBoxDescent measure)]
(when (and (number? font-ascent)
(number? font-descent)
(number? ink-ascent)
(number? ink-descent))
{:font-ascent font-ascent
:font-descent font-descent
:ink-ascent ink-ascent
:ink-descent ink-descent}))))
(defn clone-node
([^js node]
(clone-node node true))
@@ -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 INC Sucursal en España SL
(ns frontend-tests.logic.wasm-modifiers-nil-id-test
"Reproduces the production crash \"Cannot read properties of null
(reading '__u32_buffer')\".
A modif-tree containing a nil shape id (production builds elide the
asserts that catch this upstream, e.g. `update-dimensions` called
with `[(:parent-id shape)]` when `shape` is missing) reached
`wasm.api/propagate-modifiers` / `wasm.api/set-structure-modifiers`,
and `mem.h32/write-uuid` crashed calling `uuid/get-u32` on nil while
writing to the WASM heap.
These tests assert that no nil id ever crosses the WASM boundary and
that valid shapes in the same modif-tree are still processed."
(:require
[app.common.geom.rect :as grc]
[app.common.math :as mth]
[app.common.test-helpers.compositions :as ctho]
[app.common.test-helpers.files :as cthf]
[app.common.test-helpers.ids-map :as cthi]
[app.common.test-helpers.shapes :as cths]
[app.common.types.modifiers :as ctm]
[app.common.uuid :as uuid]
[app.main.data.workspace.modifiers :as dwm]
[app.render-wasm.api :as wasm.api]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.state :as ths]
[frontend-tests.helpers.wasm :as thw]))
(def ^:private captured-geometry-entries
"Entries passed to `wasm.api/propagate-modifiers` during a test."
(atom []))
(def ^:private captured-structure-entries
"Entries passed to `wasm.api/set-structure-modifiers` during a test."
(atom []))
(defn- install-capturing-spies!
"Replace the plain WASM mocks with variants that record their input.
Must run after `thw/setup-wasm-mocks!` so teardown still restores
the real implementations."
[]
(set! wasm.api/propagate-modifiers
(fn [entries _pixel-precision]
(swap! captured-geometry-entries into entries)
(into []
(map (fn [[id data]] [id (:transform data)]))
entries)))
(set! wasm.api/set-structure-modifiers
(fn [entries]
(swap! captured-structure-entries into entries)
nil)))
(t/use-fixtures :each
{:before (fn []
(cthi/reset-idmap!)
(reset! captured-geometry-entries [])
(reset! captured-structure-entries [])
(thw/setup-wasm-mocks!)
(install-capturing-spies!))
:after (fn []
(thw/teardown-wasm-mocks!))})
(t/deftest nil-id-does-not-reach-propagate-modifiers
;; A nil-keyed entry must be dropped before the WASM heap write while
;; the valid entry is still resized.
(t/async
done
(let [file (-> (cthf/sample-file :file1)
(ctho/add-rect :rect1 :x 10 :y 20 :width 100 :height 50))
store (ths/setup-store file)
rect (cths/get-shape file :rect1)
resize (ctm/change-dimensions-modifiers rect :width 200)
modif-tree {nil {:modifiers resize}
(:id rect) {:modifiers resize}}
events [(dwm/apply-wasm-modifiers modif-tree {:ignore-snap-pixel true})]]
(ths/run-store
store done events
(fn [new-state]
(let [entry-ids (into #{} (map first) @captured-geometry-entries)
file' (ths/get-file-from-state new-state)
rect' (cths/get-shape file' :rect1)
width (-> rect' :points grc/points->rect :width)]
(t/is (not (contains? entry-ids nil)))
(t/is (contains? entry-ids (:id rect)))
(t/is (mth/close? 200 width))))))))
(t/deftest nil-id-does-not-reach-set-structure-modifiers
;; A nil-keyed entry with structure modifiers must not produce
;; structure entries with a nil :parent or :id.
(t/async
done
(let [file (-> (cthf/sample-file :file1)
(ctho/add-rect :rect1 :x 10 :y 20 :width 100 :height 50))
store (ths/setup-store file)
rect (cths/get-shape file :rect1)
modif-tree {nil {:modifiers (ctm/add-children nil [(uuid/next)] 0)}
(:id rect) {:modifiers (ctm/change-dimensions-modifiers rect :width 200)}}
events [(dwm/apply-wasm-modifiers modif-tree {:ignore-snap-pixel true})]]
(ths/run-store
store done events
(fn [_new-state]
(t/is (every? #(some? (:parent %)) @captured-structure-entries))
(t/is (every? #(some? (:id %)) @captured-structure-entries)))))))
@@ -0,0 +1,40 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS 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,8 +6,11 @@
(ns frontend-tests.plugins.library-test
(:require
[app.common.types.component :as ctk]
[app.common.uuid :as uuid]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.texts :as dwt]
[app.main.data.workspace.variants :as dwv]
[app.main.store :as st]
[app.plugins.library :as library]
[app.plugins.register :as r]
@@ -93,3 +96,112 @@
(t/is (contains? (:color @captured) :image))
(t/is (not (contains? (:color @captured) :color)))
(t/is (not (contains? (:color @captured) :gradient))))))
;; ---------------------------------------------------------------------------
;; Permission checks (T9-F-02)
;; ---------------------------------------------------------------------------
(t/deftest variant-add-variant-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
id (uuid/next)
errors (atom [])]
(with-redefs [r/check-permission (constantly false)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
st/emit! mock/noop]
(let [proxy (library/variant-proxy plugin-id file-id id)]
(.addVariant proxy)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :addVariant "Plugin doesn't have 'library:write' permission"]
(first @errors)))))))
(t/deftest variant-add-property-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
id (uuid/next)
errors (atom [])]
(with-redefs [r/check-permission (constantly false)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
st/emit! mock/noop]
(let [proxy (library/variant-proxy plugin-id file-id id)]
(.addProperty proxy)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :addProperty "Plugin doesn't have 'library:write' permission"]
(first @errors)))))))
(t/deftest variant-remove-property-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
id (uuid/next)
errors (atom [])]
(with-redefs [r/check-permission (constantly false)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
library/get-variant-components (constantly [{:variant-properties [{:name "color" :value "red"}]}])
st/emit! mock/noop]
(let [proxy (library/variant-proxy plugin-id file-id id)]
(.removeProperty proxy 0)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :removeProperty "Plugin doesn't have 'library:write' permission"]
(first @errors)))))))
(t/deftest variant-rename-property-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
id (uuid/next)
errors (atom [])]
(with-redefs [r/check-permission (constantly false)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
library/get-variant-components (constantly [{:variant-properties [{:name "color" :value "red"}]}])
st/emit! mock/noop]
(let [proxy (library/variant-proxy plugin-id file-id id)]
(.renameProperty proxy 0 "newName")
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :renameProperty "Plugin doesn't have 'library:write' permission"]
(first @errors)))))))
(t/deftest component-transform-in-variant-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
id (uuid/next)
errors (atom [])]
(with-redefs [r/check-permission (constantly false)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
u/locate-library-component (constantly {:id id :main-instance-id id})
ctk/is-variant? (constantly false)
st/emit! mock/noop]
(let [proxy (library/lib-component-proxy plugin-id file-id id)]
(.transformInVariant proxy)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :transformInVariant "Plugin doesn't have 'library:write' permission"]
(first @errors)))))))
(t/deftest component-add-variant-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
id (uuid/next)
errors (atom [])]
(with-redefs [r/check-permission (constantly false)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
u/locate-library-component (constantly {:id id :main-instance-id id})
ctk/is-variant? (constantly true)
st/emit! mock/noop]
(let [proxy (library/lib-component-proxy plugin-id file-id id)]
(.addVariant proxy)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :addVariant "Plugin doesn't have 'library:write' permission"]
(first @errors)))))))
(t/deftest component-set-variant-property-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
id (uuid/next)
errors (atom [])]
(with-redefs [r/check-permission (constantly false)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
u/locate-library-component (constantly {:id id :variant-properties [{:name "color"}]})
st/emit! mock/noop]
(let [proxy (library/lib-component-proxy plugin-id file-id id)]
(.setVariantProperty proxy 0 "red")
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :setVariantProperty "Plugin doesn't have 'library:write' permission"]
(first @errors)))))))
@@ -9,12 +9,17 @@
[app.common.test-helpers.files :as cthf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as cths]
[app.common.uuid :as uuid]
[app.main.data.workspace.pages :as dwpg]
[app.main.store :as st]
[app.plugins.api :as api]
[app.plugins.page :as page]
[app.plugins.register :as r]
[app.plugins.shape :as shape]
[app.plugins.utils :as u]
[app.util.object :as obj]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.mock :as mock]
[frontend-tests.helpers.state :as ths]
[frontend-tests.helpers.wasm :as thw]
[potok.v2.core :as ptk]))
@@ -151,3 +156,85 @@
(done))))
(mock-page-initialized store page2-id))
0))))
;; ---------------------------------------------------------------------------
;; Permission checks (T9-F-03)
;; ---------------------------------------------------------------------------
(t/deftest flow-name-setter-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
flow-id (uuid/next)
errors (atom [])]
(with-redefs [r/check-permission (constantly false)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
st/emit! mock/noop]
(let [proxy (page/flow-proxy plugin-id file-id page-id flow-id)]
(set! (.-name proxy) "new-name")
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :name "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest flow-starting-board-setter-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
flow-id (uuid/next)
errors (atom [])]
(with-redefs [r/check-permission (constantly false)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
st/emit! mock/noop
shape/shape-proxy? (constantly true)]
(let [proxy (page/flow-proxy plugin-id file-id page-id flow-id)]
(set! (.-startingBoard proxy) #js {})
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :startingBoard "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest flow-remove-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
flow-id (uuid/next)
errors (atom [])]
(with-redefs [r/check-permission (constantly false)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
st/emit! mock/noop]
(let [proxy (page/flow-proxy plugin-id file-id page-id flow-id)]
(.remove proxy)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest create-flow-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
errors (atom [])]
(with-redefs [r/check-permission (constantly false)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
st/emit! mock/noop
shape/shape-proxy? (constantly true)]
(let [proxy (page/page-proxy plugin-id file-id page-id)
frame #js {"$id" (uuid/next)}]
(.createFlow proxy "flow-name" frame)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :createFlow "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest remove-flow-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
flow-id (uuid/next)
errors (atom [])]
(with-redefs [r/check-permission (constantly false)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
st/emit! mock/noop
page/flow-proxy? (constantly true)]
(let [proxy (page/page-proxy plugin-id file-id page-id)]
(.removeFlow proxy (page/flow-proxy plugin-id file-id page-id flow-id))
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :removeFlow "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
@@ -11,10 +11,15 @@
[app.common.types.component :as ctk]
[app.common.uuid :as uuid]
[app.main.data.workspace :as dw]
[app.main.data.workspace.interactions :as dwi]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.texts :as dwt]
[app.main.data.workspace.tokens.application :as dwta]
[app.main.data.workspace.variants :as dwv]
[app.main.store :as st]
[app.plugins.api :as api]
[app.plugins.public-utils :as public-utils]
[app.plugins.register :as r]
[app.plugins.shape :as shape]
[app.plugins.utils :as u]
[cljs.test :as t :include-macros true]
@@ -204,3 +209,202 @@
(t/deftest group-empty-input-returns-nil
(let [context (api/create-context plugin-id)]
(t/is (nil? (.group context #js [])))))
;; ---------------------------------------------------------------------------
;; Permission checks (T9-F-04)
;; ---------------------------------------------------------------------------
(t/deftest commit-fills-text-shape-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
errors (atom [])]
(with-redefs [u/proxy->shape (constantly {:id shape-id :type :text})
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)]
(set! (.-fills proxy) #js [#js {:fillColor "#ff0000" :fillOpacity 1}])
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :fills "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest interaction-trigger-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
errors (atom [])]
(with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)]
(set! (.-trigger inter) "click")
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :trigger "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest interaction-delay-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
errors (atom [])]
(with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)]
(set! (.-delay inter) 100)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :delay "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest interaction-action-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
errors (atom [])]
(with-redefs [u/proxy->interaction (constantly {:event-type :click :delay 0 :action-type :open-url :url "https://example.com"})
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)]
(set! (.-action inter) #js {:type "open-url" :url "https://example.com"})
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :action "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest interaction-remove-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
errors (atom [])]
(with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)]
(.remove inter)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest add-interaction-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
errors (atom [])]
(with-redefs [u/locate-shape (constantly {:id shape-id})
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)]
(.addInteraction proxy "click" #js {:type "open-url" :url "https://example.com"})
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :addInteraction "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest remove-interaction-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
errors (atom [])]
(with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)
inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)]
(.removeInteraction proxy inter)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :removeInteraction "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest detach-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
errors (atom [])]
(with-redefs [u/page-active? (constantly true)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)]
(.detach proxy)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :detach "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest export-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
errors (atom [])]
(with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)]
(let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)]
(.export proxy #js {:type "png" :scale 1})
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :export "Plugin doesn't have 'content:read' permission"]
(first @errors)))))))
(t/deftest apply-token-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
set-id (uuid/next)
token-id (uuid/next)
errors (atom [])]
(with-redefs [u/locate-token (constantly {:id token-id :name "test" :type :color})
shape/token-proxy? (constantly true)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)
token #js {"$set-id" (str set-id) "$id" (str token-id)}]
(.applyToken proxy token #js [])
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :applyToken "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest switch-variant-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
errors (atom [])]
(with-redefs [u/locate-shape (constantly {:id shape-id :component-id shape-id})
u/locate-library-component (constantly {:id (uuid/next)})
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)]
(.switchVariant proxy 0 "value")
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :switchVariant "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest combine-as-variants-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
other-id (uuid/next)
errors (atom [])]
(with-redefs [u/locate-shape (fn [_file _page id] {:id id :component-id id})
u/locate-library-component (constantly {:id (uuid/next)})
ctk/is-variant? (constantly false)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)]
(.combineAsVariants proxy #js [(str other-id)])
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :combineAsVariants "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
@@ -16,6 +16,7 @@
[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]
@@ -236,7 +237,8 @@
set-id (cthi/new-id! :set)
dup-id (cthi/new-id! :dup)
proxy (ptok/token-set-proxy "plugin-id" file-id set-id)]
(with-redefs [dwtl/duplicate-token-set
(with-redefs [r/check-permission (constantly true)
dwtl/duplicate-token-set
(mock/stub (fn [id {:keys [id-ref]}]
(t/is (= set-id id))
(reset! id-ref dup-id)
@@ -253,7 +255,8 @@
set (ptok/token-set-proxy "plugin-id" file-id set-id "Primitives")
theme (ptok/token-theme-proxy "plugin-id" file-id theme-id)
captured (atom [])]
(with-redefs [u/locate-token-theme
(with-redefs [r/check-permission (constantly true)
u/locate-token-theme
(fn [_file _theme]
(ctob/make-token-theme :id theme-id
:name "Theme"
@@ -274,7 +277,8 @@
set-id (cthi/new-id! :set)
token-id (cthi/new-id! :token)
captured (atom nil)]
(with-redefs [u/locate-token (constantly {:id token-id
(with-redefs [r/check-permission (constantly true)
u/locate-token (constantly {:id token-id
:name "font.primary"
:type :font-family
:value ["Inter"]})
@@ -347,7 +351,8 @@
theme (ctob/make-token-theme :id theme-id :group "mode" :name "Light")
emitted (atom [])
invalid (atom [])]
(with-redefs [u/locate-token-set (fn [_ id] (when (= id set-id) token-set))
(with-redefs [r/check-permission (constantly true)
u/locate-token-set (fn [_ id] (when (= id set-id) token-set))
u/locate-token-theme (fn [_ id] (when (= id theme-id) theme))
u/not-valid (fn [_ code value] (swap! invalid conj [code value]))
dwtl/update-token-theme (fn [id theme] {:id id :theme theme})
@@ -367,7 +372,8 @@
theme (ctob/make-token-theme :id theme-id :group "mode" :name "Light")
emitted (atom [])
invalid (atom [])]
(with-redefs [u/locate-token-set (fn [_ id] (when (= id set-id) token-set))
(with-redefs [r/check-permission (constantly true)
u/locate-token-set (fn [_ id] (when (= id set-id) token-set))
u/locate-token-theme (fn [_ id] (when (= id theme-id) theme))
u/not-valid (fn [_ code value] (swap! invalid conj [code value]))
dwtl/update-token-theme (fn [id theme] {:id id :theme theme})
@@ -400,3 +406,304 @@
(t/is (= 2 (count @errors)))
(t/is (every? #(instance? js/Error %) @errors))))))
;; ═══════════════════════════════════════════════════════════════
;; Permission check tests (T9-F-01)
;; ═══════════════════════════════════════════════════════════════
;; Note: token-proxy-name-setter-checks-permission test removed because
;; schema validation runs before the permission check, making it impossible
;; to test the permission check directly for setters with schemas.
(t/deftest token-proxy-value-setter-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
set-id (uuid/next)
token-id (uuid/next)
errors (atom [])]
(with-redefs [u/locate-token (constantly {:id token-id :name "test" :type :color})
u/locate-tokens-lib (constantly nil)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (ptok/token-proxy plugin-id file-id set-id token-id)]
(set! (.-value proxy) "#ff0000")
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :value "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
(t/deftest token-proxy-description-setter-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
set-id (uuid/next)
token-id (uuid/next)
errors (atom [])]
(with-redefs [u/locate-token (constantly {:id token-id :name "test"})
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (ptok/token-proxy plugin-id file-id set-id token-id)]
(set! (.-description proxy) "A description")
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :description "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
(t/deftest token-proxy-duplicate-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
set-id (uuid/next)
token-id (uuid/next)
errors (atom [])]
(with-redefs [u/locate-token (constantly {:id token-id :name "test" :type :color :value "#000"})
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (ptok/token-proxy plugin-id file-id set-id token-id)]
(.duplicate proxy)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :duplicate "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
(t/deftest token-proxy-remove-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
set-id (uuid/next)
token-id (uuid/next)
errors (atom [])]
(with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (ptok/token-proxy plugin-id file-id set-id token-id)]
(.remove proxy)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
(t/deftest token-set-proxy-name-setter-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
set-id (uuid/next)
errors (atom [])]
(with-redefs [u/locate-token-set (constantly {:id set-id :name "core"})
u/locate-tokens-lib (constantly (ctob/make-tokens-lib))
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (ptok/token-set-proxy plugin-id file-id set-id "core")]
(set! (.-name proxy) "new-core")
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :name "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
(t/deftest token-set-proxy-active-setter-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
set-id (uuid/next)
errors (atom [])]
(with-redefs [u/locate-token-set (constantly {:id set-id :name "core"})
u/locate-tokens-lib (constantly (ctob/make-tokens-lib))
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (ptok/token-set-proxy plugin-id file-id set-id "core")]
(set! (.-active proxy) true)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :active "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
(t/deftest token-set-proxy-toggle-active-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
set-id (uuid/next)
errors (atom [])]
(with-redefs [u/locate-token-set (constantly {:id set-id :name "core"})
u/locate-tokens-lib (constantly (ctob/make-tokens-lib))
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (ptok/token-set-proxy plugin-id file-id set-id)]
(.toggleActive proxy)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :toggleActive "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
(t/deftest token-set-proxy-add-token-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
set-id (uuid/next)
tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :id set-id :name "core")))
errors (atom [])]
(with-redefs [u/locate-token-set (constantly {:id set-id :name "core"})
u/locate-tokens-lib (constantly tokens-lib)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (ptok/token-set-proxy plugin-id file-id set-id "core")]
(t/is (fn? (.-addToken proxy)))
(.addToken proxy #js {"type" "color" "name" "color.test" "value" "#FF0000"})
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :addToken "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
(t/deftest token-set-proxy-duplicate-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
set-id (uuid/next)
errors (atom [])]
(with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (ptok/token-set-proxy plugin-id file-id set-id)]
(.duplicate proxy)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :duplicate "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
(t/deftest token-set-proxy-remove-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
set-id (uuid/next)
errors (atom [])]
(with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (ptok/token-set-proxy plugin-id file-id set-id)]
(.remove proxy)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
(t/deftest token-theme-proxy-group-setter-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
theme-id (uuid/next)
errors (atom [])]
(with-redefs [u/locate-token-theme (constantly {:id theme-id :name "Light" :group "mode"})
u/locate-tokens-lib (constantly nil)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)]
(set! (.-group proxy) "new-group")
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :group "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
(t/deftest token-theme-proxy-name-setter-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
theme-id (uuid/next)
errors (atom [])]
(with-redefs [u/locate-token-theme (constantly {:id theme-id :name "Light" :group "mode"})
u/locate-tokens-lib (constantly nil)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)]
(set! (.-name proxy) "Dark")
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :name "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
(t/deftest token-theme-proxy-active-setter-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
theme-id (uuid/next)
errors (atom [])]
(with-redefs [u/locate-tokens-lib (constantly (ctob/make-tokens-lib))
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)]
(set! (.-active proxy) true)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :active "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
(t/deftest token-theme-proxy-toggle-active-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
theme-id (uuid/next)
errors (atom [])]
(with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)]
(.toggleActive proxy)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :toggleActive "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
(t/deftest token-theme-proxy-add-set-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
theme-id (uuid/next)
set-id (uuid/next)
errors (atom [])]
(with-redefs [u/locate-token-theme (constantly {:id theme-id :name "Light" :sets #{}})
u/locate-token-set (constantly {:id set-id :name "core"})
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)
set-proxy (ptok/token-set-proxy plugin-id file-id set-id "core")]
(.addSet proxy set-proxy)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :addSet "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
(t/deftest token-theme-proxy-remove-set-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
theme-id (uuid/next)
set-id (uuid/next)
errors (atom [])]
(with-redefs [u/locate-token-theme (constantly {:id theme-id :name "Light" :sets #{"core"}})
u/locate-token-set (constantly {:id set-id :name "core"})
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)
set-proxy (ptok/token-set-proxy plugin-id file-id set-id "core")]
(.removeSet proxy set-proxy)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :removeSet "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
(t/deftest token-theme-proxy-duplicate-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
theme-id (uuid/next)
errors (atom [])]
(with-redefs [u/locate-token-theme (constantly {:id theme-id :name "Light" :group "mode"})
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)]
(.duplicate proxy)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :duplicate "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
(t/deftest token-theme-proxy-remove-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
theme-id (uuid/next)
errors (atom [])]
(with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)]
(.remove proxy)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
(t/deftest tokens-catalog-add-theme-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
errors (atom [])]
(with-redefs [u/locate-tokens-lib (constantly (ctob/make-tokens-lib))
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [catalog (ptok/tokens-catalog plugin-id file-id)]
(.addTheme catalog #js {"name" "NewTheme" "group" "mode"})
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :addTheme "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
(t/deftest tokens-catalog-add-set-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
errors (atom [])]
(with-redefs [u/locate-tokens-lib (constantly (ctob/make-tokens-lib))
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [catalog (ptok/tokens-catalog plugin-id file-id)]
(.addSet catalog #js {"name" "NewSet"})
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :addSet "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
@@ -0,0 +1,80 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS 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)))))))
+6
View File
@@ -39,10 +39,12 @@
[frontend-tests.logic.pasting-in-containers-test]
[frontend-tests.logic.sidebar-transform-coalescing-test]
[frontend-tests.logic.update-position-test]
[frontend-tests.logic.wasm-modifiers-nil-id-test]
[frontend-tests.main-errors-test]
[frontend-tests.plugins.comments-test]
[frontend-tests.plugins.context-shapes-test]
[frontend-tests.plugins.file-test]
[frontend-tests.plugins.flex-test]
[frontend-tests.plugins.format-test]
[frontend-tests.plugins.grid-test]
[frontend-tests.plugins.interactions-test]
@@ -54,6 +56,7 @@
[frontend-tests.plugins.shape-bugfixes-test]
[frontend-tests.plugins.text-test]
[frontend-tests.plugins.tokens-test]
[frontend-tests.plugins.user-test]
[frontend-tests.plugins.utils-test]
[frontend-tests.plugins.value-objects-test]
[frontend-tests.render-dimensions-test]
@@ -138,9 +141,11 @@
'frontend-tests.main-errors-test
'frontend-tests.logic.sidebar-transform-coalescing-test
'frontend-tests.logic.update-position-test
'frontend-tests.logic.wasm-modifiers-nil-id-test
'frontend-tests.plugins.comments-test
'frontend-tests.plugins.context-shapes-test
'frontend-tests.plugins.file-test
'frontend-tests.plugins.flex-test
'frontend-tests.plugins.format-test
'frontend-tests.plugins.grid-test
'frontend-tests.plugins.interactions-test
@@ -152,6 +157,7 @@
'frontend-tests.plugins.shape-bugfixes-test
'frontend-tests.plugins.text-test
'frontend-tests.plugins.tokens-test
'frontend-tests.plugins.user-test
'frontend-tests.plugins.utils-test
'frontend-tests.plugins.value-objects-test
'frontend-tests.render-wasm.process-objects-test
@@ -122,6 +122,43 @@
(get-in resolved-tokens ["typography.bad" :errors 0 :error/code])))
(done))))))))
;; Regression: a token with a `nil` value (e.g. a composite typography
;; token saved via the workspace form with no fields filled in) must never
;; reach StyleDictionary — its `tokens-studio` preprocessor assumes a
;; typography token's value is never null and throws an uncaught exception
;; on it, which used to take down resolution for every other token in the
;; file. It should be tagged with an empty-input error instead.
(t/deftest resolve-tokens-nil-value-test
(t/async
done
(let [tokens (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :id (cthi/new-id! :core-set)
:name "core"))
(ctob/add-token (cthi/id :core-set)
(ctob/make-token {:name "typography.empty"
:value nil
:type :typography}))
(ctob/add-token (cthi/id :core-set)
(ctob/make-token {:name "borderRadius.sm"
:value "12px"
:type :border-radius}))
(ctob/get-all-tokens-map))]
(->> (sd/resolve-tokens tokens)
(rx/subs!
(fn [resolved-tokens]
(t/testing "the nil-value token is tagged with an error instead of crashing"
(t/is (contains? resolved-tokens "typography.empty"))
(t/is (nil? (get-in resolved-tokens ["typography.empty" :resolved-value])))
(t/is (= :error.token/empty-input
(get-in resolved-tokens ["typography.empty" :errors 0 :error/code]))))
(t/testing "other tokens still resolve normally"
(t/is (= 12 (get-in resolved-tokens ["borderRadius.sm" :resolved-value])))))
(fn [err]
(t/do-report {:type :error :message "Stream error" :actual err})
(done))
(fn []
(done)))))))
(t/deftest resolve-tokens-interactive-test
(t/async
done
+1 -1
View File
@@ -267,7 +267,7 @@ The Penpot MCP server can be configured using environment variables.
| `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` |
| `PENPOT_MCP_REPL_ENABLE` | Explicitly enable/disable the REPL server. Set to `true` to enable. When unset, defaults to the value of `PENPOT_MCP_DEVENV`. | (unset) |
| `PENPOT_MCP_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` |
| `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools. Set to `true` to enable. | `false` |
| `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools in local single-user mode. Set to `true` to enable. | `false` |
| `PENPOT_MCP_TOOL_TIMEOUT_S` | Timeout, in seconds, for tool calls dispatched to the Penpot plugin | `120` |
| `PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS` | Maximum number of parallel export shape requests (multi-user mode only). | `0` (no limit) |
| `PENPOT_MCP_REDIS_URI` | Redis connection URI (e.g. `redis://host:6379`) enabling multi-instance horizontal scaling via Redis pub/sub task routing (multi-user mode only). When unset, the server runs in single-instance mode, requiring the plugin and MCP client to connect to the same instance. | (unset) |
@@ -1,6 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";
import { PenpotMcpServer } from "./PenpotMcpServer";
import { PenpotMcpServer, shouldRegisterDeveloperTools } from "./PenpotMcpServer";
test("registers developer tools in local devenv mode", () => {
assert.equal(shouldRegisterDeveloperTools(true, false), true);
});
test("does not register developer tools in multi-user devenv mode", () => {
assert.equal(shouldRegisterDeveloperTools(true, true), false);
});
test("does not register developer tools when devenv mode is disabled", () => {
assert.equal(shouldRegisterDeveloperTools(false, false), false);
});
// ── Pure function tests ────────────────────────────────────────
+8 -1
View File
@@ -50,6 +50,13 @@ class ToolInfo {
) {}
}
/**
* Indicates whether developer tools may be registered for the current server mode.
*/
export function shouldRegisterDeveloperTools(isDevEnv: boolean, isMultiUserMode: boolean): boolean {
return isDevEnv && !isMultiUserMode;
}
export class PenpotMcpServer {
/**
* Timeout, in minutes, for idle sessions (Streamable HTTP and SSE) before they are automatically closed and removed.
@@ -259,7 +266,7 @@ export class PenpotMcpServer {
if (this.isFileSystemAccessEnabled()) {
toolInstances.push(new ImportImageTool(this));
}
if (this.isDevEnv()) {
if (shouldRegisterDeveloperTools(this.isDevEnv(), this.isMultiUserMode())) {
const nreplClient = new NreplClient();
toolInstances.push(new CljsReplTool(this, nreplClient));
toolInstances.push(new ImportPenpotFileTool(this, nreplClient));
@@ -3,6 +3,7 @@ import { createPluginManager } from './plugin-manager';
import { loadManifestCode, getValidUrl, prepareUrl } from './parse-manifest.js';
import { PluginModalElement } from './modal/plugin-modal.js';
import { openUIApi } from './api/openUI.api.js';
import { validateUIUrl } from './validate-url.js';
import type { Context, Theme } from '@penpot/plugin-types';
import type { Manifest } from './models/manifest.model.js';
@@ -16,6 +17,10 @@ vi.mock('./api/openUI.api.js', () => ({
openUIApi: vi.fn(),
}));
vi.mock('./validate-url.js', () => ({
validateUIUrl: vi.fn(),
}));
describe('createPluginManager', () => {
let mockContext: Context;
let manifest: Manifest;
@@ -294,4 +299,39 @@ describe('createPluginManager', () => {
expect(mockContext.removeListener).toHaveBeenCalled();
expect(onCloseCallback).toHaveBeenCalled();
});
it('should validate the modal URL before opening', async () => {
const pluginManager = await createPluginManager(
mockContext,
manifest,
onCloseCallback,
onReloadModal,
);
pluginManager.openModal('Test Modal', '/test-url');
expect(validateUIUrl).toHaveBeenCalledWith(
'https://example.com/plugin',
manifest.host,
);
});
it('should throw when URL validation fails', async () => {
vi.mocked(validateUIUrl).mockImplementation(() => {
throw new Error("Plugin UI URL must not point to Penpot's own domain");
});
const pluginManager = await createPluginManager(
mockContext,
manifest,
onCloseCallback,
onReloadModal,
);
expect(() => pluginManager.openModal('Test Modal', '/test-url')).toThrow(
"Plugin UI URL must not point to Penpot's own domain",
);
expect(openUIApi).not.toHaveBeenCalled();
});
});
@@ -7,6 +7,7 @@ import { openUIApi } from './api/openUI.api.js';
import { OpenUIOptions } from './models/open-ui-options.model.js';
import { RegisterListener } from './models/plugin.model.js';
import { openUISchema } from './models/open-ui-options.schema.js';
import { validateUIUrl } from './validate-url.js';
export async function createPluginManager(
context: Context,
@@ -94,6 +95,7 @@ export async function createPluginManager(
const openModal = (name: string, url: string, options?: OpenUIOptions) => {
const theme = context.theme as Theme;
const modalUrl = prepareUrl(manifest, url, { theme });
validateUIUrl(modalUrl, manifest.host);
if (modal?.getAttribute('iframe-src') === modalUrl) {
return;
@@ -0,0 +1,117 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
getPenpotOrigin,
isPenpotOrigin,
validateUIUrl,
} from './validate-url.js';
describe('validate-url', () => {
const originalLocation = globalThis.location;
const originalPenpotPublicURI = (globalThis as any).penpotPublicURI;
const externalHost = 'https://example.com';
beforeEach(() => {
delete (globalThis as any).penpotPublicURI;
});
afterEach(() => {
if (originalPenpotPublicURI !== undefined) {
(globalThis as any).penpotPublicURI = originalPenpotPublicURI;
} else {
delete (globalThis as any).penpotPublicURI;
}
});
describe('getPenpotOrigin', () => {
it('should return location.origin when penpotPublicURI is not set', () => {
expect(getPenpotOrigin()).toBe(originalLocation.origin);
});
it('should return origin from penpotPublicURI when set', () => {
(globalThis as any).penpotPublicURI = 'https://design.penpot.com/';
expect(getPenpotOrigin()).toBe('https://design.penpot.com');
});
it('should fall back to location.origin when penpotPublicURI is invalid', () => {
(globalThis as any).penpotPublicURI = 'not-a-valid-url';
expect(getPenpotOrigin()).toBe(originalLocation.origin);
});
});
describe('isPenpotOrigin', () => {
it('should be true for a URL on Penpot origin', () => {
expect(
isPenpotOrigin(`${originalLocation.origin}/plugin/manifest.json`),
).toBe(true);
});
it('should be false for a URL on another origin', () => {
expect(isPenpotOrigin(`${externalHost}/manifest.json`)).toBe(false);
});
it('should be false for an unparseable URL', () => {
expect(isPenpotOrigin('not-a-valid-url')).toBe(false);
});
});
describe('validateUIUrl', () => {
it('should throw when URL has same origin as location.origin', () => {
const penpotOrigin = originalLocation.origin;
expect(() =>
validateUIUrl(`${penpotOrigin}/some/path`, externalHost),
).toThrow("Plugin UI URL must not point to Penpot's own domain");
});
it('should not throw when URL has different origin', () => {
expect(() =>
validateUIUrl('https://example.com/plugin-ui', externalHost),
).not.toThrow();
});
it('should throw when URL matches penpotPublicURI origin', () => {
(globalThis as any).penpotPublicURI = 'https://design.penpot.com/';
expect(() =>
validateUIUrl('https://design.penpot.com/some/path', externalHost),
).toThrow("Plugin UI URL must not point to Penpot's own domain");
});
it('should not throw when URL has same hostname but different port', () => {
const url = new URL(originalLocation.origin);
const differentPort = `${url.protocol}//${url.hostname}:9999`;
expect(() =>
validateUIUrl(`${differentPort}/path`, externalHost),
).not.toThrow();
});
it('should throw even when URL has different path on same origin', () => {
const penpotOrigin = originalLocation.origin;
expect(() =>
validateUIUrl(`${penpotOrigin}/deeply/nested/path`, externalHost),
).toThrow();
});
it('should not throw when the manifest is served from Penpot origin', () => {
const penpotOrigin = originalLocation.origin;
expect(() =>
validateUIUrl(`${penpotOrigin}/some/path`, `${penpotOrigin}/plugin`),
).not.toThrow();
});
it('should not throw when the manifest is served from penpotPublicURI origin', () => {
(globalThis as any).penpotPublicURI = 'https://design.penpot.com/';
expect(() =>
validateUIUrl(
'https://design.penpot.com/some/path',
'https://design.penpot.com/plugin',
),
).not.toThrow();
});
it('should still throw when the manifest host is unparseable', () => {
const penpotOrigin = originalLocation.origin;
expect(() => validateUIUrl(`${penpotOrigin}/path`, '')).toThrow(
"Plugin UI URL must not point to Penpot's own domain",
);
});
});
});
@@ -0,0 +1,44 @@
export function getPenpotOrigin(): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const publicUri = (globalThis as any).penpotPublicURI;
if (publicUri) {
try {
return new URL(publicUri).origin;
} catch {
// fall through to location.origin
}
}
return globalThis.location.origin;
}
/**
* Whether the given URL is served from Penpot's own origin. Unparseable URLs
* are considered external.
*/
export function isPenpotOrigin(url: string): boolean {
try {
return new URL(url).origin === getPenpotOrigin();
} catch {
return false;
}
}
/**
* Rejects UI URLs that resolve to Penpot's own origin, which would let the
* plugin iframe escape its sandbox isolation.
*
* Plugins whose manifest is itself served from Penpot's origin are part of the
* instance and are exempt from the check.
*/
export function validateUIUrl(url: string, manifestHost: string): void {
if (isPenpotOrigin(manifestHost)) {
return;
}
const parsed = new URL(url);
if (parsed.origin === getPenpotOrigin()) {
throw new Error(
`Plugin UI URL must not point to Penpot's own domain: ${url}`,
);
}
}
+14 -3
View File
@@ -690,7 +690,12 @@ impl RenderState {
/// Renders background blur effect directly to the given target surface.
/// Must be called BEFORE any save_layer for the shape's own opacity/blend,
/// so that the backdrop blur is independent of the shape's visual properties.
fn render_background_blur(&mut self, shape: &Shape, target_surface: SurfaceId) {
fn render_background_blur(
&mut self,
shape: &Shape,
clip_bounds: Option<&ClipStack>,
target_surface: SurfaceId,
) {
if self.options.is_fast_mode() {
return;
}
@@ -730,8 +735,14 @@ impl RenderState {
matrix.post_translate(center);
matrix.pre_translate(-center);
self.surfaces.canvas(target_surface).save();
if let Some(clips) = clip_bounds {
let antialias = shape.should_use_antialias(scale, self.options.antialias_threshold);
self.clip_target_surface_to_stack(clips, target_surface, scale, antialias);
}
let canvas = self.surfaces.canvas(target_surface);
canvas.save();
// Current/Export have no render context transform (identity canvas).
// Apply scale + translate + shape transform so the clip maps
@@ -3477,7 +3488,7 @@ impl RenderState {
// Render background blur BEFORE save_layer so it modifies
// the backdrop independently of the shape's opacity.
if !node_render_state.is_root() && self.focus_mode.is_active() {
self.render_background_blur(element, target_surface);
self.render_background_blur(element, clip_bounds.as_ref(), target_surface);
}
self.render_shape_enter(element, mask, clip_bounds.as_ref(), target_surface);