Compare commits

...

125 Commits

Author SHA1 Message Date
Andrey Antukh
18f9158d43 Add better approach for error handling to obj/reify 2026-02-13 12:17:37 +01:00
Andrey Antukh
43af62e5bc Make the obj/proxy object do not extend js/Object directly 2026-02-13 08:21:37 +01:00
Florian Schroedl
375608b44b ⬆️ Update tokenscript interpreter to 0.26.0 and add CSS color schemas
Regenerate schemas.js with preset:cssColors to support CSS color constants.
2026-02-12 14:14:45 +01:00
Andrey Antukh
12e5d8d8c4 Merge remote-tracking branch 'origin/staging-render' into develop 2026-02-12 11:00:56 +01:00
Andrey Antukh
04a3126856 Merge remote-tracking branch 'origin/main' into staging-render 2026-02-12 11:00:38 +01:00
Elena Torró
2f71663470 Merge pull request #8245 from penpot/elenatorro-13047-setup-embedded-text-editor
🔧 Set up embedded editor
2026-02-12 10:05:39 +01:00
Andrey Antukh
43cb313cd7 Merge pull request #8310 from oraios/mcp-tokens
 MCP improvements to enable UC2, design token handling
2026-02-12 09:47:32 +01:00
Elena Torró
0b199c606a Merge pull request #8331 from penpot/ladybenko-add-wasm-config-playwright-helper
🔧 Add helper utils to mock config flags for WasmWorkspacePage (e2e)
2026-02-12 09:45:03 +01:00
Aitor Moreno
54f63c5dc5 ♻️ Refactor minor things 2026-02-12 09:34:21 +01:00
Elena Torro
a14c36e996 📚 Add embedded text editor MVP documentation 2026-02-12 09:34:20 +01:00
Elena Torro
2b525f0f48 🔧 Set up embedded editor 2026-02-12 09:34:20 +01:00
Belén Albeza
fd6ff04e90 🔧 Add helper utils to mock config flags for WasmWorkspacePage (e2e) 2026-02-12 09:25:08 +01:00
eps-epsiloneridani
dbb0aa8ce2 📚 Update recommended-settings.md (#8330)
Got rid of a stray quotation mark

Signed-off-by: eps-epsiloneridani <162043859+eps-epsiloneridani@users.noreply.github.com>
2026-02-12 09:19:10 +01:00
Andrey Antukh
12822833f6 Merge pull request #8301 from eureka928/fix/4513-shift-arrow-color-inputs
🐛 Add Shift/Alt arrow key stepping to color picker inputs
2026-02-12 08:26:05 +01:00
eureka928
307ae374fe ♻️ Unify color picker input handlers by treating alpha as a property
Eliminate duplicated on-change-opacity and on-key-down-opacity handlers
by routing alpha through apply-property-change, and extract shared
stepping logic into on-key-down-step.

Signed-off-by: eureka928 <meobius123@gmail.com>
2026-02-12 08:25:37 +01:00
eureka928
7d7dbd4662 🐛 Add Shift/Alt arrow key stepping to color picker inputs (#4513)
Color picker numeric inputs (R, G, B, H, S, V, Alpha) now support
Shift+Arrow for ×10 steps and Alt+Arrow for ×0.1 steps, matching
the behavior of numeric inputs elsewhere in the application.

Signed-off-by: eureka928 <meobius123@gmail.com>
2026-02-12 08:25:37 +01:00
Alejandro Alonso
139d4ba13c Merge pull request #8328 from penpot/elenatorro-13311-fix-multiple-strokes-blending
🐛 Fix stroke color aliasing when a shape has multiple strokes
2026-02-12 07:05:44 +01:00
Elena Torro
0cb5c16823 🐛 Fix fallback font 2026-02-12 06:43:52 +01:00
Elena Torro
4ed1a544f8 🐛 Fix stroke color aliasing when a shape has multiple strokes 2026-02-12 06:43:52 +01:00
Elena Torró
566ac67fc9 Merge pull request #8324 from penpot/azazeln28-fix-editor-fills
🐛 Fix text editor issues
2026-02-11 16:37:20 +01:00
Juanfran
394d597736 Add enhacements to plugins build mechanism (#8326)
* 🐛 Fix plugin code build

* 🔧 Update editor.defaultFormatter to new Prettier

* 🐛 Fix lint issues in create-palette-plugin

* 🐛 Add missing run in pnpm init script for plugins
2026-02-11 15:28:33 +01:00
Aitor Moreno
b2231e520c 📚 Add best practices to text editor README.md 2026-02-11 13:09:56 +01:00
Aitor Moreno
e722e17b10 🐛 Fix paragraph styles not being applied 2026-02-11 12:49:20 +01:00
Aitor Moreno
755d720b34 🐛 Fix text editor fills not being updated 2026-02-11 12:29:03 +01:00
Alejandro Alonso
d991d59852 Merge pull request #8318 from penpot/elenatorro-13311-fix-multiple-fills-blending
🐛 Fix fill aliasing when a shape has multiple fills
2026-02-11 11:37:43 +01:00
Dominik Jain
7eb9a207f5 Change PenpotUtils.findShapes to search on all pages by default
This matches the behaviour of findShape, more closely aligning with
the LLM's expectations (given the lack of concrete information in
the instructions)
2026-02-11 11:35:10 +01:00
Dominik Jain
8ac17604fd Improve information on component instances
* Add information on detachment
* Add information on remove behaviour in component instances
2026-02-11 11:35:10 +01:00
Elena Torro
eede023d6b 🐛 Fix fill aliasing when a shape has multiple fills 2026-02-11 11:21:08 +01:00
Belén Albeza
ccd42852b7 🐛 Fix token not being highlighted (wasm) 2026-02-11 11:17:27 +01:00
Alejandro Alonso
a2f7ae549e Merge pull request #8312 from penpot/elenatorro-13256-sync-text-selection
🔧 Hide text color from selected text
2026-02-11 11:02:35 +01:00
Alejandro Alonso
6f74d458a8 🐛 Adding lost file for render e2e testing get-file-stroke-styles.json 2026-02-11 10:47:50 +01:00
Alejandro Alonso
8d033de145 Merge pull request #8299 from penpot/elenatorro-13242-review-performance
🔧 Improve layout performance
2026-02-11 10:45:40 +01:00
Dominik Jain
f5afcde0de Improve shapeStructure
* Add information on component instance (component id, name; main instance id)
* Improve JSON result order (children should come last)
2026-02-11 10:45:22 +01:00
Dominik Jain
b6dfdc23cd Update information on TokenProperty 2026-02-11 10:45:22 +01:00
Dominik Jain
a5a084cf0f Update API type information based on current repo state 2026-02-11 10:45:22 +01:00
Dominik Jain
1546025814 Avoid certain <ul> elements with single <li> generating bullets 2026-02-11 10:45:22 +01:00
Dominik Jain
8de510d1c6 🐛 Fix PenpotAPIDocsProcessor not requiring the url parameter
* Add additional constant for the PROD url
* Adapt the debug function to use a URL
* Improve logging
2026-02-11 10:45:10 +01:00
Elena Torró
2e77c09ca5 Merge pull request #8309 from penpot/superalex-fix-stroke-dot-dash-mix
🐛 Fix dot strokes
2026-02-11 10:37:46 +01:00
Elena Torró
47346e478e Merge pull request #8303 from penpot/superalex-fix-stroke-opacity-for-boards
🐛 Fix stroke opacity for boards
2026-02-11 10:05:47 +01:00
Andrey Antukh
89cd4d820c 🔥 Remove mcp from compose 2026-02-11 09:13:24 +01:00
Alejandro Alonso
f32c377f17 🐛 Fix stroke opacity for boards 2026-02-11 09:08:03 +01:00
Andrey Antukh
8693623b13 📎 Update SECURITY.md file 2026-02-11 08:11:04 +01:00
Alejandro Alonso
97f01c646d 🎉 Improve multiple emoji E2E test 2026-02-11 07:36:22 +01:00
Alejandro Alonso
eea1d3c0a5 🎉 Improve updating canvas background E2E test 2026-02-11 07:19:22 +01:00
Alejandro Alonso
9eef4de87d 🐛 Fix dot/dahs/mixed strokes 2026-02-11 07:08:28 +01:00
Andrey Antukh
f4d07a3c36 ⬆️ Update pnpm on frontend and plugins modules 2026-02-10 19:02:32 +01:00
Francis Santiago
15fa6206e2 Merge pull request #8286 from penpot/fc-fix-ipv6-fronted
🎉 Enable IPv6 support for docker images
2026-02-10 16:01:06 +01:00
Francis Santiago
3281819283 🎉 Enable IPv6 support for docker images 2026-02-10 15:52:33 +01:00
Andrés Moya
41f29767db 🐛 Fix configuration of poc-tokens-plugin (#8314)
* 🐛 Fix configuration of poc-tokens-plugin

* 📎 Add missing changes on poc-tokens-pligin tsconfig

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-02-10 15:18:11 +01:00
Dominik Jain
76289df32c Establish compatibility with new member anchors (h3 instead of a tag) 2026-02-10 14:03:40 +01:00
Andrey Antukh
920fbe34ad 🐛 Fix invalid deps passed to http management routes service 2026-02-10 13:46:29 +01:00
Elena Torro
187d1118c0 🔧 Hide text color from selected text 2026-02-10 13:15:55 +01:00
Dominik Jain
a674b5f914 📚 Add instructions on running only the docs server 2026-02-10 12:53:20 +01:00
Dominik Jain
71507fb9b7 ♻️ Adjust ConfigurationLoader to use markdown file instead of yml 2026-02-10 12:35:44 +01:00
Dominik Jain
024aedc3ca ♻️ Convert prompt content to markdown format 2026-02-10 12:35:44 +01:00
Dominik Jain
44657c95df ♻️ Rename prompts.yml -> initial_instructions.md 2026-02-10 12:35:44 +01:00
Dominik Jain
d4d5009a3d Improve prompts on token application 2026-02-10 12:35:44 +01:00
Dominik Jain
bb4d0322d8 🚧 Temporarily add ts-ignore statements
This shall be reverted once the new API types are published
2026-02-10 12:35:44 +01:00
Dominik Jain
56e369a1c0 Add helper functions for token exploration
Extend PenpotUtils with helper functions for token exploration/discovery
and describe them in the system prompt
2026-02-10 12:35:44 +01:00
Dominik Jain
6b277956b9 Add information on clone() method 2026-02-10 12:35:44 +01:00
Dominik Jain
e9a56c9d9f Shorten design token instructions 2026-02-10 12:35:44 +01:00
Dominik Jain
8d90edcc2f Add instructions on design tokens 2026-02-10 12:35:44 +01:00
Dominik Jain
8186f3c87c 📚 Remove misleading information from README
The types build is not part of the bootstrap, and it is not
relevant to regular users (only to developers).

Information on how to apply it is now in types-generator/README.md
2026-02-10 12:35:44 +01:00
Dominik Jain
d7282518c4 📚 Improve usage documentation of API type generator script 2026-02-10 12:35:44 +01:00
Dominik Jain
467eb3c333 Update API docs to include token-related types 2026-02-10 12:35:44 +01:00
Dominik Jain
d2299f83ec Apply bash in build scripts explicitly (Win compatibility) 2026-02-10 12:35:44 +01:00
Andrey Antukh
11a283916d Merge remote-tracking branch 'origin/staging' into staging-render 2026-02-10 11:58:27 +01:00
Andrey Antukh
f08700945a Merge remote-tracking branch 'origin/staging' into develop 2026-02-10 11:58:09 +01:00
Andrey Antukh
59711a1cf8 📎 Update changelog 2026-02-10 11:57:01 +01:00
Aitor Moreno
e9b2e9e818 🚑 Hot fix for text editor internal error 2026-02-10 11:10:16 +01:00
Belén Albeza
c4aa51bc01 🐛 Fix permanent blur when switching pages 2026-02-10 10:59:47 +01:00
Belén Albeza
1c270ac9c6 Remove leftover println in render code 2026-02-10 10:59:47 +01:00
Andrey Antukh
06e5825c8a 🐛 Add proper input checking to font related RCP method 2026-02-10 10:36:57 +01:00
Andrey Antukh
e3dfa69011 Make plugins devserver to be able run inside devenv 2026-02-10 08:29:24 +01:00
Juanfran
96b682aa12 ♻️ Remove Nx and rely on pnpm monorepo features 2026-02-10 08:29:24 +01:00
Juanfran
45d04942cc Add example ui storybook 2026-02-10 08:29:24 +01:00
Juanfran
07055b53d1 ⬆️ Update plugins dependencies 2026-02-10 08:29:24 +01:00
Andrey Antukh
d30387eb77 Backport docker images changes from develop 2026-02-09 19:21:30 +01:00
Andrey Antukh
33fd672c21 Backport MCP related changes from develop (#8306) 2026-02-09 18:00:43 +01:00
Andrey Antukh
dd7038bdad 📎 Fix fmt issue on frontend code 2026-02-09 17:38:40 +01:00
Andrey Antukh
5ec345162a Add mcp plugin into the frontend bundle 2026-02-09 17:38:40 +01:00
Andrey Antukh
0027e9031a Make mcp env vars to use the same convention as penpot 2026-02-09 17:38:40 +01:00
Pablo Alba
5d3ccbc8b4 Add managed profiles endpoint to nitrate api (#8292) 2026-02-09 15:52:18 +01:00
Andrés Moya
1a1c351466 🐛 Fix dependency 2026-02-09 15:06:39 +01:00
Andrés Moya
5b5f22a8c6 🎉 Add tokens to Penpot Plugins API (#7756)
* 🎉 Add tokens to plugins API documentation

And add poc plugin example

* 📚 Document better the tokens value in plugins API

* 🔧 Refactor token validation schemas

* 🔧 Use automatic validation in token proxies

* 🔧 Use schemas to validate token creation

* 🔧 Use multi schema for token value

* 🔧 Use schema in token api methods

* 🐛 Fix review comments

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-02-09 14:18:31 +01:00
Andrey Antukh
ac1c3ff184 Merge branch 'staging-render' into develop 2026-02-09 14:14:02 +01:00
Andrey Antukh
43cd92c76d Merge remote-tracking branch 'origin/staging' into staging-render 2026-02-09 14:12:55 +01:00
Elena Torró
cf2b40a097 Merge pull request #8302 from penpot/azazeln28-issue-13124-text-not-restored-undoing
🐛 Fix text not restored on ctrl+z
2026-02-09 13:41:43 +01:00
Aitor Moreno
b72959544c 🐛 Fix text not restored on ctrl+z 2026-02-09 13:29:31 +01:00
Andrey Antukh
a7b2e98b8e ⬆️ Use latest imagemagick version on docker images 2026-02-09 13:19:26 +01:00
Andrey Antukh
d979894872 Add libxml2 dep to imagemagick dockerfile 2026-02-09 12:27:44 +01:00
Alejandro Alonso
3d20fc508d 🐛 Fix image magick info call (#8300) 2026-02-09 12:26:42 +01:00
Elena Torro
969666b39b 🔧 Simplify view interaction log message
Remove zoom_changed from log output as it's no longer needed
for debugging after the tile optimization changes.
2026-02-09 11:44:50 +01:00
Yamila Moreno
d953222eb4 🔧 Add CI for MCP server (#8293) 2026-02-09 11:25:24 +01:00
Elena Torró
b3faa985ce Merge pull request #8291 from penpot/superalex-fix-dashboard-navigation
🐛 Fix dashboard navigation from workspace
2026-02-09 09:59:11 +01:00
Elena Torro
a8322215dd 🔧 Optimize pan/zoom tile handling
- Add incremental tile update that preserves cache during pan
- Only invalidate tile cache when zoom changes
- Force visible tiles to render synchronously (no yielding)
- Increase interest area threshold from 2 to 3 tiles
2026-02-09 09:38:01 +01:00
Elena Torro
e1ce97a2b4 🔧 Prioritize visible tiles over interest-area tiles
Partition pending tiles into 4 groups by visibility and cache status.
Visible tiles are processed first to eliminate empty squares during
pan/zoom. Cached tiles within each group are processed before uncached.
2026-02-09 09:38:01 +01:00
Elena Torro
2ccd2a6679 🔧 Use HashSet for grid layout children lookup
HashSet provides O(1) contains() vs Vec's O(n), improving
child lookup performance in grid cell data creation.
2026-02-09 09:38:01 +01:00
Elena Torro
2d9a2e0d50 🔧 Use swap_remove in flex layout distribution
swap_remove is O(1) vs remove's O(n) when order doesn't matter.
These loops iterate backwards, so swap_remove is safe.
2026-02-09 09:38:01 +01:00
Elena Torro
216d400262 🔧 Prevent duplicate layout calculations
Use HashSet for layout_reflows to avoid processing the same
layout multiple times. Also use std::mem::take instead of
creating a new Vec on each iteration.
2026-02-09 09:37:58 +01:00
Elena Torro
c87ffdcd30 🔧 Add forward children iterator for flex layout
Avoid Vec allocation + reverse for reversed flex layouts.
The new children_ids_iter_forward returns children in original order,
eliminating the need to collect and reverse.
2026-02-09 09:35:04 +01:00
Elena Torro
8ef6600cdc 🔧 Return HashSet from update_shape_tiles
Avoid final collect() allocation by returning HashSet directly.
Callers already use extend() which works with both types.
2026-02-09 09:35:04 +01:00
Elena Torro
a3764b9713 🔧 Avoid clone in rebuild_touched_tiles
Use std::mem::take instead of clone to avoid HashSet allocation.
The set was cleared anyway by clean_touched(), so take() is safe.
2026-02-09 09:35:03 +01:00
Alejandro Alonso
e5cdb5b163 Merge pull request #8290 from penpot/alotor-fix-alt-duplicate
🐛 Fix problem with alt+move for duplicate shapes
2026-02-09 06:33:13 +01:00
David Barragán Merino
a4f2641cc9 🔧 Enable observability for plugin docs and packages 2026-02-06 18:01:11 +01:00
Alejandro Alonso
a164a1bab3 🐛 Fix dashboard navigation from workspace 2026-02-06 12:58:56 +01:00
alonso.torres
a0cbb392af 🐛 Fix problem with alt+move for duplicate shapes 2026-02-06 12:20:43 +01:00
Alejandro Alonso
ccfee34e76 Merge pull request #8289 from penpot/niwinz-staging-exporter-fix
🐛 Fix issue with pdf render on exporter
2026-02-06 11:40:18 +01:00
Andrey Antukh
989eb12139 🔥 Remove merge conflict from plugins api ns 2026-02-06 11:38:36 +01:00
Eva Marco
a5e36dbb3d 🐛 Fix broken attribute on numeric input (#8250)
* 🐛 Fix broken attribute on numeric input

* 🐛 Fix tooltip position
2026-02-06 11:32:16 +01:00
Alejandro Alonso
8acd031ab2 Merge remote-tracking branch 'origin/staging-render' into develop 2026-02-06 11:23:50 +01:00
Andrey Antukh
6f3f2f9a71 🐛 Fix issue with pdf render on exporter
When paired with release build penpot app
2026-02-06 11:19:56 +01:00
Elena Torro
a7c1de6478 🐛 Fix lazy load intersection on dragging at the beginning 2026-02-06 10:59:05 +01:00
Elena Torro
184487f568 🐛 Fix lazy load intersection on dragging at the beginning 2026-02-06 10:53:11 +01:00
Andrey Antukh
c00d512193 Add the concept of version to plugins
And make mcp plugin version 2
2026-02-06 09:42:59 +01:00
alonso.torres
af5dbf2fbc 🐛 Set objects modified instead of modif-tree 2026-02-06 09:34:58 +01:00
Alejandro Alonso
7c7e32d85f 🐛 Fix grid lines 2026-02-06 09:34:58 +01:00
Andrey Antukh
2ccb33ba89 📎 Add missing for-update for the migration 145 2026-02-05 18:12:11 +01:00
Andrey Antukh
ee88ee63a2 Add data migration for fix plugins data on profiles 2026-02-05 18:08:28 +01:00
alonso.torres
fd3d549f9c Batch text layout updates 2026-02-05 17:29:43 +01:00
alonso.torres
53c2acb3e6 🐛 Fix several problems with layouts and texts 2026-02-05 17:29:43 +01:00
Belén Albeza
8a72eb64c3 Add integration test for 13267 2026-02-05 16:37:21 +01:00
alonso.torres
1d45ca7019 🐛 Fix problem propagating geometry changes to instances 2026-02-05 16:37:21 +01:00
Alejandro Alonso
ad5e8ccdb3 🐛 Fix pdf sizing issue on export (#8274) 2026-02-05 09:23:14 +01:00
andrés gonzález
79e5d2f4cd 📚 Change link to post at SH guide (#8247) 2026-02-03 08:27:17 +01:00
331 changed files with 39751 additions and 30533 deletions

View File

@@ -59,6 +59,7 @@ jobs:
mv penpot/frontend bundle-frontend
mv penpot/exporter bundle-exporter
mv penpot/storybook bundle-storybook
mv penpot/mcp bundle-mcp
popd
- name: Set up Docker Buildx
@@ -89,6 +90,7 @@ jobs:
backend
exporter
storybook
mcp
labels: |
bundle_version=${{ steps.bundles.outputs.bundle_version }}
@@ -152,6 +154,21 @@ jobs:
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
- name: Build and push MCP Docker image
uses: docker/build-push-action@v6
env:
DOCKER_IMAGE: 'mcp'
BUNDLE_PATH: './bundle-mcp'
with:
context: ./docker/images/
file: ./docker/images/Dockerfile.mcp
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:${{ steps.vars.outputs.gh_ref }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
- name: Notify Mattermost
if: failure()
uses: mattermost/action-mattermost-notify@master

View File

@@ -87,7 +87,11 @@ jobs:
- name: Build runtime
working-directory: ./plugins
run: pnpm run build
run: pnpm run build:runtime
- name: Build doc
working-directory: ./plugins
run: pnpm run build:doc
- name: Build plugins
working-directory: ./plugins

View File

@@ -10,6 +10,7 @@
### :sparkles: New features & Enhancements
- Access to design tokens in Penpot Plugins [Taiga #8990](https://tree.taiga.io/project/penpot/us/8990)
- Remap references when renaming tokens [Taiga #10202](https://tree.taiga.io/project/penpot/us/10202)
- Tokens panel nested path view [Taiga #9966](https://tree.taiga.io/project/penpot/us/9966)
- Improve usability of lock and hide buttons in the layer panel. [Taiga #12916](https://tree.taiga.io/project/penpot/issue/12916)
@@ -34,6 +35,19 @@
- Fix viewer can update library [Taiga #13186](https://tree.taiga.io/project/penpot/issue/13186)
- Fix remove fill affects different element than selected [Taiga #13128](https://tree.taiga.io/project/penpot/issue/13128)
## 2.13.2
### :bug: Bugs fixed
- Fix security issue (Path Traversal Vulnerability) on fonts related RPC method
## 2.13.1
### :bug: Bugs fixed
- Fix PDF Exporter outputs empty page when board has A4 format [Taiga #13181](https://tree.taiga.io/project/penpot/issue/13181)
## 2.13.0
### :heart: Community contributions (Thank you!)

View File

@@ -2,4 +2,30 @@
## Reporting a Vulnerability
Please report security issues to `support@penpot.app`
We take the security of this project seriously. If you have discovered
a security vulnerability, please do **not** open a public issue.
Please report vulnerabilities via email to: **[support@penpot.app]**
### What to include:
* A brief description of the vulnerability.
* Steps to reproduce the issue.
* Potential impact if exploited.
We appreciate your patience and your commitment to **responsible disclosure**.
---
## Security Contributors
We are incredibly grateful to the following individuals and
organizations for their help in keeping this project safe.
* **Ali Maharramli** for identifying critical path traversal vulnerability
> **Note:** This list is a work in progress. If you have contributed
> to the security of this project and would like to be recognized (or
> prefer to remain anonymous), please let us know.

View File

@@ -275,7 +275,8 @@
::email/whitelist (ig/ref ::email/whitelist)}
::mgmt/routes
{::db/pool (ig/ref ::db/pool)}
{::db/pool (ig/ref ::db/pool)
::setup/props (ig/ref ::setup/props)}
:app.http/router
{::session/manager (ig/ref ::session/manager)

View File

@@ -35,8 +35,7 @@
javax.xml.parsers.SAXParserFactory
org.apache.commons.io.IOUtils
org.im4java.core.ConvertCmd
org.im4java.core.IMOperation
org.im4java.core.Info))
org.im4java.core.IMOperation))
(def default-max-file-size
(* 1024 1024 10)) ; 10 MiB
@@ -224,17 +223,18 @@
;; If we are processing an animated gif we use the first frame with -scene 0
(let [dim-result (sh/sh "identify" "-format" "%w %h\n" path)
orient-result (sh/sh "identify" "-format" "%[EXIF:Orientation]\n" path)]
(if (and (= 0 (:exit dim-result))
(= 0 (:exit orient-result)))
(when (= 0 (:exit dim-result))
(let [[w h] (-> (:out dim-result)
str/trim
(clojure.string/split #"\s+")
(->> (mapv #(Integer/parseInt %))))
orientation (-> orient-result :out str/trim)]
(case orientation
("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees
{:width w :height h})) ; Normal or unknown orientation
nil)))
orientation-exit (:exit orient-result)
orientation (-> orient-result :out str/trim)]
(if (= 0 orientation-exit)
(case orientation
("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees
{:width w :height h}) ; Normal or unknown orientation
{:width w :height h}))))) ; If orientation can't be read, use dimensions as-is
(defmethod process :info
[{:keys [input] :as params}]
@@ -247,26 +247,37 @@
:hint "uploaded svg does not provides dimensions"))
(merge input info {:ts (ct/now) :size (fs/size path)}))
(let [instance (Info. (str path))
mtype' (.getProperty instance "Mime type")]
(let [path-str (str path)
identify-res (sh/sh "identify" "-format" "image/%[magick]\n" path-str)
;; identify prints one line per frame (animated GIFs, etc.); we take the first one
mtype' (if (zero? (:exit identify-res))
(-> identify-res
:out
str/trim
(str/split #"\s+" 2)
first
str/lower)
(ex/raise :type :validation
:code :invalid-image
:hint "invalid image"))
{:keys [width height]}
(or (get-dimensions-with-orientation path-str)
(do
(l/warn "Failed to read image dimensions with orientation" {:path path})
(ex/raise :type :validation
:code :invalid-image
:hint "invalid image")))]
(when (and (string? mtype)
(not= mtype mtype'))
(not= (str/lower mtype) mtype'))
(ex/raise :type :validation
:code :media-type-mismatch
:hint (str "Seems like you are uploading a file whose content does not match the extension."
"Expected: " mtype ". Got: " mtype')))
(let [{:keys [width height]}
(or (get-dimensions-with-orientation (str path))
(do
(l/warn "Failed to read image dimensions with orientation; falling back to im4java"
{:path path})
{:width (.getPageWidth instance)
:height (.getPageHeight instance)}))]
(assoc input
:width width
:height height
:size (fs/size path)
:ts (ct/now)))))))
(assoc input
:width width
:height height
:size (fs/size path)
:ts (ct/now))))))
(defmethod process-error org.im4java.core.InfoException
[error]

View File

@@ -10,6 +10,7 @@
[app.common.logging :as l]
[app.db :as db]
[app.migrations.clj.migration-0023 :as mg0023]
[app.migrations.clj.migration-0145 :as mg0145]
[app.util.migrations :as mg]
[integrant.core :as ig]))
@@ -459,7 +460,11 @@
:fn (mg/resource "app/migrations/sql/0143-add-http-session-v2-table.sql")}
{:name "0144-mod-server-error-report-table"
:fn (mg/resource "app/migrations/sql/0144-mod-server-error-report-table.sql")}])
:fn (mg/resource "app/migrations/sql/0144-mod-server-error-report-table.sql")}
{:name "0145-fix-plugins-uri-on-profile"
:fn mg0145/migrate}])
(defn apply-migrations!
[pool name migrations]

View File

@@ -0,0 +1,83 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.migrations.clj.migration-0145
"Migrate plugins references on profiles"
(:require
[app.common.data :as d]
[app.common.logging :as l]
[app.db :as db]
[cuerdas.core :as str]))
(def ^:private replacements
{"https://colors-to-tokens-plugin.pages.dev"
"https://colors-to-tokens.plugins.penpot.app"
"https://contrast-penpot-plugin.pages.dev"
"https://contrast.plugins.penpot.app"
"https://create-palette-penpot-plugin.pages.dev"
"https://create-palette.plugins.penpot.app"
"https://icons-penpot-plugin.pages.dev"
"https://icons.plugins.penpot.app"
"https://lorem-ipsum-penpot-plugin.pages.dev"
"https://lorem-ipsum.plugins.penpot.app"
"https://rename-layers-penpot-plugin.pages.dev"
"https://rename-layers.plugins.penpot.app"
"https://table-penpot-plugin.pages.dev"
"https://table.plugins.penpot.app"})
(defn- fix-url
[url]
(reduce-kv (fn [url prefix replacement]
(if (str/starts-with? url prefix)
(reduced (str replacement (subs url (count prefix))))
url))
url
replacements))
(defn- fix-manifest
[manifest]
(-> manifest
(d/update-when :url fix-url)
(d/update-when :host fix-url)))
(defn- fix-plugins-data
[props]
(d/update-in-when props [:plugins :data]
(fn [data]
(reduce-kv (fn [data id manifest]
(let [manifest' (fix-manifest manifest)]
(if (= manifest manifest')
data
(assoc data id manifest'))))
data
data))))
(def ^:private sql:get-profiles
"SELECT id, props FROM profile
WHERE props ?? '~:plugins'
ORDER BY created_at
FOR UPDATE")
(defn migrate
[conn]
(->> (db/plan conn [sql:get-profiles])
(run! (fn [{:keys [id props]}]
(when-let [props (some-> props db/decode-transit-pgobject)]
(let [props' (fix-plugins-data props)]
(when (not= props props')
(l/inf :hint "fixing plugins data on profile props" :profile-id (str id))
(db/update! conn :profile
{:props (db/tjson props')}
{:id id}
{::db/return-keys false}))))))))

View File

@@ -89,7 +89,8 @@
(def ^:private schema:create-font-variant
[:map {:title "create-font-variant"}
[:team-id ::sm/uuid]
[:data [:map-of ::sm/text ::sm/any]]
[:data [:map-of ::sm/text [:or ::sm/bytes
[::sm/vec ::sm/bytes]]]]
[:font-id ::sm/uuid]
[:font-family ::sm/text]
[:font-weight [::sm/one-of {:format "number"} valid-weight]]

View File

@@ -9,7 +9,7 @@
organization management and token validation endpoints."
(:require
[app.common.schema :as sm]
[app.common.types.profile :refer [schema:profile]]
[app.common.types.profile :refer [schema:profile, schema:basic-profile]]
[app.common.types.team :refer [schema:team]]
[app.common.uuid :as uuid]
[app.db :as db]
@@ -80,3 +80,35 @@
:team-id id
:organization-id organization-id
:organization-name organization-name})))
;; ---- API: get-managed-profiles
(def ^:private sql:get-managed-profiles
"SELECT DISTINCT p.id, p.fullname as name, p.email
FROM profile p
JOIN team_profile_rel tpr_member
ON tpr_member.profile_id = p.id
WHERE p.id <> ?
AND EXISTS (
SELECT 1
FROM team_profile_rel tpr_owner
JOIN team t
ON t.id = tpr_owner.team_id
WHERE tpr_owner.profile_id = ?
AND tpr_owner.team_id = tpr_member.team_id
AND tpr_owner.is_owner IS TRUE
AND t.is_default IS FALSE
AND t.deleted_at IS NULL);")
(def schema:managed-profile-result
[:vector schema:basic-profile])
(sv/defmethod ::get-managed-profiles
"List profiles that belong to teams for which current user is owner"
{::doc/added "2.14"
::sm/params [:map]
::sm/result schema:managed-profile-result}
[cfg {:keys [::rpc/profile-id]}]
(let [current-user-id (-> (profile/get-profile cfg profile-id) :id)]
(db/exec! cfg [sql:get-managed-profiles current-user-id current-user-id])))

View File

@@ -274,3 +274,30 @@
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 3 (:delete res)))))))
(t/deftest input-sanitization-1
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
font-id (uuid/custom 10 1)
ttfdata (-> (io/resource "backend_tests/test_files/font-1.ttf")
(io/read*))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "somefont"
:font-weight 400
:font-style "normal"
:data {"font/ttf" "/etc/passwd"}}
out (th/command! params)]
(t/is (= 0 (:call-count @mock)))
;; (th/print-result! out)
(let [error (:error out)
error-data (ex-data error)]
(t/is (th/ex-info? error))))))

View File

@@ -55,6 +55,7 @@
"design-tokens/v1"
"text-editor/v2-html-paste"
"text-editor/v2"
"text-editor-wasm/v1"
"render-wasm/v1"
"variants/v1"})
@@ -78,6 +79,7 @@
"plugins/runtime"
"text-editor/v2-html-paste"
"text-editor/v2"
"text-editor-wasm/v1"
"tokens/numeric-input"
"render-wasm/v1"})
@@ -127,6 +129,7 @@
:feature-design-tokens "design-tokens/v1"
:feature-text-editor-v2 "text-editor/v2"
:feature-text-editor-v2-html-paste "text-editor/v2-html-paste"
:feature-text-editor-wasm "text-editor-wasm/v1"
:feature-render-wasm "render-wasm/v1"
:feature-variants "variants/v1"
:feature-token-input "tokens/numeric-input"

View File

@@ -27,6 +27,7 @@
[app.common.types.path :as path]
[app.common.types.shape :as cts]
[app.common.types.shape-tree :as ctst]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[app.common.types.typographies-list :as ctyl]
[app.common.types.typography :as ctt]
@@ -378,7 +379,7 @@
[:type [:= :set-token]]
[:set-id ::sm/uuid]
[:token-id ::sm/uuid]
[:attrs [:maybe ctob/schema:token-attrs]]]]
[:attrs [:maybe cto/schema:token-attrs]]]]
[:set-token-set
[:map {:title "SetTokenSetChange"}

View File

@@ -8,8 +8,228 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.i18n :refer [tr]]
[app.common.schema :as sm]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[clojure.set :as set]
[cuerdas.core :as str]))
[cuerdas.core :as str]
[malli.core :as m]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HIGH LEVEL SCHEMAS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Token value
(defn- token-value-empty-fn
[{:keys [value]}]
(when (or (str/empty? value)
(str/blank? value))
(tr "workspace.tokens.empty-input")))
(def schema:token-value-generic
[::sm/text {:error/fn token-value-empty-fn}])
(def schema:token-value-composite-ref
[::sm/text {:error/fn token-value-empty-fn}])
(def schema:token-value-font-family
[:vector :string])
(def schema:token-value-typography-map
[:map
[:font-family {:optional true} schema:token-value-font-family]
[:font-weight {:optional true} schema:token-value-generic]
[:font-size {:optional true} schema:token-value-generic]
[:line-height {:optional true} schema:token-value-generic]
[:letter-spacing {:optional true} schema:token-value-generic]
[:paragraph-spacing {:optional true} schema:token-value-generic]
[:text-decoration {:optional true} schema:token-value-generic]
[:text-case {:optional true} schema:token-value-generic]])
(def schema:token-value-typography
[:or
schema:token-value-typography-map
schema:token-value-composite-ref])
(def schema:token-value-shadow-vector
[:vector
[:map
[:offset-x :string]
[:offset-y :string]
[:blur
[:and
:string
[:fn {:error/fn #(tr "workspace.tokens.shadow-token-blur-value-error")}
(fn [blur]
(let [n (d/parse-double blur)]
(or (nil? n) (not (< n 0)))))]]]
[:spread
[:and
:string
[:fn {:error/fn #(tr "workspace.tokens.shadow-token-spread-value-error")}
(fn [spread]
(let [n (d/parse-double spread)]
(or (nil? n) (not (< n 0)))))]]]
[:color :string]
[:inset {:optional true} :boolean]]])
(def schema:token-value-shadow
[:or
schema:token-value-shadow-vector
schema:token-value-composite-ref])
(defn make-token-value-schema
[token-type]
[:multi {:dispatch (constantly token-type)
:title "Token Value"}
[:font-family schema:token-value-font-family]
[:typography schema:token-value-typography]
[:shadow schema:token-value-shadow]
[::m/default schema:token-value-generic]])
;; Token
(defn make-token-name-schema
"Dynamically generates a schema to check a token name, adding translated error messages
and two additional validations:
- Min and max length.
- Checks if other token with a path derived from the name already exists at `tokens-tree`.
e.g. it's not allowed to create a token `foo.bar` if a token `foo` already exists."
[tokens-tree]
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
(-> cto/schema:token-name
(sm/update-properties assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))))
[:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}
#(and (some? tokens-tree)
(not (ctob/token-name-path-exists? % tokens-tree)))]])
(def schema:token-description
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}])
(defn make-token-schema
[tokens-tree token-type]
[:and
(sm/merge
cto/schema:token-attrs
[:map
[:name (make-token-name-schema tokens-tree)]
[:value (make-token-value-schema token-type)]
[:description {:optional true} schema:token-description]])
[:fn {:error/field :value
:error/fn #(tr "workspace.tokens.self-reference")}
(fn [{:keys [name value]}]
(when (and name value)
(not (cto/token-value-self-reference? name value))))]])
(defn convert-dtcg-token
"Convert token attributes as they come from a decoded json, with DTCG types, to internal types.
Eg. From this:
{'name' 'body-text'
'type' 'typography'
'value' {
'fontFamilies' ['Arial' 'Helvetica' 'sans-serif']
'fontSize' '16px'
'fontWeights' 'normal'}}
to this
{:name 'body-text'
:type :typography
:value {
:font-family ['Arial' 'Helvetica' 'sans-serif']
:font-size '16px'
:font-weight 'normal'}}"
[token-attrs]
(let [name (get token-attrs "name")
type (get token-attrs "type")
value (get token-attrs "value")
description (get token-attrs "description")
type (cto/dtcg-token-type->token-type type)
value (case type
:font-family (ctob/convert-dtcg-font-family value)
:typography (ctob/convert-dtcg-typography-composite value)
:shadow (ctob/convert-dtcg-shadow-composite value)
value)]
(d/without-nils {:name name
:type type
:value value
:description description})))
;; Token set
(defn make-token-set-name-schema
"Generates a dynamic schema to check a token set name:
- Validate name length.
- Checks if other token set with a path derived from the name already exists in the tokens lib."
[tokens-lib set-id]
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
[:fn {:error/fn #(tr "errors.token-set-already-exists" (:value %))}
(fn [name]
(or (nil? tokens-lib)
(let [set (ctob/get-set-by-name tokens-lib name)]
(or (nil? set) (= (ctob/get-id set) set-id)))))]])
(def schema:token-set-description
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}])
(defn make-token-set-schema
[tokens-lib set-id]
(sm/merge
ctob/schema:token-set-attrs
[:map
[:name [:and (make-token-set-name-schema tokens-lib set-id)
[:fn #(ctob/normalized-set-name? %)]]]
[:description {:optional true} schema:token-set-description]]))
;; Token theme
(defn make-token-theme-group-schema
"Generates a dynamic schema to check a token theme group:
- Validate group length.
- Checks if other token theme with the same name already exists in the new group in the tokens lib."
[tokens-lib name theme-id]
[:and
[:string {:min 0 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
[:fn {:error/fn #(tr "errors.token-theme-already-exists" (:value %))}
(fn [group]
(or (nil? tokens-lib)
(let [theme (ctob/get-theme-by-name tokens-lib group name)]
(or (nil? theme) (= (:id theme) theme-id)))))]])
(defn make-token-theme-name-schema
"Generates a dynamic schema to check a token theme name:
- Validate name length.
- Checks if other token theme with the same name already exists in the same group in the tokens lib."
[tokens-lib group theme-id]
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
[:fn {:error/fn #(tr "errors.token-theme-already-exists" (str group "/" (:value %)))}
(fn [name]
(or (nil? tokens-lib)
(let [theme (ctob/get-theme-by-name tokens-lib group name)]
(or (nil? theme) (= (:id theme) theme-id)))))]])
(def schema:token-theme-description
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}])
(defn make-token-theme-schema
[tokens-lib group name theme-id]
(sm/merge
ctob/schema:token-theme-attrs
[:map
[:group (make-token-theme-group-schema tokens-lib name theme-id)] ;; TODO how to keep error-fn from here?
[:name (make-token-theme-name-schema tokens-lib group theme-id)]
[:description {:optional true} schema:token-theme-description]]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def parseable-token-value-regexp
"Regexp that can be used to parse a number value out of resolved token value.
@@ -80,56 +300,6 @@
(defn shapes-applied-all? [ids-by-attributes shape-ids attributes]
(every? #(set/superset? (get ids-by-attributes %) shape-ids) attributes))
(defn token-name->path
"Splits token-name into a path vector split by `.` characters.
Will concatenate multiple `.` characters into one."
[token-name]
(str/split token-name #"\.+"))
(defn token-name->path-selector
"Splits token-name into map with `:path` and `:selector` using `token-name->path`.
`:selector` is the last item of the names path
`:path` is everything leading up the the `:selector`."
[token-name]
(let [path-segments (token-name->path token-name)
last-idx (dec (count path-segments))
[path [selector]] (split-at last-idx path-segments)]
{:path (seq path)
:selector selector}))
(defn token-name-path-exists?
"Traverses the path from `token-name` down a `token-tree` and checks if a token at that path exists.
It's not allowed to create a token inside a token. E.g.:
Creating a token with
{:name \"foo.bar\"}
in the tokens tree:
{\"foo\" {:name \"other\"}}"
[token-name token-names-tree]
(let [{:keys [path selector]} (token-name->path-selector token-name)
path-target (reduce
(fn [acc cur]
(let [target (get acc cur)]
(cond
;; Path segment doesn't exist yet
(nil? target) (reduced false)
;; A token exists at this path
(:name target) (reduced true)
;; Continue traversing the true
:else target)))
token-names-tree path)]
(cond
(boolean? path-target) path-target
(get path-target :name) true
:else (-> (get path-target selector)
(seq)
(boolean)))))
(defn color-token? [token]
(= (:type token) :color))

View File

@@ -0,0 +1,15 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.common.i18n
"Dummy i18n functions, to be used by code in common that needs translations.")
(defn tr
"This function will be monkeypatched at runtime with the real function in frontend i18n.
Here it just returns the key passed as argument. This way the result can be used in
unit tests or backend code for logs or error messages."
[key & _args]
key)

View File

@@ -58,7 +58,7 @@
(cto/shape-attr->token-attrs attr changed-sub-attr))]
(if (some #(contains? tokens %) token-attrs)
(pcb/update-shapes changes [shape-id] #(cto/unapply-token-id % token-attrs))
(pcb/update-shapes changes [shape-id] #(cto/unapply-tokens-from-shape % token-attrs))
changes)))
check-shape

View File

@@ -11,6 +11,7 @@
#?(:clj [malli.dev.pretty :as mdp])
#?(:clj [malli.dev.virhe :as v])
[app.common.data :as d]
[app.common.json :as json]
[app.common.math :as mth]
[app.common.pprint :as pp]
[app.common.schema.generators :as sg]
@@ -92,6 +93,31 @@
[& items]
(apply mu/merge (map schema items)))
(defn assoc-key
"Add a key & value to a schema of type [:map]. If the first level node of the schema
is not a map, will do a depth search to find the first map node and add the key there."
([s k v]
(assoc-key s k {} v))
([s k opts v] ;; change order of opts and v to match static schema defintions (e.g. [:something {:optional true} ::sm/integer])
(let [s (schema s)
v (schema v)]
(if (= (m/type s) :map)
(mu/assoc s k v opts)
(if-let [path (mu/find-first s (fn [s' path _] (when (= (m/type s') :map) path)))]
(mu/assoc-in s (conj path k) v opts)
s)))))
(defn dissoc-key
"Remove a key from a schema of type [:map]. If the first level node of the schema
is not a map, will do a depth search to find the first map node and remove the key there."
[s k]
(let [s (schema s)]
(if (= (m/type s) :map)
(mu/dissoc s k)
(if-let [path (mu/find-first s (fn [s' path _] (when (= (m/type s') :map) path)))]
(mu/update-in s path mu/dissoc k)
s))))
(defn ref?
[s]
(m/-ref-schema? s))
@@ -270,6 +296,13 @@
(let [explain (fn [] (me/with-error-messages explain))]
((mdp/prettifier variant message explain default-options)))))
(defn validation-errors
"Checks a value against a schema. If valid, returns nil. If not, returns a list
of english error messages."
[value schema]
(let [explainer (explainer schema)]
(-> value explainer simplify not-empty)))
(defmacro ignoring
[expr]
(if (:ns &env)
@@ -850,6 +883,32 @@
:encode/string str
::oapi/type "boolean"}})
(defn parse-keyword
[v]
(if (string? v)
(-> v (json/read-kebab-key) (keyword))
v))
(defn format-keyword
[v]
(if (keyword? v)
(-> v (name) (json/write-camel-key))
v))
(register!
{:type ::keyword
:pred keyword?
:type-properties
{:title "keyword"
:description "keyword"
:error/message "expected keyword"
:error/code "errors.invalid-keyword"
:gen/gen sg/keyword
:decode/string parse-keyword
:decode/json parse-keyword
:encode/string format-keyword
::oapi/type "string"}})
(register!
{:type ::contains-any
:min 1
@@ -1009,6 +1068,15 @@
{:title "agent"
:description "instance of clojure agent"}}))
#?(:clj
(register!
{:type ::bytes
:pred bytes?
:type-properties
{:title "bytes"
:description "bytes array"}}))
(register! ::any (mu/update-properties :any assoc :gen/gen sg/any))
;; ---- PREDICATES

View File

@@ -21,3 +21,10 @@
;; Only present on resolved profile objects, the resolve process
;; takes the photo-id or geneates an image from the name
[:photo-url {:optional true} :string]])
(def schema:basic-profile
[:map {:title "Basic profile"}
[:id ::sm/uuid]
[:name {:optional true} :string]
[:email {:optional true} :string]])

View File

@@ -407,17 +407,19 @@
(defn change-text
"Changes the content of the text shape to use the text as argument. Will use the styles of the
first paragraph and text that is present in the shape (and override the rest)"
[content text]
[content text & {:as styles}]
(let [root-styles (select-keys content root-attrs)
paragraph-style
(merge
default-text-attrs
styles
(select-keys (->> content (node-seq is-paragraph-node?) first) text-all-attrs))
text-style
(merge
default-text-attrs
styles
(select-keys (->> content (node-seq is-text-node?) first) text-all-attrs))
paragraph-texts

View File

@@ -9,13 +9,13 @@
[app.common.data :as d]
[app.common.schema :as sm]
[app.common.schema.generators :as sg]
[clojure.data :as data]
[app.common.time :as ct]
[clojure.set :as set]
[cuerdas.core :as str]
[malli.util :as mu]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS
;; GENERAL HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- schema-keys
@@ -45,7 +45,7 @@
[token-name token-value]
(let [token-references (find-token-value-references token-value)
self-reference? (get token-references token-name)]
self-reference?))
(boolean self-reference?)))
(defn references-token?
"Recursively check if a value references the token name. Handles strings, maps, and sequences."
@@ -59,14 +59,33 @@
(some true? (map #(references-token? % token-name) value))
:else false))
(defn composite-token-reference?
"Predicate if a composite token is a reference value - a string pointing to another token."
[token-value]
(string? token-value))
(defn update-token-value-references
"Recursively update token references within a token value, supporting complex token values (maps, sequences, strings)."
[value old-name new-name]
(cond
(string? value)
(str/replace value
(re-pattern (str "\\{" (str/replace old-name "." "\\.") "\\}"))
(str "{" new-name "}"))
(map? value)
(d/update-vals value #(update-token-value-references % old-name new-name))
(sequential? value)
(mapv #(update-token-value-references % old-name new-name) value)
:else
value))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SCHEMA
;; SCHEMA: Token types
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def token-type->dtcg-token-type
{:boolean "boolean"
:border-radius "borderRadius"
:shadow "shadow"
:color "color"
:dimensions "dimension"
:font-family "fontFamilies"
@@ -77,6 +96,7 @@
:opacity "opacity"
:other "other"
:rotation "rotation"
:shadow "shadow"
:sizing "sizing"
:spacing "spacing"
:string "string"
@@ -94,14 +114,13 @@
"boxShadow" :shadow)))
(def composite-token-type->dtcg-token-type
"Custom set of conversion keys for composite typography token with `:line-height` available.
(Penpot doesn't support `:line-height` token)"
"When converting the type of one element inside a composite token, an additional type
:line-height is available, that is not allowed for a standalone token."
(assoc token-type->dtcg-token-type
:line-height "lineHeights"))
(def composite-dtcg-token-type->token-type
"Custom set of conversion keys for composite typography token with `:line-height` available.
(Penpot doesn't support `:line-height` token)"
"Same as above, in the opposite direction."
(assoc dtcg-token-type->token-type
"lineHeights" :line-height
"lineHeight" :line-height))
@@ -109,96 +128,98 @@
(def token-types
(into #{} (keys token-type->dtcg-token-type)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SCHEMA: Token
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def token-name-validation-regex
#"^[a-zA-Z0-9_-][a-zA-Z0-9$_-]*(\.[a-zA-Z0-9$_-]+)*$")
(def token-name-ref
[:re {:title "TokenNameRef" :gen/gen sg/text}
(def schema:token-name
"A token name can contains letters, numbers, underscores the character $ and dots, but
not start with $ or end with a dot. The $ character does not have any special meaning,
but dots separate token groups (e.g. color.primary.background)."
[:re {:title "TokenName"
:gen/gen sg/text}
token-name-validation-regex])
(def ^:private schema:color
[:map
[:fill {:optional true} token-name-ref]
[:stroke-color {:optional true} token-name-ref]])
(def schema:token-type
[::sm/one-of {:decode/json (fn [type]
(if (string? type)
(dtcg-token-type->token-type type)
type))}
(def color-keys (schema-keys schema:color))
token-types])
(def schema:token-attrs
[:map {:title "Token"}
[:id ::sm/uuid]
[:name schema:token-name]
[:type schema:token-type]
[:value ::sm/any]
[:description {:optional true} :string]
[:modified-at {:optional true} ::ct/inst]])
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SCHEMA: Token application to shape
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; All the following schemas define the `:applied-tokens` attribute of a shape.
;; This attribute is a map <token-attribute> -> <token-name>.
;; Token attributes approximately match shape attributes, but not always.
;; For each schema there is a `*keys` set including all the possible token attributes
;; to which a token of the corresponding type can be applied.
;; Some token types can be applied to some attributes only if the shape has a
;; particular condition (i.e. has a layout itself or is a layout item).
(def ^:private schema:border-radius
[:map {:title "BorderRadiusTokenAttrs"}
[:r1 {:optional true} token-name-ref]
[:r2 {:optional true} token-name-ref]
[:r3 {:optional true} token-name-ref]
[:r4 {:optional true} token-name-ref]])
[:r1 {:optional true} schema:token-name]
[:r2 {:optional true} schema:token-name]
[:r3 {:optional true} schema:token-name]
[:r4 {:optional true} schema:token-name]])
(def border-radius-keys (schema-keys schema:border-radius))
(def ^:private schema:shadow
[:map {:title "ShadowTokenAttrs"}
[:shadow {:optional true} token-name-ref]])
(def shadow-keys (schema-keys schema:shadow))
(def ^:private schema:stroke-width
(def ^:private schema:color
[:map
[:stroke-width {:optional true} token-name-ref]])
[:fill {:optional true} schema:token-name]
[:stroke-color {:optional true} schema:token-name]])
(def stroke-width-keys (schema-keys schema:stroke-width))
(def color-keys (schema-keys schema:color))
(def ^:private schema:sizing-base
[:map {:title "SizingBaseTokenAttrs"}
[:width {:optional true} token-name-ref]
[:height {:optional true} token-name-ref]])
[:width {:optional true} schema:token-name]
[:height {:optional true} schema:token-name]])
(def ^:private schema:sizing-layout-item
[:map {:title "SizingLayoutItemTokenAttrs"}
[:layout-item-min-w {:optional true} token-name-ref]
[:layout-item-max-w {:optional true} token-name-ref]
[:layout-item-min-h {:optional true} token-name-ref]
[:layout-item-max-h {:optional true} token-name-ref]])
[:layout-item-min-w {:optional true} schema:token-name]
[:layout-item-max-w {:optional true} schema:token-name]
[:layout-item-min-h {:optional true} schema:token-name]
[:layout-item-max-h {:optional true} schema:token-name]])
(def sizing-layout-item-keys (schema-keys schema:sizing-layout-item))
(def ^:private schema:sizing
(-> (reduce mu/union [schema:sizing-base
schema:sizing-layout-item])
(mu/update-properties assoc :title "SizingTokenAttrs")))
(def sizing-layout-item-keys (schema-keys schema:sizing-layout-item))
(def sizing-keys (schema-keys schema:sizing))
(def ^:private schema:opacity
[:map {:title "OpacityTokenAttrs"}
[:opacity {:optional true} token-name-ref]])
(def opacity-keys (schema-keys schema:opacity))
(def ^:private schema:spacing-gap
[:map {:title "SpacingGapTokenAttrs"}
[:row-gap {:optional true} token-name-ref]
[:column-gap {:optional true} token-name-ref]])
[:row-gap {:optional true} schema:token-name]
[:column-gap {:optional true} schema:token-name]])
(def ^:private schema:spacing-padding
[:map {:title "SpacingPaddingTokenAttrs"}
[:p1 {:optional true} token-name-ref]
[:p2 {:optional true} token-name-ref]
[:p3 {:optional true} token-name-ref]
[:p4 {:optional true} token-name-ref]])
(def ^:private schema:spacing-margin
[:map {:title "SpacingMarginTokenAttrs"}
[:m1 {:optional true} token-name-ref]
[:m2 {:optional true} token-name-ref]
[:m3 {:optional true} token-name-ref]
[:m4 {:optional true} token-name-ref]])
(def ^:private schema:spacing
(-> (reduce mu/union [schema:spacing-gap
schema:spacing-padding
schema:spacing-margin])
(mu/update-properties assoc :title "SpacingTokenAttrs")))
(def spacing-margin-keys (schema-keys schema:spacing-margin))
(def spacing-keys (schema-keys schema:spacing))
[:p1 {:optional true} schema:token-name]
[:p2 {:optional true} schema:token-name]
[:p3 {:optional true} schema:token-name]
[:p4 {:optional true} schema:token-name]])
(def ^:private schema:spacing-gap-padding
(-> (reduce mu/union [schema:spacing-gap
@@ -207,6 +228,29 @@
(def spacing-gap-padding-keys (schema-keys schema:spacing-gap-padding))
(def ^:private schema:spacing-margin
[:map {:title "SpacingMarginTokenAttrs"}
[:m1 {:optional true} schema:token-name]
[:m2 {:optional true} schema:token-name]
[:m3 {:optional true} schema:token-name]
[:m4 {:optional true} schema:token-name]])
(def spacing-margin-keys (schema-keys schema:spacing-margin))
(def ^:private schema:spacing
(-> (reduce mu/union [schema:spacing-gap
schema:spacing-padding
schema:spacing-margin])
(mu/update-properties assoc :title "SpacingTokenAttrs")))
(def spacing-keys (schema-keys schema:spacing))
(def ^:private schema:stroke-width
[:map
[:stroke-width {:optional true} schema:token-name]])
(def stroke-width-keys (schema-keys schema:stroke-width))
(def ^:private schema:dimensions
(-> (reduce mu/union [schema:sizing
schema:spacing
@@ -216,91 +260,109 @@
(def dimensions-keys (schema-keys schema:dimensions))
(def ^:private schema:axis
[:map
[:x {:optional true} token-name-ref]
[:y {:optional true} token-name-ref]])
(def axis-keys (schema-keys schema:axis))
(def ^:private schema:rotation
[:map {:title "RotationTokenAttrs"}
[:rotation {:optional true} token-name-ref]])
(def rotation-keys (schema-keys schema:rotation))
(def ^:private schema:font-size
[:map {:title "FontSizeTokenAttrs"}
[:font-size {:optional true} token-name-ref]])
(def font-size-keys (schema-keys schema:font-size))
(def ^:private schema:letter-spacing
[:map {:title "LetterSpacingTokenAttrs"}
[:letter-spacing {:optional true} token-name-ref]])
(def letter-spacing-keys (schema-keys schema:letter-spacing))
(def ^:private schema:font-family
[:map
[:font-family {:optional true} token-name-ref]])
[:font-family {:optional true} schema:token-name]])
(def font-family-keys (schema-keys schema:font-family))
(def ^:private schema:text-case
[:map
[:text-case {:optional true} token-name-ref]])
(def ^:private schema:font-size
[:map {:title "FontSizeTokenAttrs"}
[:font-size {:optional true} schema:token-name]])
(def text-case-keys (schema-keys schema:text-case))
(def font-size-keys (schema-keys schema:font-size))
(def ^:private schema:font-weight
[:map
[:font-weight {:optional true} token-name-ref]])
[:font-weight {:optional true} schema:token-name]])
(def font-weight-keys (schema-keys schema:font-weight))
(def ^:private schema:typography
[:map
[:typography {:optional true} token-name-ref]])
(def ^:private schema:letter-spacing
[:map {:title "LetterSpacingTokenAttrs"}
[:letter-spacing {:optional true} schema:token-name]])
(def typography-token-keys (schema-keys schema:typography))
(def letter-spacing-keys (schema-keys schema:letter-spacing))
(def ^:private schema:text-decoration
[:map
[:text-decoration {:optional true} token-name-ref]])
(def ^:private schema:line-height ;; This is not available for standalone tokens, only typography
[:map {:title "LineHeightTokenAttrs"}
[:line-height {:optional true} schema:token-name]])
(def text-decoration-keys (schema-keys schema:text-decoration))
(def line-height-keys (schema-keys schema:line-height))
(def typography-keys (set/union font-size-keys
letter-spacing-keys
font-family-keys
font-weight-keys
text-case-keys
text-decoration-keys
font-weight-keys
typography-token-keys
#{:line-height}))
(def ^:private schema:rotation
[:map {:title "RotationTokenAttrs"}
[:rotation {:optional true} schema:token-name]])
(def rotation-keys (schema-keys schema:rotation))
(def ^:private schema:number
(-> (reduce mu/union [[:map [:line-height {:optional true} token-name-ref]]
(-> (reduce mu/union [schema:line-height
schema:rotation])
(mu/update-properties assoc :title "NumberTokenAttrs")))
(def number-keys (schema-keys schema:number))
(def all-keys (set/union color-keys
(def ^:private schema:opacity
[:map {:title "OpacityTokenAttrs"}
[:opacity {:optional true} schema:token-name]])
(def opacity-keys (schema-keys schema:opacity))
(def ^:private schema:shadow
[:map {:title "ShadowTokenAttrs"}
[:shadow {:optional true} schema:token-name]])
(def shadow-keys (schema-keys schema:shadow))
(def ^:private schema:text-case
[:map
[:text-case {:optional true} schema:token-name]])
(def text-case-keys (schema-keys schema:text-case))
(def ^:private schema:text-decoration
[:map
[:text-decoration {:optional true} schema:token-name]])
(def text-decoration-keys (schema-keys schema:text-decoration))
(def ^:private schema:typography
[:map
[:typography {:optional true} schema:token-name]])
(def typography-token-keys (schema-keys schema:typography))
(def typography-keys (set/union font-family-keys
font-size-keys
font-weight-keys
font-weight-keys
letter-spacing-keys
line-height-keys
text-case-keys
text-decoration-keys
typography-token-keys))
(def ^:private schema:axis
[:map
[:x {:optional true} schema:token-name]
[:y {:optional true} schema:token-name]])
(def axis-keys (schema-keys schema:axis))
(def all-keys (set/union axis-keys
border-radius-keys
shadow-keys
stroke-width-keys
sizing-keys
opacity-keys
spacing-keys
color-keys
dimensions-keys
axis-keys
number-keys
opacity-keys
rotation-keys
shadow-keys
sizing-keys
spacing-keys
stroke-width-keys
typography-keys
typography-token-keys
number-keys))
typography-token-keys))
(def ^:private schema:tokens
[:map {:title "GenericTokenAttrs"}])
@@ -321,11 +383,28 @@
schema:text-decoration
schema:dimensions])
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS for conversion between token attrs and shape attrs
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn token-attr?
[attr]
(contains? all-keys attr))
(defn token-attr->shape-attr
"Returns the actual shape attribute affected when a token have been applied
to a given `token-attr`."
[token-attr]
(case token-attr
:fill :fills
:stroke-color :strokes
:stroke-width :strokes
token-attr))
(defn shape-attr->token-attrs
"Returns the token-attr affected when a given attribute in a shape is changed.
The sub-attr is for attributes that may have multiple values, like strokes
(may be width or color) and layout padding & margin (may have 4 edges)."
([shape-attr] (shape-attr->token-attrs shape-attr nil))
([shape-attr changed-sub-attr]
(cond
@@ -367,21 +446,13 @@
(number-keys shape-attr) #{shape-attr}
(axis-keys shape-attr) #{shape-attr})))
(defn token-attr->shape-attr
[token-attr]
(case token-attr
:fill :fills
:stroke-color :strokes
:stroke-width :strokes
token-attr))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TOKEN SHAPE ATTRIBUTES
;; HELPERS for token attributes by shape type
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def position-attributes #{:x :y})
(def ^:private position-attributes #{:x :y})
(def generic-attributes
(def ^:private generic-attributes
(set/union color-keys
stroke-width-keys
rotation-keys
@@ -390,20 +461,22 @@
shadow-keys
position-attributes))
(def rect-attributes
(def ^:private rect-attributes
(set/union generic-attributes
border-radius-keys))
(def frame-with-layout-attributes
(def ^:private frame-with-layout-attributes
(set/union rect-attributes
spacing-gap-padding-keys))
(def text-attributes
(def ^:private text-attributes
(set/union generic-attributes
typography-keys
number-keys))
(defn shape-type->attributes
"Returns what token attributes may be applied to a shape depending on its type
and if it is a frame with a layout."
[type is-layout]
(case type
:bool generic-attributes
@@ -419,12 +492,14 @@
nil))
(defn appliable-attrs-for-shape
"Returns intersection of shape `attributes` for `shape-type`."
"Returns which ones of the given `attributes` can be applied to a shape
of type `shape-type` and `is-layout`."
[attributes shape-type is-layout]
(set/intersection attributes (shape-type->attributes shape-type is-layout)))
(defn any-appliable-attr-for-shape?
"Checks if `token-type` supports given shape `attributes`."
"Returns if any of the given `attributes` can be applied to a shape
of type `shape-type` and `is-layout`."
[attributes token-type is-layout]
(d/not-empty? (appliable-attrs-for-shape attributes token-type is-layout)))
@@ -435,42 +510,6 @@
typography-keys
#{:fill}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TOKENS IN SHAPES
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- toggle-or-apply-token
"Remove any shape attributes from token if they exists.
Othewise apply token attributes."
[shape token]
(let [[shape-leftover token-leftover _matching] (data/diff (:applied-tokens shape) token)]
(merge {} shape-leftover token-leftover)))
(defn- token-from-attributes [token attributes]
(->> (map (fn [attr] [attr (:name token)]) attributes)
(into {})))
(defn- apply-token-to-attributes [{:keys [shape token attributes]}]
(let [token (token-from-attributes token attributes)]
(toggle-or-apply-token shape token)))
(defn apply-token-to-shape
[{:keys [shape token attributes] :as _props}]
(let [applied-tokens (apply-token-to-attributes {:shape shape
:token token
:attributes attributes})]
(update shape :applied-tokens #(merge % applied-tokens))))
(defn unapply-token-id [shape attributes]
(update shape :applied-tokens d/without-keys attributes))
(defn unapply-layout-item-tokens
"Unapplies all layout item related tokens from shape."
[shape]
(let [layout-item-attrs (set/union sizing-layout-item-keys
spacing-margin-keys)]
(unapply-token-id shape layout-item-attrs)))
(def tokens-by-input
"A map from input name to applicable token for that input."
{:width #{:sizing :dimensions}
@@ -500,7 +539,33 @@
:stroke-color #{:color}})
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TYPOGRAPHY
;; HELPERS for tokens application
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- generate-attr-map [token attributes]
(->> (map (fn [attr] [attr (:name token)]) attributes)
(into {})))
(defn apply-token-to-shape
"Applies the token to the given attributes in the shape."
[{:keys [shape token attributes] :as _props}]
(let [map-to-apply (generate-attr-map token attributes)]
(update shape :applied-tokens #(merge % map-to-apply))))
(defn unapply-tokens-from-shape
"Removes any token applied to the given attributes in the shape."
[shape attributes]
(update shape :applied-tokens d/without-keys attributes))
(defn unapply-layout-item-tokens
"Unapplies all layout item related tokens from shape."
[shape]
(let [layout-item-attrs (set/union sizing-layout-item-keys
spacing-margin-keys)]
(unapply-tokens-from-shape shape layout-item-attrs)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS for typography tokens
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn split-font-family
@@ -563,32 +628,3 @@
(when (font-weight-values weight)
(cond-> {:weight weight}
italic? (assoc :style "italic")))))
(defn typography-composite-token-reference?
"Predicate if a typography composite token is a reference value - a string pointing to another reference token."
[token-value]
(string? token-value))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SHADOW
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn shadow-composite-token-reference?
"Predicate if a shadow composite token is a reference value - a string pointing to another reference token."
[token-value]
(string? token-value))
(defn update-token-value-references
"Recursively update token references within a token value, supporting complex token values (maps, sequences, strings)."
[value old-name new-name]
(cond
(string? value)
(str/replace value
(re-pattern (str "\\{" (str/replace old-name "." "\\.") "\\}"))
(str "{" new-name "}"))
(map? value)
(d/update-vals value #(update-token-value-references % old-name new-name))
(sequential? value)
(mapv #(update-token-value-references % old-name new-name) value)
:else
value))

View File

@@ -114,25 +114,19 @@
[o]
(instance? Token o))
(def schema:token-attrs
[:map {:title "Token"}
[:id ::sm/uuid]
[:name cto/token-name-ref]
[:type [::sm/one-of cto/token-types]]
[:value ::sm/any]
[:description {:optional true} :string]
[:modified-at {:optional true} ::ct/inst]])
(declare make-token)
(def schema:token
[:and {:gen/gen (->> (sg/generator schema:token-attrs)
[:and {:gen/gen (->> (sg/generator cto/schema:token-attrs)
(sg/fmap #(make-token %)))}
(sm/required-keys schema:token-attrs)
(sm/required-keys cto/schema:token-attrs)
[:fn token?]])
(def ^:private check-token-attrs
(sm/check-fn schema:token-attrs :hint "expected valid params for token"))
(sm/check-fn cto/schema:token-attrs :hint "expected valid params for token"))
(def decode-token-attrs
(sm/lazy-decoder cto/schema:token-attrs sm/json-transformer))
(def check-token
(sm/check-fn schema:token :hint "expected valid token"))
@@ -317,10 +311,18 @@
[o]
(instance? TokenSetLegacy o))
(declare make-token-set)
(declare normalized-set-name?)
(def schema:token-set-name
[:and
:string
[:fn #(normalized-set-name? %)]]) ;; The #() is necessary because the function is only declared, not defined
(def schema:token-set-attrs
[:map {:title "TokenSet"}
[:id ::sm/uuid]
[:name :string]
[:name schema:token-set-name]
[:description {:optional true} :string]
[:modified-at {:optional true} ::ct/inst]
[:tokens {:optional true
@@ -342,8 +344,6 @@
:string schema:token]
[:fn d/ordered-map?]]]])
(declare make-token-set)
(def schema:token-set
[:schema {:gen/gen (->> (sg/generator schema:token-set-attrs)
(sg/fmap #(make-token-set %)))}
@@ -404,12 +404,25 @@
(split-set-name name))
(cpn/join-path :separator set-separator :with-spaces? false))))
(defn normalized-set-name?
"Check if a set name is normalized (no extra spaces)."
[name]
(= name (normalize-set-name name)))
(defn replace-last-path-name
"Replaces the last element in a `path` vector with `name`."
[path name]
(-> (into [] (drop-last path))
(conj name)))
(defn make-child-name
"Generate the name of a set child of `parent-set` adding the name `name`."
[parent-set name]
(if-let [parent-path (get-set-path parent-set)]
(->> (concat parent-path (split-set-name name))
(join-set-path))
(normalize-set-name name)))
;; The following functions will be removed after refactoring the internal structure of TokensLib,
;; since we'll no longer need group prefixes to differentiate between sets and set-groups.
@@ -1370,10 +1383,13 @@ Will return a value that matches this schema:
(def ^:private check-tokens-lib-map
(sm/check-fn schema:tokens-lib-map :hint "invalid tokens-lib internal data structure"))
(defn tokens-lib?
[o]
(instance? TokensLib o))
(defn valid-tokens-lib?
[o]
(and (instance? TokensLib o)
(valid? o)))
(and (tokens-lib? o) (valid? o)))
(defn- ensure-hidden-theme
"A helper that is responsible to ensure that the hidden theme always
@@ -1435,6 +1451,50 @@ Will return a value that matches this schema:
(rename copy-name)
(reid (uuid/next))))))
(defn- token-name->path-selector
"Splits token-name into map with `:path` and `:selector` using `token-name->path`.
`:selector` is the last item of the names path
`:path` is everything leading up the the `:selector`."
[token-name]
(let [path-segments (get-token-path {:name token-name})
last-idx (dec (count path-segments))
[path [selector]] (split-at last-idx path-segments)]
{:path (seq path)
:selector selector}))
(defn token-name-path-exists?
"Traverses the path from `token-name` down a `tokens-tree` and checks if a token at that path exists.
It's not allowed to create a token inside a token. E.g.:
Creating a token with
{:name \"foo.bar\"}
in the tokens tree:
{\"foo\" {:name \"other\"}}"
[token-name tokens-tree]
(let [{:keys [path selector]} (token-name->path-selector token-name)
path-target (reduce
(fn [acc cur]
(let [target (get acc cur)]
(cond
;; Path segment doesn't exist yet
(nil? target) (reduced false)
;; A token exists at this path
(:name target) (reduced true)
;; Continue traversing the true
:else target)))
tokens-tree
path)]
(cond
(boolean? path-target) path-target
(get path-target :name) true
:else (-> (get path-target selector)
(seq)
(boolean)))))
;; === Import / Export from JSON format
;; Supported formats:

View File

@@ -6,34 +6,34 @@
(ns common-tests.files.tokens-test
(:require
[app.common.files.tokens :as cft]
[app.common.files.tokens :as cfo]
[clojure.test :as t]))
(t/deftest test-parse-token-value
(t/testing "parses double from a token value"
(t/is (= {:value 100.1 :unit nil} (cft/parse-token-value "100.1")))
(t/is (= {:value -9.0 :unit nil} (cft/parse-token-value "-9"))))
(t/is (= {:value 100.1 :unit nil} (cfo/parse-token-value "100.1")))
(t/is (= {:value -9.0 :unit nil} (cfo/parse-token-value "-9"))))
(t/testing "trims white-space"
(t/is (= {:value -1.3 :unit nil} (cft/parse-token-value " -1.3 "))))
(t/is (= {:value -1.3 :unit nil} (cfo/parse-token-value " -1.3 "))))
(t/testing "parses unit: px"
(t/is (= {:value 70.3 :unit "px"} (cft/parse-token-value " 70.3px "))))
(t/is (= {:value 70.3 :unit "px"} (cfo/parse-token-value " 70.3px "))))
(t/testing "parses unit: %"
(t/is (= {:value -10.0 :unit "%"} (cft/parse-token-value "-10%"))))
(t/is (= {:value -10.0 :unit "%"} (cfo/parse-token-value "-10%"))))
(t/testing "parses unit: px")
(t/testing "returns nil for any invalid characters"
(t/is (nil? (cft/parse-token-value " -1.3a "))))
(t/is (nil? (cfo/parse-token-value " -1.3a "))))
(t/testing "doesnt accept invalid double"
(t/is (nil? (cft/parse-token-value ".3")))))
(t/is (nil? (cfo/parse-token-value ".3")))))
(t/deftest token-applied-test
(t/testing "matches passed token with `:token-attributes`"
(t/is (true? (cft/token-applied? {:name "a"} {:applied-tokens {:x "a"}} #{:x}))))
(t/is (true? (cfo/token-applied? {:name "a"} {:applied-tokens {:x "a"}} #{:x}))))
(t/testing "doesn't match empty token"
(t/is (nil? (cft/token-applied? {} {:applied-tokens {:x "a"}} #{:x}))))
(t/is (nil? (cfo/token-applied? {} {:applied-tokens {:x "a"}} #{:x}))))
(t/testing "does't match passed token `:id`"
(t/is (nil? (cft/token-applied? {:name "b"} {:applied-tokens {:x "a"}} #{:x}))))
(t/is (nil? (cfo/token-applied? {:name "b"} {:applied-tokens {:x "a"}} #{:x}))))
(t/testing "doesn't match passed `:token-attributes`"
(t/is (nil? (cft/token-applied? {:name "a"} {:applied-tokens {:x "a"}} #{:y})))))
(t/is (nil? (cfo/token-applied? {:name "a"} {:applied-tokens {:x "a"}} #{:y})))))
(t/deftest shapes-ids-by-applied-attributes
(t/testing "Returns set of matched attributes that fit the applied token"
@@ -54,7 +54,7 @@
shape-applied-x-y
shape-applied-all
shape-applied-none]
expected (cft/shapes-ids-by-applied-attributes {:name "1"} shapes attributes)]
expected (cfo/shapes-ids-by-applied-attributes {:name "1"} shapes attributes)]
(t/is (= (:x expected) (shape-ids shape-applied-x
shape-applied-x-y
shape-applied-all)))
@@ -62,34 +62,21 @@
shape-applied-x-y
shape-applied-all)))
(t/is (= (:z expected) (shape-ids shape-applied-all)))
(t/is (true? (cft/shapes-applied-all? expected (shape-ids shape-applied-all) attributes)))
(t/is (false? (cft/shapes-applied-all? expected (apply shape-ids shapes) attributes)))
(t/is (true? (cfo/shapes-applied-all? expected (shape-ids shape-applied-all) attributes)))
(t/is (false? (cfo/shapes-applied-all? expected (apply shape-ids shapes) attributes)))
(shape-ids shape-applied-x
shape-applied-x-y
shape-applied-all))))
(t/deftest tokens-applied-test
(t/testing "is true when single shape matches the token and attributes"
(t/is (true? (cft/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "a"}}
(t/is (true? (cfo/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "a"}}
{:applied-tokens {:x "b"}}]
#{:x}))))
(t/testing "is false when no shape matches the token or attributes"
(t/is (nil? (cft/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "b"}}
(t/is (nil? (cfo/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "b"}}
{:applied-tokens {:x "b"}}]
#{:x})))
(t/is (nil? (cft/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "a"}}
(t/is (nil? (cfo/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "a"}}
{:applied-tokens {:x "a"}}]
#{:y})))))
(t/deftest name->path-test
(t/is (= ["foo" "bar" "baz"] (cft/token-name->path "foo.bar.baz")))
(t/is (= ["foo" "bar" "baz"] (cft/token-name->path "foo..bar.baz")))
(t/is (= ["foo" "bar" "baz"] (cft/token-name->path "foo..bar.baz...."))))
(t/deftest token-name-path-exists?-test
(t/is (true? (cft/token-name-path-exists? "border-radius" {"border-radius" {"sm" {:name "sm"}}})))
(t/is (true? (cft/token-name-path-exists? "border-radius" {"border-radius" {:name "sm"}})))
(t/is (true? (cft/token-name-path-exists? "border-radius.sm" {"border-radius" {:name "sm"}})))
(t/is (true? (cft/token-name-path-exists? "border-radius.sm.x" {"border-radius" {:name "sm"}})))
(t/is (false? (cft/token-name-path-exists? "other" {"border-radius" {:name "sm"}})))
(t/is (false? (cft/token-name-path-exists? "dark.border-radius.md" {"dark" {"border-radius" {"sm" {:name "sm"}}}}))))

View File

@@ -255,28 +255,28 @@
(cls/generate-update-shapes [(:id frame1)]
(fn [shape]
(-> shape
(cto/unapply-token-id [:r1 :r2 :r3 :r4])
(cto/unapply-token-id [:rotation])
(cto/unapply-token-id [:opacity])
(cto/unapply-token-id [:stroke-width])
(cto/unapply-token-id [:stroke-color])
(cto/unapply-token-id [:fill])
(cto/unapply-token-id [:width :height])))
(cto/unapply-tokens-from-shape [:r1 :r2 :r3 :r4])
(cto/unapply-tokens-from-shape [:rotation])
(cto/unapply-tokens-from-shape [:opacity])
(cto/unapply-tokens-from-shape [:stroke-width])
(cto/unapply-tokens-from-shape [:stroke-color])
(cto/unapply-tokens-from-shape [:fill])
(cto/unapply-tokens-from-shape [:width :height])))
(:objects page)
{})
(cls/generate-update-shapes [(:id text1)]
(fn [shape]
(-> shape
(cto/unapply-token-id [:font-size])
(cto/unapply-token-id [:letter-spacing])
(cto/unapply-token-id [:font-family])))
(cto/unapply-tokens-from-shape [:font-size])
(cto/unapply-tokens-from-shape [:letter-spacing])
(cto/unapply-tokens-from-shape [:font-family])))
(:objects page)
{})
(cls/generate-update-shapes [(:id circle1)]
(fn [shape]
(-> shape
(cto/unapply-token-id [:layout-item-max-h :layout-item-min-h :layout-item-max-w :layout-item-min-w])
(cto/unapply-token-id [:m1 :m2 :m3 :m4])))
(cto/unapply-tokens-from-shape [:layout-item-max-h :layout-item-min-h :layout-item-max-w :layout-item-min-w])
(cto/unapply-tokens-from-shape [:m1 :m2 :m3 :m4])))
(:objects page)
{}))

View File

@@ -8,20 +8,19 @@
(:require
[app.common.schema :as sm]
[app.common.types.token :as cto]
[app.common.uuid :as uuid]
[clojure.test :as t]))
(t/deftest test-valid-token-name-schema
;; Allow regular namespace token names
(t/is (true? (sm/validate cto/token-name-ref "Foo")))
(t/is (true? (sm/validate cto/token-name-ref "foo")))
(t/is (true? (sm/validate cto/token-name-ref "FOO")))
(t/is (true? (sm/validate cto/token-name-ref "Foo.Bar.Baz")))
(t/is (true? (sm/validate cto/schema:token-name "Foo")))
(t/is (true? (sm/validate cto/schema:token-name "foo")))
(t/is (true? (sm/validate cto/schema:token-name "FOO")))
(t/is (true? (sm/validate cto/schema:token-name "Foo.Bar.Baz")))
;; Disallow trailing tokens
(t/is (false? (sm/validate cto/token-name-ref "Foo.Bar.Baz....")))
(t/is (false? (sm/validate cto/schema:token-name "Foo.Bar.Baz....")))
;; Disallow multiple separator dots
(t/is (false? (sm/validate cto/token-name-ref "Foo..Bar.Baz")))
(t/is (false? (sm/validate cto/schema:token-name "Foo..Bar.Baz")))
;; Disallow any special characters
(t/is (false? (sm/validate cto/token-name-ref "Hey Foo.Bar")))
(t/is (false? (sm/validate cto/token-name-ref "Hey😈Foo.Bar")))
(t/is (false? (sm/validate cto/token-name-ref "Hey%Foo.Bar"))))
(t/is (false? (sm/validate cto/schema:token-name "Hey Foo.Bar")))
(t/is (false? (sm/validate cto/schema:token-name "Hey😈Foo.Bar")))
(t/is (false? (sm/validate cto/schema:token-name "Hey%Foo.Bar"))))

View File

@@ -678,35 +678,35 @@
(t/deftest list-active-themes-tokens-bug-taiga-10617
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "Mode / Dark"
(ctob/add-set (ctob/make-token-set :name "Mode/Dark"
:tokens {"red"
(ctob/make-token :name "red"
:type :color
:value "#700000")}))
(ctob/add-set (ctob/make-token-set :name "Mode / Light"
(ctob/add-set (ctob/make-token-set :name "Mode/Light"
:tokens {"red"
(ctob/make-token :name "red"
:type :color
:value "#ff0000")}))
(ctob/add-set (ctob/make-token-set :name "Device / Desktop"
(ctob/add-set (ctob/make-token-set :name "Device/Desktop"
:tokens {"border1"
(ctob/make-token :name "border1"
:type :border-radius
:value 30)}))
(ctob/add-set (ctob/make-token-set :name "Device / Mobile"
(ctob/add-set (ctob/make-token-set :name "Device/Mobile"
:tokens {"border1"
(ctob/make-token :name "border1"
:type :border-radius
:value 50)}))
(ctob/add-theme (ctob/make-token-theme :group "App"
:name "Mobile"
:sets #{"Mode / Dark" "Device / Mobile"}))
:sets #{"Mode/Dark" "Device/Mobile"}))
(ctob/add-theme (ctob/make-token-theme :group "App"
:name "Web"
:sets #{"Mode / Dark" "Mode / Light" "Device / Desktop"}))
:sets #{"Mode/Dark" "Mode/Light" "Device/Desktop"}))
(ctob/add-theme (ctob/make-token-theme :group "Brand"
:name "Brand A"
:sets #{"Mode / Dark" "Mode / Light" "Device / Desktop" "Device / Mobile"}))
:sets #{"Mode/Dark" "Mode/Light" "Device/Desktop" "Device/Mobile"}))
(ctob/add-theme (ctob/make-token-theme :group "Brand"
:name "Brand B"
:sets #{}))
@@ -2013,3 +2013,11 @@
(t/is (some? imported-ref))
(t/is (= (:type original-ref) (:type imported-ref)))
(t/is (= (:value imported-ref) (:value original-ref))))))))
(t/deftest token-name-path-exists?-test
(t/is (true? (ctob/token-name-path-exists? "border-radius" {"border-radius" {"sm" {:name "sm"}}})))
(t/is (true? (ctob/token-name-path-exists? "border-radius" {"border-radius" {:name "sm"}})))
(t/is (true? (ctob/token-name-path-exists? "border-radius.sm" {"border-radius" {:name "sm"}})))
(t/is (true? (ctob/token-name-path-exists? "border-radius.sm.x" {"border-radius" {:name "sm"}})))
(t/is (false? (ctob/token-name-path-exists? "other" {"border-radius" {:name "sm"}})))
(t/is (false? (ctob/token-name-path-exists? "dark.border-radius.md" {"dark" {"border-radius" {"sm" {:name "sm"}}}}))))

View File

@@ -51,6 +51,11 @@ services:
- 4401:4401
- 4402:4402
# Plugins
- 4200:4200
- 4201:4201
- 4202:4202
environment:
- EXTERNAL_UID=${CURRENT_USER_ID}
# SMTP setup

View File

@@ -121,7 +121,7 @@ http {
proxy_http_version 1.1;
}
location /mcp {
location /plugins/mcp {
alias /home/penpot/penpot/mcp/packages/plugin/dist;
proxy_http_version 1.1;
}
@@ -133,6 +133,11 @@ http {
proxy_http_version 1.1;
}
location /management {
proxy_pass http://127.0.0.1:6060/management;
proxy_http_version 1.1;
}
location /mcp/stream {
proxy_pass http://127.0.0.1:4401/mcp;
proxy_http_version 1.1;

View File

@@ -24,6 +24,7 @@ RUN set -e; \
libltdl-dev \
liblzma-dev \
libopenexr-dev \
libxml2-dev \
libpng-dev \
librsvg2-dev \
libtiff-dev \
@@ -52,6 +53,7 @@ RUN set -e; \
libfftw3-dev \
libheif-dev \
libjpeg-dev \
libxml2-dev \
liblcms2-dev \
libltdl-dev \
liblzma-dev \
@@ -77,6 +79,7 @@ RUN set -e; \
libopenjp2-7 \
libpng16-16 \
librsvg2-2 \
libxml2 \
libtiff6 \
libwebp7 \
libwebpdemux2 \

View File

@@ -125,7 +125,7 @@ RUN set -ex; \
COPY --from=build /opt/jre /opt/jre
COPY --from=build /opt/node /opt/node
COPY --from=penpotapp/imagemagick:7.1.2-0 /opt/imagick /opt/imagick
COPY --from=penpotapp/imagemagick:7.1.2-13 /opt/imagick /opt/imagick
ARG BUNDLE_PATH="./bundle-backend/"
COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/backend/

View File

@@ -107,7 +107,7 @@ RUN set -eux; \
ARG BUNDLE_PATH="./bundle-exporter/"
COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/exporter/
COPY --from=penpotapp/imagemagick:7.1.2-0 /opt/imagick /opt/imagick
COPY --from=penpotapp/imagemagick:7.1.2-13 /opt/imagick /opt/imagick
WORKDIR /opt/penpot/exporter
USER penpot:penpot

View File

@@ -198,13 +198,6 @@ services:
## Valkey (or previously Redis) is used for the websockets notifications.
PENPOT_REDIS_URI: redis://penpot-valkey/0
penpot-mcp:
image: penpotapp/mcp:${PENPOT_VERSION:-latest}
restart: always
networks:
- penpot
penpot-postgres:
image: "postgres:15"
restart: always

View File

@@ -1 +1 @@
resolver $PENPOT_INTERNAL_RESOLVER ipv6=off valid=10s;
resolver $PENPOT_INTERNAL_RESOLVER valid=10s;

View File

@@ -73,6 +73,7 @@ http {
server {
listen 8080 default_server;
listen [::]:8080 default_server;
server_name _;
client_max_body_size $PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE;
@@ -129,6 +130,11 @@ http {
proxy_buffering off;
}
location /plugins {
alias /var/www/app/plugins;
proxy_http_version 1.1;
}
location /readyz {
access_log off;
proxy_pass $PENPOT_BACKEND_URI$request_uri;

View File

@@ -8,9 +8,7 @@ desc: Customize your Penpot instance today. Learn how to install with Elestio, D
This guide explains how to get your own Penpot instance, running on a machine you control,
to test it, use it by you or your team, or even customize and extend it any way you like.
If you need more context you can look at the <a
href="https://community.penpot.app/t/self-hosting-penpot-i/2336" target="_blank">post
about self-hosting</a> in Penpot community.
For additional context, see the post <a href="https://penpot.app/blog/how-to-self-host-penpot/" target="_blank">How to self-host Penpot: A technical implementation guide</a> on the Penpot blog.
<strong>The experience stays the same, whether you use
Penpot <a href="https://design.penpot.app" target="_blank">in the cloud</a>

View File

@@ -14,7 +14,7 @@ Keep in mind that database size doesn't grow strictly proportionally with user c
# About Valkey / Redis requirements
"Valkey is mainly used for coordinating websocket notifications and, since Penpot 2.11, as a cache. Therefore, disk storage will not be necessary as it will use the instance's RAM.
Valkey is mainly used for coordinating websocket notifications and, since Penpot 2.11, as a cache. Therefore, disk storage will not be necessary as it will use the instance's RAM.
To prevent the cache from hogging all the system's RAM usage, it is recommended to use two configuration parameters which, both in the docker-compose.yaml provided by Penpot and in the official Helm Chart, come with default parameters that should be sufficient for most deployments:

View File

@@ -38,6 +38,24 @@
(assoc :path "/render.html")
(assoc :query (u/map->query-string params)))))
(sync-page-size! [dom]
(bw/eval! dom
(fn [elem]
;; IMPORTANT: No CLJS runtime allowed. Use only JS
;; primitives. This runs in a context without access to
;; cljs.core. Avoid any functions that transpile to
;; cljs.core/* calls, as they will break in the browser
;; runtime.
(let [width (.getAttribute ^js elem "width")
height (.getAttribute ^js elem "height")
style-node (let [node (.createElement js/document "style")]
(.appendChild (.-head js/document) node)
node)]
(set! (.-textContent style-node)
(dm/str "@page { size: " width "px " height "px; margin: 0; }\n"
"html, body, #app { margin: 0; padding: 0; width: " width "px; height: " height "px; overflow: visible; }"))))))
(render-object [page base-uri {:keys [id] :as object}]
(p/let [uri (prepare-uri base-uri id)
path (sh/tempfile :prefix "penpot.tmp.pdf." :suffix (mime/get-extension type))]
@@ -45,6 +63,7 @@
(bw/nav! page uri)
(p/let [dom (bw/select page (dm/str "#screenshot-" id))]
(bw/wait-for dom)
(sync-page-size! dom)
(bw/screenshot dom {:full-page? true})
(bw/sleep page 2000) ; the good old fix with sleep
(bw/pdf page {:path path})

View File

@@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC",
"private": true,
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
"packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8",
"browserslist": [
"defaults"
],
@@ -47,10 +47,11 @@
"devDependencies": {
"@penpot/draft-js": "workspace:./packages/draft-js",
"@penpot/mousetrap": "workspace:./packages/mousetrap",
"@penpot/tokenscript": "workspace:./packages/tokenscript",
"@penpot/plugins-runtime": "1.4.2",
"@penpot/svgo": "penpot/svgo#v3.2",
"@penpot/text-editor": "workspace:./text-editor",
"@penpot/tokenscript": "workspace:./packages/tokenscript",
"@penpot/ui": "workspace:./packages/ui",
"@playwright/test": "1.58.0",
"@storybook/addon-docs": "10.1.11",
"@storybook/addon-themes": "10.1.11",
@@ -102,6 +103,7 @@
"sass": "^1.89.0",
"sass-embedded": "^1.89.0",
"sax": "^1.4.1",
"scheduler": "^0.27.0",
"source-map-support": "^0.5.21",
"storybook": "10.1.11",
"style-dictionary": "5.0.0-rc.1",

View File

@@ -8,6 +8,6 @@
"author": "Andrey Antukh",
"license": "MPL-2.0",
"dependencies": {
"@tokens-studio/tokenscript-interpreter": "^0.23.1"
"@tokens-studio/tokenscript-interpreter": "^0.26.0"
}
}

View File

@@ -1,12 +1,12 @@
// Auto-generated by @tokens-studio/tokenscript-schemas
// Version: @tokens-studio/tokenscript-schemas@v0.1.2
// Version: @tokens-studio/tokenscript-schemas@v0.4.0
// GitHub: https://github.com/tokens-studio/tokenscript-schemas
// Command: npx @tokens-studio/tokenscript-schemas bundle preset:css --output ./tokenscript-schemas.js
// Generated: 2026-01-07T09:21:11.478Z
// Command: npx @tokens-studio/tokenscript-schemas bundle preset:css preset:cssColors --output ./schemas.js
// Generated: 2026-02-11T08:46:40.467Z
import { Config } from "@tokens-studio/tokenscript-interpreter";
const SCHEMAS = [
export const SCHEMAS = [
{
uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hex-color/0/",
schema: {
@@ -31,7 +31,127 @@ const SCHEMAS = [
}
}
],
"conversions": []
"conversions": [
{
"source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/",
"target": "$self",
"description": "Converts sRGB (0-1) to Hex format",
"lossless": true,
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// sRGB to Hex Conversion\n// Converts sRGB (0-1) to hexadecimal string format\n//\n// Examples:\n// sRGB(1, 0, 0) → #ff0000\n// sRGB(0, 1, 0.5) → #00ff80\n\nvariable hex: String = \"#\";\nvariable value: Number = 0;\n\n// Red channel\nvalue = round({input}.r * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Green channel\nvalue = round({input}.g * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Blue channel\nvalue = round({input}.b * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\nreturn hex;"
}
},
{
"source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-color/0/",
"target": "$self",
"description": "Converts Display P3 to Hex format (clamps to sRGB gamut)",
"lossless": false,
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// Display P3 to Hex Conversion\n// Converts P3 (0-1) to hexadecimal string format\n// Note: P3 colors may be out of sRGB gamut, values are clamped to 0-1\n//\n// Examples:\n// P3(1, 0, 0) → #ff0000\n// P3(0, 1, 0.5) → #00ff80\n\nvariable hex: String = \"#\";\nvariable value: Number = 0;\n\n// Red channel (clamp P3 to sRGB range)\nvalue = {input}.r;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Green channel\nvalue = {input}.g;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Blue channel\nvalue = {input}.b;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\nreturn hex;"
}
},
{
"source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hsl-color/0/",
"target": "$self",
"description": "Converts HSL to Hex format",
"lossless": true,
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// HSL to Hex Conversion\n// Converts HSL to hexadecimal string format\n// Reference: Standard HSL to RGB algorithm\n//\n// Input: Color.HSL with h (0-360), s (0-1), l (0-1)\n// Output: Hex string #rrggbb\n\n// Get input HSL values\nvariable h: Number = {input}.h;\nvariable s: Number = {input}.s;\nvariable l: Number = {input}.l;\n\n// Normalize hue to 0-1 range\nvariable hue: Number = h / 360;\n\n// RGB values (default to achromatic)\nvariable r: Number = l;\nvariable g: Number = l;\nvariable b: Number = l;\n\n// Only calculate if there's saturation\nif (s > 0) [\n variable q: Number = 0;\n if (l < 0.5) [\n q = l * (1 + s);\n ] else [\n q = l + s - l * s;\n ];\n\n variable p: Number = 2 * l - q;\n\n // Red (hue + 1/3)\n variable tr: Number = hue + 0.333333333333333;\n if (tr < 0) [ tr = tr + 1; ];\n if (tr > 1) [ tr = tr - 1; ];\n\n if (tr < 0.166666666666667) [\n r = p + (q - p) * 6 * tr;\n ] else [\n if (tr < 0.5) [\n r = q;\n ] else [\n if (tr < 0.666666666666667) [\n r = p + (q - p) * (0.666666666666667 - tr) * 6;\n ] else [\n r = p;\n ];\n ];\n ];\n\n // Green (hue)\n variable tg: Number = hue;\n if (tg < 0) [ tg = tg + 1; ];\n if (tg > 1) [ tg = tg - 1; ];\n\n if (tg < 0.166666666666667) [\n g = p + (q - p) * 6 * tg;\n ] else [\n if (tg < 0.5) [\n g = q;\n ] else [\n if (tg < 0.666666666666667) [\n g = p + (q - p) * (0.666666666666667 - tg) * 6;\n ] else [\n g = p;\n ];\n ];\n ];\n\n // Blue (hue - 1/3)\n variable tb: Number = hue - 0.333333333333333;\n if (tb < 0) [ tb = tb + 1; ];\n if (tb > 1) [ tb = tb - 1; ];\n\n if (tb < 0.166666666666667) [\n b = p + (q - p) * 6 * tb;\n ] else [\n if (tb < 0.5) [\n b = q;\n ] else [\n if (tb < 0.666666666666667) [\n b = p + (q - p) * (0.666666666666667 - tb) * 6;\n ] else [\n b = p;\n ];\n ];\n ];\n];\n\n// Convert RGB to hex\nvariable hex: String = \"#\";\nvariable value: Number = 0;\n\n// Red\nvalue = round(r * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Green\nvalue = round(g * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Blue\nvalue = round(b * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\nreturn hex;"
}
},
{
"source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklch-color/0/",
"target": "$self",
"description": "Converts OKLCH to Hex format (clamps to sRGB gamut)",
"lossless": false,
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// OKLCH to Hex Conversion\n// Converts OKLCH perceptual color to hexadecimal string format\n// Path: OKLCH → OKLab → XYZ-D65 → Linear sRGB → sRGB → Hex\n//\n// Input: Color.OKLCH with l (0-1), c, h (0-360)\n// Output: Hex string #rrggbb\n\n// Get input OKLCH values\nvariable ok_l: Number = {input}.l;\nvariable ok_c: Number = {input}.c;\nvariable ok_h: Number = {input}.h;\n\n// === Step 1: OKLCH to OKLab (polar to cartesian) ===\nvariable pi: Number = pi();\nvariable deg_to_rad: Number = pi / 180;\nvariable h_rad: Number = ok_h * deg_to_rad;\n\nvariable lab_a: Number = ok_c * cos(h_rad);\nvariable lab_b: Number = ok_c * sin(h_rad);\n\n// === Step 2: OKLab to XYZ-D65 ===\n// Inverse Lab-to-LMS matrix\nvariable lms_l: Number = 1.0 * ok_l + 0.3963377773761749 * lab_a + 0.2158037573099136 * lab_b;\nvariable lms_m: Number = 1.0 * ok_l + -0.1055613458156586 * lab_a + -0.0638541728258133 * lab_b;\nvariable lms_s: Number = 1.0 * ok_l + -0.0894841775298119 * lab_a + -1.2914855480194092 * lab_b;\n\n// Cube the values (inverse of cube root)\nvariable lms_l_cubed: Number = lms_l * lms_l * lms_l;\nvariable lms_m_cubed: Number = lms_m * lms_m * lms_m;\nvariable lms_s_cubed: Number = lms_s * lms_s * lms_s;\n\n// Inverse LMS-to-XYZ matrix\nvariable xyz_x: Number = 1.2268798758459243 * lms_l_cubed + -0.5578149944602171 * lms_m_cubed + 0.2813910456659647 * lms_s_cubed;\nvariable xyz_y: Number = -0.0405757452148008 * lms_l_cubed + 1.1122868032803170 * lms_m_cubed + -0.0717110580655164 * lms_s_cubed;\nvariable xyz_z: Number = -0.0763729366746601 * lms_l_cubed + -0.4214933324022432 * lms_m_cubed + 1.5869240198367816 * lms_s_cubed;\n\n// === Step 3: XYZ-D65 to Linear sRGB ===\nvariable linear_r: Number = 3.2409699419045226 * xyz_x + -1.537383177570094 * xyz_y + -0.4986107602930034 * xyz_z;\nvariable linear_g: Number = -0.9692436362808796 * xyz_x + 1.8759675015077202 * xyz_y + 0.04155505740717559 * xyz_z;\nvariable linear_b: Number = 0.05563007969699366 * xyz_x + -0.20397695888897652 * xyz_y + 1.0569715142428786 * xyz_z;\n\n// === Step 4: Linear sRGB to sRGB (gamma correction) ===\nvariable threshold: Number = 0.0031308;\nvariable linear_scale: Number = 12.92;\nvariable gamma_offset: Number = 0.055;\nvariable gamma_scale: Number = 1.055;\nvariable gamma_exp: Number = 0.416666666666667;\n\nvariable srgb_r: Number = 0;\nif (linear_r <= threshold) [\n srgb_r = linear_r * linear_scale;\n] else [\n if (linear_r > 0) [\n srgb_r = gamma_scale * pow(linear_r, gamma_exp) - gamma_offset;\n ] else [\n srgb_r = 0;\n ];\n];\n\nvariable srgb_g: Number = 0;\nif (linear_g <= threshold) [\n srgb_g = linear_g * linear_scale;\n] else [\n if (linear_g > 0) [\n srgb_g = gamma_scale * pow(linear_g, gamma_exp) - gamma_offset;\n ] else [\n srgb_g = 0;\n ];\n];\n\nvariable srgb_b: Number = 0;\nif (linear_b <= threshold) [\n srgb_b = linear_b * linear_scale;\n] else [\n if (linear_b > 0) [\n srgb_b = gamma_scale * pow(linear_b, gamma_exp) - gamma_offset;\n ] else [\n srgb_b = 0;\n ];\n];\n\n// === Step 5: sRGB to Hex ===\nvariable hex: String = \"#\";\nvariable value: Number = 0;\n\n// Red (clamp to 0-1)\nvalue = srgb_r;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Green\nvalue = srgb_g;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Blue\nvalue = srgb_b;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\nreturn hex;"
}
}
]
}
},
{
uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/",
schema: {
"name": "SRGB",
"type": "color",
"description": "sRGB color space with normalized 0-1 range. The standard color space for web and displays.",
"schema": {
"type": "object",
"properties": {
"r": {
"type": "number",
"description": "Red channel (0-1)"
},
"g": {
"type": "number",
"description": "Green channel (0-1)"
},
"b": {
"type": "number",
"description": "Blue channel (0-1)"
}
},
"required": [
"r",
"g",
"b"
],
"order": [
"r",
"g",
"b"
],
"additionalProperties": false
},
"initializers": [
{
"title": "sRGB Color Initializer",
"keyword": "srgb",
"description": "Creates an sRGB color from normalized 0-1 values",
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// sRGB Color Initializer\n// Creates an sRGB color from normalized 0-1 values\n// Input: List of [r, g, b] or [r, g, b, alpha] values in 0-1 range\n\nvariable color_values: List = {input};\nvariable output: Color.SRGB;\n\noutput.r = color_values.get(0);\noutput.g = color_values.get(1);\noutput.b = color_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (color_values.length() > 3) [\n output.alpha = color_values.get(3);\n];\n\nreturn output;"
}
}
],
"conversions": [
{
"source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/rgb-color/0/",
"target": "$self",
"description": "Converts RGB (0-255) to sRGB (0-1) by normalizing",
"lossless": true,
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// RGB to sRGB Conversion\n// Converts RGB (0-255) to sRGB (0-1) by normalizing\n// Input: Color.Rgb with r, g, b in 0-255 range\n// Output: Color.SRGB with r, g, b in 0-1 range\n// Lossless: Yes (simple division)\n\nvariable r_normalized: Number = {input}.r / 255;\nvariable g_normalized: Number = {input}.g / 255;\nvariable b_normalized: Number = {input}.b / 255;\n\nvariable output: Color.SRGB;\noutput.r = r_normalized;\noutput.g = g_normalized;\noutput.b = b_normalized;\n\nreturn output;"
}
},
{
"source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hsl-color/0/",
"target": "$self",
"description": "Converts HSL to sRGB",
"lossless": true,
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// HSL to sRGB Conversion\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/hsl.js\n//\n// Algorithm:\n// 1. If saturation is 0, it's achromatic: R=G=B=L\n// 2. Otherwise use the HSL to RGB formula:\n// - Calculate intermediate values based on L\n// - Use hue to determine RGB components\n//\n// Input: Color.HSL with h (0-360), s (0-1), l (0-1)\n// Output: Color.SRGB with r, g, b in 0-1 range\n\n// Get input HSL values\nvariable h: Number = {input}.h;\nvariable s: Number = {input}.s;\nvariable l: Number = {input}.l;\n\n// Normalize hue to 0-1 range\nvariable hue: Number = h / 360;\n\n// Output values\nvariable r: Number = l;\nvariable g: Number = l;\nvariable b: Number = l;\n\n// Only calculate if there's saturation (not achromatic)\nif (s > 0) [\n // Calculate intermediate value\n variable q: Number = 0;\n if (l < 0.5) [\n q = l * (1 + s);\n ] else [\n q = l + s - l * s;\n ];\n \n variable p: Number = 2 * l - q;\n \n // Helper function logic inlined for R (hue + 1/3)\n variable tr: Number = hue + 0.333333333333333;\n if (tr < 0) [ tr = tr + 1; ];\n if (tr > 1) [ tr = tr - 1; ];\n \n if (tr < 0.166666666666667) [\n r = p + (q - p) * 6 * tr;\n ] else [\n if (tr < 0.5) [\n r = q;\n ] else [\n if (tr < 0.666666666666667) [\n r = p + (q - p) * (0.666666666666667 - tr) * 6;\n ] else [\n r = p;\n ];\n ];\n ];\n \n // Helper function logic inlined for G (hue)\n variable tg: Number = hue;\n if (tg < 0) [ tg = tg + 1; ];\n if (tg > 1) [ tg = tg - 1; ];\n \n if (tg < 0.166666666666667) [\n g = p + (q - p) * 6 * tg;\n ] else [\n if (tg < 0.5) [\n g = q;\n ] else [\n if (tg < 0.666666666666667) [\n g = p + (q - p) * (0.666666666666667 - tg) * 6;\n ] else [\n g = p;\n ];\n ];\n ];\n \n // Helper function logic inlined for B (hue - 1/3)\n variable tb: Number = hue - 0.333333333333333;\n if (tb < 0) [ tb = tb + 1; ];\n if (tb > 1) [ tb = tb - 1; ];\n \n if (tb < 0.166666666666667) [\n b = p + (q - p) * 6 * tb;\n ] else [\n if (tb < 0.5) [\n b = q;\n ] else [\n if (tb < 0.666666666666667) [\n b = p + (q - p) * (0.666666666666667 - tb) * 6;\n ] else [\n b = p;\n ];\n ];\n ];\n];\n\n// Create output\nvariable output: Color.SRGB;\noutput.r = r;\noutput.g = g;\noutput.b = b;\n\nreturn output;"
}
},
{
"source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-linear-color/0/",
"target": "$self",
"description": "Converts Linear sRGB to sRGB by applying gamma correction",
"lossless": true,
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// Linear sRGB to sRGB Conversion\n// Applies gamma correction (transfer function)\n// Reference: IEC 61966-2-1:1999 (sRGB specification)\n//\n// Algorithm:\n// if linear ≤ 0.0031308: srgb = linear * 12.92\n// else: srgb = 1.055 * linear^(1/2.4) - 0.055\n//\n// Input: Color.LinearSRGB with r, g, b in linear 0-1 range\n// Output: Color.SRGB with r, g, b in gamma-corrected 0-1 range\n\n// Gamma correction constants (IEC 61966-2-1)\nvariable threshold: Number = 0.0031308;\nvariable linear_scale: Number = 12.92;\nvariable gamma_offset: Number = 0.055;\nvariable gamma_scale: Number = 1.055;\nvariable gamma_exponent: Number = 0.416666666666667;\n\n// Get input linear values\nvariable linear_r: Number = {input}.r;\nvariable linear_g: Number = {input}.g;\nvariable linear_b: Number = {input}.b;\n\n// Convert red channel\nvariable srgb_r: Number = 0;\nif (linear_r <= threshold) [\n srgb_r = linear_r * linear_scale;\n] else [\n srgb_r = gamma_scale * pow(linear_r, gamma_exponent) - gamma_offset;\n];\n\n// Convert green channel\nvariable srgb_g: Number = 0;\nif (linear_g <= threshold) [\n srgb_g = linear_g * linear_scale;\n] else [\n srgb_g = gamma_scale * pow(linear_g, gamma_exponent) - gamma_offset;\n];\n\n// Convert blue channel\nvariable srgb_b: Number = 0;\nif (linear_b <= threshold) [\n srgb_b = linear_b * linear_scale;\n] else [\n srgb_b = gamma_scale * pow(linear_b, gamma_exponent) - gamma_offset;\n];\n\n// Create output\nvariable output: Color.SRGB;\noutput.r = srgb_r;\noutput.g = srgb_g;\noutput.b = srgb_b;\n\nreturn output;"
}
}
]
}
},
{
@@ -177,85 +297,6 @@ const SCHEMAS = [
]
}
},
{
uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/",
schema: {
"name": "SRGB",
"type": "color",
"description": "sRGB color space with normalized 0-1 range. The standard color space for web and displays.",
"schema": {
"type": "object",
"properties": {
"r": {
"type": "number",
"description": "Red channel (0-1)"
},
"g": {
"type": "number",
"description": "Green channel (0-1)"
},
"b": {
"type": "number",
"description": "Blue channel (0-1)"
}
},
"required": [
"r",
"g",
"b"
],
"order": [
"r",
"g",
"b"
],
"additionalProperties": false
},
"initializers": [
{
"title": "sRGB Color Initializer",
"keyword": "srgb",
"description": "Creates an sRGB color from normalized 0-1 values",
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// sRGB Color Initializer\n// Creates an sRGB color from normalized 0-1 values\n// Input: List of [r, g, b] or [r, g, b, alpha] values in 0-1 range\n\nvariable color_values: List = {input};\nvariable output: Color.SRGB;\n\noutput.r = color_values.get(0);\noutput.g = color_values.get(1);\noutput.b = color_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (color_values.length() > 3) [\n output.alpha = color_values.get(3);\n];\n\nreturn output;"
}
}
],
"conversions": [
{
"source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/rgb-color/0/",
"target": "$self",
"description": "Converts RGB (0-255) to sRGB (0-1) by normalizing",
"lossless": true,
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// RGB to sRGB Conversion\n// Converts RGB (0-255) to sRGB (0-1) by normalizing\n// Input: Color.Rgb with r, g, b in 0-255 range\n// Output: Color.SRGB with r, g, b in 0-1 range\n// Lossless: Yes (simple division)\n\nvariable r_normalized: Number = {input}.r / 255;\nvariable g_normalized: Number = {input}.g / 255;\nvariable b_normalized: Number = {input}.b / 255;\n\nvariable output: Color.SRGB;\noutput.r = r_normalized;\noutput.g = g_normalized;\noutput.b = b_normalized;\n\nreturn output;"
}
},
{
"source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hsl-color/0/",
"target": "$self",
"description": "Converts HSL to sRGB",
"lossless": true,
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// HSL to sRGB Conversion\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/hsl.js\n//\n// Algorithm:\n// 1. If saturation is 0, it's achromatic: R=G=B=L\n// 2. Otherwise use the HSL to RGB formula:\n// - Calculate intermediate values based on L\n// - Use hue to determine RGB components\n//\n// Input: Color.HSL with h (0-360), s (0-1), l (0-1)\n// Output: Color.SRGB with r, g, b in 0-1 range\n\n// Get input HSL values\nvariable h: Number = {input}.h;\nvariable s: Number = {input}.s;\nvariable l: Number = {input}.l;\n\n// Normalize hue to 0-1 range\nvariable hue: Number = h / 360;\n\n// Output values\nvariable r: Number = l;\nvariable g: Number = l;\nvariable b: Number = l;\n\n// Only calculate if there's saturation (not achromatic)\nif (s > 0) [\n // Calculate intermediate value\n variable q: Number = 0;\n if (l < 0.5) [\n q = l * (1 + s);\n ] else [\n q = l + s - l * s;\n ];\n \n variable p: Number = 2 * l - q;\n \n // Helper function logic inlined for R (hue + 1/3)\n variable tr: Number = hue + 0.333333333333333;\n if (tr < 0) [ tr = tr + 1; ];\n if (tr > 1) [ tr = tr - 1; ];\n \n if (tr < 0.166666666666667) [\n r = p + (q - p) * 6 * tr;\n ] else [\n if (tr < 0.5) [\n r = q;\n ] else [\n if (tr < 0.666666666666667) [\n r = p + (q - p) * (0.666666666666667 - tr) * 6;\n ] else [\n r = p;\n ];\n ];\n ];\n \n // Helper function logic inlined for G (hue)\n variable tg: Number = hue;\n if (tg < 0) [ tg = tg + 1; ];\n if (tg > 1) [ tg = tg - 1; ];\n \n if (tg < 0.166666666666667) [\n g = p + (q - p) * 6 * tg;\n ] else [\n if (tg < 0.5) [\n g = q;\n ] else [\n if (tg < 0.666666666666667) [\n g = p + (q - p) * (0.666666666666667 - tg) * 6;\n ] else [\n g = p;\n ];\n ];\n ];\n \n // Helper function logic inlined for B (hue - 1/3)\n variable tb: Number = hue - 0.333333333333333;\n if (tb < 0) [ tb = tb + 1; ];\n if (tb > 1) [ tb = tb - 1; ];\n \n if (tb < 0.166666666666667) [\n b = p + (q - p) * 6 * tb;\n ] else [\n if (tb < 0.5) [\n b = q;\n ] else [\n if (tb < 0.666666666666667) [\n b = p + (q - p) * (0.666666666666667 - tb) * 6;\n ] else [\n b = p;\n ];\n ];\n ];\n];\n\n// Create output\nvariable output: Color.SRGB;\noutput.r = r;\noutput.g = g;\noutput.b = b;\n\nreturn output;"
}
},
{
"source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-linear-color/0/",
"target": "$self",
"description": "Converts Linear sRGB to sRGB by applying gamma correction",
"lossless": true,
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// Linear sRGB to sRGB Conversion\n// Applies gamma correction (transfer function)\n// Reference: IEC 61966-2-1:1999 (sRGB specification)\n//\n// Algorithm:\n// if linear ≤ 0.0031308: srgb = linear * 12.92\n// else: srgb = 1.055 * linear^(1/2.4) - 0.055\n//\n// Input: Color.LinearSRGB with r, g, b in linear 0-1 range\n// Output: Color.SRGB with r, g, b in gamma-corrected 0-1 range\n\n// Gamma correction constants (IEC 61966-2-1)\nvariable threshold: Number = 0.0031308;\nvariable linear_scale: Number = 12.92;\nvariable gamma_offset: Number = 0.055;\nvariable gamma_scale: Number = 1.055;\nvariable gamma_exponent: Number = 0.416666666666667;\n\n// Get input linear values\nvariable linear_r: Number = {input}.r;\nvariable linear_g: Number = {input}.g;\nvariable linear_b: Number = {input}.b;\n\n// Convert red channel\nvariable srgb_r: Number = 0;\nif (linear_r <= threshold) [\n srgb_r = linear_r * linear_scale;\n] else [\n srgb_r = gamma_scale * pow(linear_r, gamma_exponent) - gamma_offset;\n];\n\n// Convert green channel\nvariable srgb_g: Number = 0;\nif (linear_g <= threshold) [\n srgb_g = linear_g * linear_scale;\n] else [\n srgb_g = gamma_scale * pow(linear_g, gamma_exponent) - gamma_offset;\n];\n\n// Convert blue channel\nvariable srgb_b: Number = 0;\nif (linear_b <= threshold) [\n srgb_b = linear_b * linear_scale;\n] else [\n srgb_b = gamma_scale * pow(linear_b, gamma_exponent) - gamma_offset;\n];\n\n// Create output\nvariable output: Color.SRGB;\noutput.r = srgb_r;\noutput.g = srgb_g;\noutput.b = srgb_b;\n\nreturn output;"
}
}
]
}
},
{
uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-linear-color/0/",
schema: {
@@ -729,6 +770,65 @@ const SCHEMAS = [
]
}
},
{
uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-color/0/",
schema: {
"name": "P3",
"type": "color",
"description": "Display-P3 color space with sRGB transfer function. Wider gamut than sRGB, common on modern Apple displays.",
"schema": {
"type": "object",
"properties": {
"r": {
"type": "number",
"description": "Red channel (0-1, can exceed for out-of-gamut)"
},
"g": {
"type": "number",
"description": "Green channel (0-1, can exceed for out-of-gamut)"
},
"b": {
"type": "number",
"description": "Blue channel (0-1, can exceed for out-of-gamut)"
}
},
"required": [
"r",
"g",
"b"
],
"order": [
"r",
"g",
"b"
],
"additionalProperties": false
},
"initializers": [
{
"title": "Display-P3 Color Initializer",
"keyword": "p3",
"description": "Creates a Display-P3 color from 0-1 values",
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// Display-P3 Color Initializer\n// Creates a Display-P3 color from 0-1 values\n// Input: List of [r, g, b] or [r, g, b, alpha] values\n\nvariable color_values: List = {input};\nvariable output: Color.P3;\n\noutput.r = color_values.get(0);\noutput.g = color_values.get(1);\noutput.b = color_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (color_values.length() > 3) [\n output.alpha = color_values.get(3);\n];\n\nreturn output;"
}
}
],
"conversions": [
{
"source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-linear-color/0/",
"target": "$self",
"description": "Converts Linear P3 to P3 by applying sRGB transfer function",
"lossless": true,
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// Linear P3 to P3 Conversion\n// Applies sRGB transfer function (gamma encoding)\n// P3 uses the same transfer function as sRGB\n// Reference: CSS Color Level 4\n//\n// Algorithm (same as sRGB):\n// if linear ≤ 0.0031308: encoded = 12.92 × linear\n// else: encoded = 1.055 × linear^(1/2.4) - 0.055\n//\n// Input: Color.LinearP3 with linear r, g, b values\n// Output: Color.P3 with gamma-encoded r, g, b values\n\n// Transfer function constants (same as sRGB)\nvariable threshold: Number = 0.0031308;\nvariable linear_scale: Number = 12.92;\nvariable gamma_scale: Number = 1.055;\nvariable gamma_offset: Number = 0.055;\nvariable gamma_exponent: Number = 0.4166666666666667;\n\n// Get linear values\nvariable linear_r: Number = {input}.r;\nvariable linear_g: Number = {input}.g;\nvariable linear_b: Number = {input}.b;\n\n// Convert red channel\nvariable encoded_r: Number = 0;\nif (linear_r <= threshold) [\n encoded_r = linear_scale * linear_r;\n] else [\n encoded_r = gamma_scale * pow(linear_r, gamma_exponent) - gamma_offset;\n];\n\n// Convert green channel\nvariable encoded_g: Number = 0;\nif (linear_g <= threshold) [\n encoded_g = linear_scale * linear_g;\n] else [\n encoded_g = gamma_scale * pow(linear_g, gamma_exponent) - gamma_offset;\n];\n\n// Convert blue channel\nvariable encoded_b: Number = 0;\nif (linear_b <= threshold) [\n encoded_b = linear_scale * linear_b;\n] else [\n encoded_b = gamma_scale * pow(linear_b, gamma_exponent) - gamma_offset;\n];\n\n// Create output\nvariable output: Color.P3;\noutput.r = encoded_r;\noutput.g = encoded_g;\noutput.b = encoded_b;\n\nreturn output;"
}
}
]
}
},
{
uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/css-color/0/",
schema: {
@@ -1186,65 +1286,6 @@ const SCHEMAS = [
]
}
},
{
uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-color/0/",
schema: {
"name": "P3",
"type": "color",
"description": "Display-P3 color space with sRGB transfer function. Wider gamut than sRGB, common on modern Apple displays.",
"schema": {
"type": "object",
"properties": {
"r": {
"type": "number",
"description": "Red channel (0-1, can exceed for out-of-gamut)"
},
"g": {
"type": "number",
"description": "Green channel (0-1, can exceed for out-of-gamut)"
},
"b": {
"type": "number",
"description": "Blue channel (0-1, can exceed for out-of-gamut)"
}
},
"required": [
"r",
"g",
"b"
],
"order": [
"r",
"g",
"b"
],
"additionalProperties": false
},
"initializers": [
{
"title": "Display-P3 Color Initializer",
"keyword": "p3",
"description": "Creates a Display-P3 color from 0-1 values",
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// Display-P3 Color Initializer\n// Creates a Display-P3 color from 0-1 values\n// Input: List of [r, g, b] or [r, g, b, alpha] values\n\nvariable color_values: List = {input};\nvariable output: Color.P3;\n\noutput.r = color_values.get(0);\noutput.g = color_values.get(1);\noutput.b = color_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (color_values.length() > 3) [\n output.alpha = color_values.get(3);\n];\n\nreturn output;"
}
}
],
"conversions": [
{
"source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-linear-color/0/",
"target": "$self",
"description": "Converts Linear P3 to P3 by applying sRGB transfer function",
"lossless": true,
"script": {
"type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/",
"script": "// Linear P3 to P3 Conversion\n// Applies sRGB transfer function (gamma encoding)\n// P3 uses the same transfer function as sRGB\n// Reference: CSS Color Level 4\n//\n// Algorithm (same as sRGB):\n// if linear ≤ 0.0031308: encoded = 12.92 × linear\n// else: encoded = 1.055 × linear^(1/2.4) - 0.055\n//\n// Input: Color.LinearP3 with linear r, g, b values\n// Output: Color.P3 with gamma-encoded r, g, b values\n\n// Transfer function constants (same as sRGB)\nvariable threshold: Number = 0.0031308;\nvariable linear_scale: Number = 12.92;\nvariable gamma_scale: Number = 1.055;\nvariable gamma_offset: Number = 0.055;\nvariable gamma_exponent: Number = 0.4166666666666667;\n\n// Get linear values\nvariable linear_r: Number = {input}.r;\nvariable linear_g: Number = {input}.g;\nvariable linear_b: Number = {input}.b;\n\n// Convert red channel\nvariable encoded_r: Number = 0;\nif (linear_r <= threshold) [\n encoded_r = linear_scale * linear_r;\n] else [\n encoded_r = gamma_scale * pow(linear_r, gamma_exponent) - gamma_offset;\n];\n\n// Convert green channel\nvariable encoded_g: Number = 0;\nif (linear_g <= threshold) [\n encoded_g = linear_scale * linear_g;\n] else [\n encoded_g = gamma_scale * pow(linear_g, gamma_exponent) - gamma_offset;\n];\n\n// Convert blue channel\nvariable encoded_b: Number = 0;\nif (linear_b <= threshold) [\n encoded_b = linear_scale * linear_b;\n] else [\n encoded_b = gamma_scale * pow(linear_b, gamma_exponent) - gamma_offset;\n];\n\n// Create output\nvariable output: Color.P3;\noutput.r = encoded_r;\noutput.g = encoded_g;\noutput.b = encoded_b;\n\nreturn output;"
}
}
]
}
},
{
uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/function/lighten/0/",
schema: {
@@ -1424,6 +1465,165 @@ const SCHEMAS = [
]
}
},
{
uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/constants/css-hex-colors/0/",
schema: {
"name": "CSS Hex Colors",
"type": "constants",
"description": "CSS named colors mapped to their hex values (CSS Color Level 4)",
"inline": true,
"values": {
"aliceblue": "#F0F8FF",
"antiquewhite": "#FAEBD7",
"aqua": "#00FFFF",
"aquamarine": "#7FFFD4",
"azure": "#F0FFFF",
"beige": "#F5F5DC",
"bisque": "#FFE4C4",
"black": "#000000",
"blanchedalmond": "#FFEBCD",
"blue": "#0000FF",
"blueviolet": "#8A2BE2",
"brown": "#A52A2A",
"burlywood": "#DEB887",
"cadetblue": "#5F9EA0",
"chartreuse": "#7FFF00",
"chocolate": "#D2691E",
"coral": "#FF7F50",
"cornflowerblue": "#6495ED",
"cornsilk": "#FFF8DC",
"crimson": "#DC143C",
"cyan": "#00FFFF",
"darkblue": "#00008B",
"darkcyan": "#008B8B",
"darkgoldenrod": "#B8860B",
"darkgray": "#A9A9A9",
"darkgreen": "#006400",
"darkgrey": "#A9A9A9",
"darkkhaki": "#BDB76B",
"darkmagenta": "#8B008B",
"darkolivegreen": "#556B2F",
"darkorange": "#FF8C00",
"darkorchid": "#9932CC",
"darkred": "#8B0000",
"darksalmon": "#E9967A",
"darkseagreen": "#8FBC8F",
"darkslateblue": "#483D8B",
"darkslategray": "#2F4F4F",
"darkslategrey": "#2F4F4F",
"darkturquoise": "#00CED1",
"darkviolet": "#9400D3",
"deeppink": "#FF1493",
"deepskyblue": "#00BFFF",
"dimgray": "#696969",
"dimgrey": "#696969",
"dodgerblue": "#1E90FF",
"firebrick": "#B22222",
"floralwhite": "#FFFAF0",
"forestgreen": "#228B22",
"fuchsia": "#FF00FF",
"gainsboro": "#DCDCDC",
"ghostwhite": "#F8F8FF",
"gold": "#FFD700",
"goldenrod": "#DAA520",
"gray": "#808080",
"green": "#008000",
"greenyellow": "#ADFF2F",
"grey": "#808080",
"honeydew": "#F0FFF0",
"hotpink": "#FF69B4",
"indianred": "#CD5C5C",
"indigo": "#4B0082",
"ivory": "#FFFFF0",
"khaki": "#F0E68C",
"lavender": "#E6E6FA",
"lavenderblush": "#FFF0F5",
"lawngreen": "#7CFC00",
"lemonchiffon": "#FFFACD",
"lightblue": "#ADD8E6",
"lightcoral": "#F08080",
"lightcyan": "#E0FFFF",
"lightgoldenrodyellow": "#FAFAD2",
"lightgray": "#D3D3D3",
"lightgreen": "#90EE90",
"lightgrey": "#D3D3D3",
"lightpink": "#FFB6C1",
"lightsalmon": "#FFA07A",
"lightseagreen": "#20B2AA",
"lightskyblue": "#87CEFA",
"lightslategray": "#778899",
"lightslategrey": "#778899",
"lightsteelblue": "#B0C4DE",
"lightyellow": "#FFFFE0",
"lime": "#00FF00",
"limegreen": "#32CD32",
"linen": "#FAF0E6",
"magenta": "#FF00FF",
"maroon": "#800000",
"mediumaquamarine": "#66CDAA",
"mediumblue": "#0000CD",
"mediumorchid": "#BA55D3",
"mediumpurple": "#9370DB",
"mediumseagreen": "#3CB371",
"mediumslateblue": "#7B68EE",
"mediumspringgreen": "#00FA9A",
"mediumturquoise": "#48D1CC",
"mediumvioletred": "#C71585",
"midnightblue": "#191970",
"mintcream": "#F5FFFA",
"mistyrose": "#FFE4E1",
"moccasin": "#FFE4B5",
"navajowhite": "#FFDEAD",
"navy": "#000080",
"oldlace": "#FDF5E6",
"olive": "#808000",
"olivedrab": "#6B8E23",
"orange": "#FFA500",
"orangered": "#FF4500",
"orchid": "#DA70D6",
"palegoldenrod": "#EEE8AA",
"palegreen": "#98FB98",
"paleturquoise": "#AFEEEE",
"palevioletred": "#DB7093",
"papayawhip": "#FFEFD5",
"peachpuff": "#FFDAB9",
"peru": "#CD853F",
"pink": "#FFC0CB",
"plum": "#DDA0DD",
"powderblue": "#B0E0E6",
"purple": "#800080",
"rebeccapurple": "#663399",
"red": "#FF0000",
"rosybrown": "#BC8F8F",
"royalblue": "#4169E1",
"saddlebrown": "#8B4513",
"salmon": "#FA8072",
"sandybrown": "#F4A460",
"seagreen": "#2E8B57",
"seashell": "#FFF5EE",
"sienna": "#A0522D",
"silver": "#C0C0C0",
"skyblue": "#87CEEB",
"slateblue": "#6A5ACD",
"slategray": "#708090",
"slategrey": "#708090",
"snow": "#FFFAFA",
"springgreen": "#00FF7F",
"steelblue": "#4682B4",
"tan": "#D2B48C",
"teal": "#008080",
"thistle": "#D8BFD8",
"tomato": "#FF6347",
"turquoise": "#40E0D0",
"violet": "#EE82EE",
"wheat": "#F5DEB3",
"white": "#FFFFFF",
"whitesmoke": "#F5F5F5",
"yellow": "#FFFF00",
"yellowgreen": "#9ACD32"
}
}
},
];
export function makeConfig() {

View File

@@ -0,0 +1,4 @@
{
"presets": [],
"plugins": []
}

1
frontend/packages/ui/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
dist/

View File

@@ -0,0 +1,23 @@
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/lib/**/*.@(mdx|stories.@(js|jsx|ts|tsx))'],
addons: [],
framework: {
name: getAbsolutePath('@storybook/react-vite'),
options: {
builder: {
viteConfigPath: 'vite.config.mts',
},
},
},
};
function getAbsolutePath(value: string): any {
return dirname(fileURLToPath(import.meta.resolve(`${value}/package.json`)));
}
export default config;

View File

View File

@@ -0,0 +1,11 @@
# UI
A React component library with TypeScript for the Penpot ecosystem.
## Commands
Run from workspace root:
- **`pnpm storybook:ui`** - Start Storybook for component development
- **`pnpm build:ui`** - Build the library for production
- **`pnpm start:ui`** - Build in watch mode for development

View File

@@ -0,0 +1,39 @@
{
"name": "@penpot/ui",
"version": "0.0.1",
"types": "./dist/index.d.ts",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js"
},
"./style.css": "./dist/style.css"
},
"scripts": {
"watch": "vite build --watch",
"build": "vite build"
},
"devDependencies": {
"@babel/core": "^7.14.5",
"@babel/preset-react": "^7.14.5",
"@storybook/react": "10.2.0",
"@storybook/react-vite": "10.2.0",
"@testing-library/dom": "10.4.0",
"@testing-library/react": "16.3.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.2.0",
"babel-plugin-react-compiler": "^1.0.0",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-jsx-a11y": "6.10.1",
"eslint-plugin-react": "7.35.0",
"eslint-plugin-react-hooks": "7.0.1",
"react-compiler-runtime": "^1.0.0",
"storybook": "10.2.0",
"vite-plugin-dts": "^4.5.4"
},
"peerDependencies": {
"react": ">=19.2",
"react-dom": ">=19.2"
}
}

View File

@@ -0,0 +1 @@
export * from './lib/example/Example';

View File

@@ -0,0 +1,5 @@
.container {
background-color: #f0f0f0;
padding: 16px;
border: 2px solid #000;
}

View File

@@ -0,0 +1,10 @@
import { render } from '@testing-library/react';
import Example from './Example';
describe('Example', () => {
it('should render successfully', () => {
const { baseElement } = render(<Example />);
expect(baseElement).toBeTruthy();
});
});

View File

@@ -0,0 +1,12 @@
import { Example } from './Example';
import type { Meta, StoryObj } from '@storybook/react-vite';
const meta = {
title: 'UI/Example',
component: Example,
} satisfies Meta<typeof Example>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {};

View File

@@ -0,0 +1,21 @@
import { useState } from 'react';
import styles from './Example.module.css';
export function Example() {
const [count, setCount] = useState(0);
return (
<div className={styles.container}>
<h1>Example!</h1>
<div>
<h2>Counter: {count}</h2>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)}>Decrement</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
</div>
);
}
export default Example;

View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": false,
"noEmit": true,
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"jsx": "react-jsx",
"types": ["vite/client", "vitest"],
"baseUrl": "."
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
},
{
"path": "./tsconfig.storybook.json"
}
]
}

View File

@@ -0,0 +1,37 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": [
"node",
"vite/client"
]
},
"exclude": [
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"**/*.stories.ts",
"**/*.stories.js",
"**/*.stories.jsx",
"**/*.stories.tsx"
],
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
}

View File

@@ -0,0 +1,28 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
]
}

View File

@@ -0,0 +1,28 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"emitDecoratorMetadata": true,
"outDir": "",
"module": "esnext",
"moduleResolution": "bundler"
},
"exclude": [
"src/**/*.spec.ts",
"src/**/*.test.ts",
"src/**/*.spec.js",
"src/**/*.test.js",
"src/**/*.spec.tsx",
"src/**/*.test.tsx",
"src/**/*.spec.jsx",
"src/**/*.test.js"
],
"include": [
"src/**/*.stories.ts",
"src/**/*.stories.js",
"src/**/*.stories.jsx",
"src/**/*.stories.tsx",
"src/**/*.stories.mdx",
".storybook/*.js",
".storybook/*.ts"
]
}

View File

@@ -0,0 +1,66 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import * as path from 'path';
import { copyFileSync } from 'node:fs';
const copyCssPlugin = () => ({
name: 'copy-css',
closeBundle: () => {
try {
copyFileSync(
'dist/index.css',
'../../resources/public/css/ui.css',
);
} catch (e) {
console.log('Error copying css file', e);
}
},
});
export default defineConfig(() => ({
root: import.meta.dirname,
plugins: [
react({
babel: {
plugins: ['babel-plugin-react-compiler'],
},
}),
dts({
entryRoot: 'src',
tsconfigPath: path.join(import.meta.dirname, 'tsconfig.lib.json'),
pathsToAliases: false,
}),
copyCssPlugin(),
],
build: {
outDir: 'dist/',
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
lib: {
entry: 'src/index.ts',
name: 'ui',
fileName: 'index',
formats: ['es' as const],
},
rollupOptions: {
external: ['react', 'react-dom', 'react/jsx-runtime'],
},
},
test: {
name: 'ui',
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: '../../coverage/libs/ui',
provider: 'v8' as const,
},
},
}));

View File

@@ -0,0 +1,146 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"plugins/runtime",
"design-tokens/v1",
"variants/v1",
"layout/grid",
"styles/v2",
"fdata/objects-map",
"render-wasm/v1",
"components/v2",
"fdata/shape-data-type"
]
},
"~:team-id": "~u99e49e93-362f-80ef-8007-3450ea52c9a4",
"~: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": "BUG 13267",
"~:revn": 3,
"~:modified-at": "~m1770302832804",
"~:vern": 0,
"~:id": "~ue9c84e12-dd29-80fc-8007-86d559dced7f",
"~: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"
]
},
"~:version": 67,
"~:project-id": "~ufc576d2f-8d02-8101-8007-70ec5793bd81",
"~:created-at": "~m1770302800755",
"~:backend": "legacy-db",
"~:data": {
"~:pages": [
"~ue9c84e12-dd29-80fc-8007-86d559dced80"
],
"~:pages-index": {
"~ue9c84e12-dd29-80fc-8007-86d559dced80": {
"~: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\",[\"~udc075bef-4a1f-8056-8007-86d562cf43b7\"]]]",
"~udc075bef-4a1f-8056-8007-86d55e028ccb": "[\"~#shape\",[\"^ \",\"~:y\",234,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",117,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",574,\"~:y\",234]],[\"^<\",[\"^ \",\"~:x\",691,\"~:y\",234]],[\"^<\",[\"^ \",\"~:x\",691,\"~:y\",316]],[\"^<\",[\"^ \",\"~:x\",574,\"~:y\",316]]],\"~: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,\"~:constraints-v\",\"~:scale\",\"~:constraints-h\",\"^B\",\"~:r1\",0,\"~:id\",\"~udc075bef-4a1f-8056-8007-86d55e028ccb\",\"~:parent-id\",\"~udc075bef-4a1f-8056-8007-86d562cf43b7\",\"~:frame-id\",\"~udc075bef-4a1f-8056-8007-86d562cf43b7\",\"~:strokes\",[],\"~:x\",574,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",574,\"~:y\",234,\"^8\",117,\"~:height\",82,\"~:x1\",574,\"~:y1\",234,\"~:x2\",691,\"~:y2\",316]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^M\",82,\"~:flip-y\",null]]",
"~udc075bef-4a1f-8056-8007-86d562cf43b7": "[\"~#shape\",[\"^ \",\"~:y\",234,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:hide-in-viewer\",true,\"~:name\",\"A Component\",\"~:width\",117,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",574,\"~:y\",234]],[\"^;\",[\"^ \",\"~:x\",691,\"~:y\",234]],[\"^;\",[\"^ \",\"~:x\",691,\"~:y\",316]],[\"^;\",[\"^ \",\"~:x\",574,\"~:y\",316]]],\"~:r2\",0,\"~:component-root\",true,\"~: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\",0,\"~:r1\",0,\"~:id\",\"~udc075bef-4a1f-8056-8007-86d562cf43b7\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:component-id\",\"~udc075bef-4a1f-8056-8007-86d562d06904\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",574,\"~:main-instance\",true,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",574,\"~:y\",234,\"^7\",117,\"~:height\",82,\"~:x1\",574,\"~:y1\",234,\"~:x2\",691,\"~:y2\",316]],\"~:fills\",[],\"~:flip-x\",null,\"^M\",82,\"~:component-file\",\"~ue9c84e12-dd29-80fc-8007-86d559dced7f\",\"~:flip-y\",null,\"~:shapes\",[\"~udc075bef-4a1f-8056-8007-86d55e028ccb\"]]]"
}
},
"~:id": "~ue9c84e12-dd29-80fc-8007-86d559dced80",
"~:name": "Page 1"
}
},
"~:id": "~ue9c84e12-dd29-80fc-8007-86d559dced7f",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
},
"~:components": {
"~udc075bef-4a1f-8056-8007-86d562d06904": {
"~:id": "~udc075bef-4a1f-8056-8007-86d562d06904",
"~:name": "A Component",
"~:path": "",
"~:modified-at": "~m1770302824566",
"~:main-instance-id": "~udc075bef-4a1f-8056-8007-86d562cf43b7",
"~:main-instance-page": "~ue9c84e12-dd29-80fc-8007-86d559dced80"
}
}
}
}

View File

@@ -0,0 +1,147 @@
{
"~: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",
"components/v2",
"fdata/shape-data-type"
]
},
"~:team-id": "~ud7430f09-4f59-8049-8007-6277bb7586f6",
"~: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": "test_color_blending",
"~:revn": 78,
"~:modified-at": "~m1770820738388",
"~:vern": 0,
"~:id": "~ub15901d7-d46d-8056-8007-8d5e34fc1f0c",
"~: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"
]
},
"~:version": 67,
"~:project-id": "~ud7430f09-4f59-8049-8007-6277bb765abd",
"~:created-at": "~m1770741329904",
"~:backend": "legacy-db",
"~:data": {
"~:pages": [
"~ub15901d7-d46d-8056-8007-8d5e34fc1f0d"
],
"~:pages-index": {
"~ub15901d7-d46d-8056-8007-8d5e34fc1f0d": {
"~: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\",[\"~u3b7d4c1f-3b79-80e5-8007-8d5e38c5a297\",\"~udb80df91-a3a3-803b-8007-8e379b5fd50f\",\"~udb80df91-a3a3-803b-8007-8e38034ff7c8\",\"~udb80df91-a3a3-803b-8007-8e37a71c9d28\",\"~udb80df91-a3a3-803b-8007-8e384d8c53b9\",\"~udb80df91-a3a3-803b-8007-8e37c09b4084\",\"~u18522c44-655d-8050-8007-8e89f4bdc0c4\",\"~u097859f1-ca3b-80ba-8007-8e8beb99a3f5\",\"~u18522c44-655d-8050-8007-8e89f4bdc0c5\",\"~u097859f1-ca3b-80ba-8007-8e8bfca43303\",\"~ufb1f50bf-1bff-8030-8007-8e8c3bd8fcd7\",\"~u18522c44-655d-8050-8007-8e89f4bdc0c6\"]]]",
"~u097859f1-ca3b-80ba-8007-8e8bfca43303": "[\"~#shape\",[\"^ \",\"~:y\",-637.0000057220459,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",336.9999895095825,\"~:y\",-637.000005722046]],[\"^<\",[\"^ \",\"~:x\",636.9999995231628,\"~:y\",-637.000005722046]],[\"^<\",[\"^ \",\"~:x\",636.9999995231628,\"~:y\",-337.00000858306885]],[\"^<\",[\"^ \",\"~:x\",336.9999895095825,\"~:y\",-337.00000858306885]]],\"~: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,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~u097859f1-ca3b-80ba-8007-8e8bfca43303\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",10,\"~:stroke-color\",\"#4bff00\",\"~:stroke-opacity\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"~:outer\",\"^N\",10,\"^O\",\"#333fbd\",\"^P\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"~:inner\",\"^N\",10,\"^O\",\"#ff0000\",\"^P\",1]],\"~:x\",336.9999895095825,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",336.9999895095825,\"~:y\",-637.0000057220459,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",336.9999895095825,\"~:y1\",-637.0000057220459,\"~:x2\",636.9999995231628,\"~:y2\",-337.00000858306885]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^11\",\"#ff0000\",\"^12\",1]],\"~:flip-x\",null,\"^W\",299.99999713897705,\"~:flip-y\",null]]",
"~udb80df91-a3a3-803b-8007-8e384d8c53b9": "[\"~#shape\",[\"^ \",\"~:y\",450.99999806284904,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Ellipse\",\"~:width\",300.0000065565109,\"~:type\",\"~:circle\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1021.0000203847885,\"~:y\",450.99999806284904]],[\"^<\",[\"^ \",\"~:x\",1321.0000269412994,\"~:y\",450.99999806284904]],[\"^<\",[\"^ \",\"~:x\",1321.0000269412994,\"~:y\",751.0000142753124]],[\"^<\",[\"^ \",\"~:x\",1021.0000203847885,\"~:y\",751.0000142753124]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e37b7ddd15c\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"^@\",\"~udb80df91-a3a3-803b-8007-8e384d8c53b9\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-width\",20,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",1]],\"~:x\",1021.0000203847885,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1021.0000203847885,\"~:y\",450.99999806284904,\"^8\",300.0000065565109,\"~:height\",300.0000162124634,\"~:x1\",1021.0000203847885,\"~:y1\",450.99999806284904,\"~:x2\",1321.0000269412994,\"~:y2\",751.0000142753124]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^W\",\"#ff0000\",\"^X\",1]],\"~:flip-x\",null,\"^Q\",300.0000162124634,\"~:flip-y\",null]]",
"~udb80df91-a3a3-803b-8007-8e379b5fd50f": "[\"~#shape\",[\"^ \",\"~:y\",82.00000368146124,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",686.7500124588994,\"~:y\",82.00000368146122]],[\"^<\",[\"^ \",\"~:x\",986.7500224724797,\"~:y\",82.00000368146122]],[\"^<\",[\"^ \",\"~:x\",986.7500224724797,\"~:y\",382.0000008204383]],[\"^<\",[\"^ \",\"~:x\",686.7500124588994,\"~:y\",382.0000008204383]]],\"~: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\",\"~udb80df91-a3a3-803b-8007-8e379b5fd50f\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",686.7500124588994,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",686.7500124588994,\"~:y\",82.00000368146124,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",686.7500124588994,\"~:y1\",82.00000368146124,\"~:x2\",986.7500224724797,\"~:y2\",382.0000008204383]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^P\",\"#ff0000\",\"^Q\",1]],\"~:flip-x\",null,\"^J\",299.99999713897705,\"~:flip-y\",null]]",
"~u3b7d4c1f-3b79-80e5-8007-8d5e38c5a297": "[\"~#shape\",[\"^ \",\"~:y\",81.9999960520667,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",337.0000200882939,\"~:y\",81.99999605206669]],[\"^<\",[\"^ \",\"~:x\",637.0000301018742,\"~:y\",81.99999605206669]],[\"^<\",[\"^ \",\"~:x\",637.0000301018742,\"~:y\",381.99999319104376]],[\"^<\",[\"^ \",\"~:x\",337.0000200882939,\"~:y\",381.99999319104376]]],\"~: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,\"~:blur\",[\"^ \",\"~:id\",\"~u432cbb09-2ee7-80bf-8007-8d660b2f52ad\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~u3b7d4c1f-3b79-80e5-8007-8d5e38c5a297\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",337.0000200882939,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",337.0000200882939,\"~:y\",81.9999960520667,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",337.0000200882939,\"~:y1\",81.9999960520667,\"~:x2\",637.0000301018742,\"~:y2\",381.99999319104376]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^T\",\"#ff0000\",\"^U\",1]],\"~:flip-x\",null,\"^N\",299.99999713897705,\"~:flip-y\",null]]",
"~ufb1f50bf-1bff-8030-8007-8e8c3bd8fcd7": "[\"~#shape\",[\"^ \",\"~:y\",-629.9999999999998,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1037,\"~:y\",-630]],[\"^<\",[\"^ \",\"~:x\",1337.0000100135803,\"~:y\",-630]],[\"^<\",[\"^ \",\"~:x\",1337.0000100135803,\"~:y\",-330.0000028610228]],[\"^<\",[\"^ \",\"~:x\",1037,\"~:y\",-330.0000028610228]]],\"~: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,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~ufb1f50bf-1bff-8030-8007-8e8c3bd8fcd7\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:outer\",\"~:stroke-width\",10,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"~:inner\",\"^N\",10,\"^O\",\"#ff0000\",\"^P\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"~:center\",\"^N\",10,\"^O\",\"#4bff00\",\"^P\",1]],\"~:x\",1037,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1037,\"~:y\",-629.9999999999998,\"^8\",300.0000100135803,\"~:height\",299.999997138977,\"~:x1\",1037,\"~:y1\",-629.9999999999998,\"~:x2\",1337.0000100135803,\"~:y2\",-330.0000028610228]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^11\",\"#ff0000\",\"^12\",1]],\"~:flip-x\",null,\"^W\",299.999997138977,\"~:flip-y\",null]]",
"~u097859f1-ca3b-80ba-8007-8e8beb99a3f5": "[\"~#shape\",[\"^ \",\"~:y\",-626.0000057220459,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",687.0000123977661,\"~:y\",-626.000005722046]],[\"^<\",[\"^ \",\"~:x\",987.0000224113464,\"~:y\",-626.000005722046]],[\"^<\",[\"^ \",\"~:x\",987.0000224113464,\"~:y\",-326.00000858306885]],[\"^<\",[\"^ \",\"~:x\",687.0000123977661,\"~:y\",-326.00000858306885]]],\"~: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,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~u097859f1-ca3b-80ba-8007-8e8beb99a3f5\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-width\",10,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"^M\",\"^N\",10,\"^O\",\"#ff0000\",\"^P\",1]],\"~:x\",687.0000123977661,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",687.0000123977661,\"~:y\",-626.0000057220459,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",687.0000123977661,\"~:y1\",-626.0000057220459,\"~:x2\",987.0000224113464,\"~:y2\",-326.00000858306885]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^[\",\"#ff0000\",\"^10\",1]],\"~:flip-x\",null,\"^U\",299.99999713897705,\"~:flip-y\",null]]",
"~udb80df91-a3a3-803b-8007-8e37a71c9d28": "[\"~#shape\",[\"^ \",\"~:y\",450.99999806284904,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Ellipse\",\"~:width\",300.0000065565109,\"~:type\",\"~:circle\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",337.0000203847885,\"~:y\",450.99999806284904]],[\"^<\",[\"^ \",\"~:x\",637.0000269412994,\"~:y\",450.99999806284904]],[\"^<\",[\"^ \",\"~:x\",637.0000269412994,\"~:y\",751.0000142753124]],[\"^<\",[\"^ \",\"~:x\",337.0000203847885,\"~:y\",751.0000142753124]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e37b7ddd15c\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"^@\",\"~udb80df91-a3a3-803b-8007-8e37a71c9d28\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",337.0000203847885,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",337.0000203847885,\"~:y\",450.99999806284904,\"^8\",300.0000065565109,\"~:height\",300.0000162124634,\"~:x1\",337.0000203847885,\"~:y1\",450.99999806284904,\"~:x2\",637.0000269412994,\"~:y2\",751.0000142753124]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^P\",\"#ff0000\",\"^Q\",1]],\"~:flip-x\",null,\"^J\",300.0000162124634,\"~:flip-y\",null]]",
"~u18522c44-655d-8050-8007-8e89f4bdc0c5": "[\"~#shape\",[\"^ \",\"~:y\",-287.0000057220459,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",337.00002002716064,\"~:y\",-287.000005722046]],[\"^<\",[\"^ \",\"~:x\",637.000030040741,\"~:y\",-287.000005722046]],[\"^<\",[\"^ \",\"~:x\",637.000030040741,\"~:y\",12.999991416931152]],[\"^<\",[\"^ \",\"~:x\",337.00002002716064,\"~:y\",12.999991416931152]]],\"~: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,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~u18522c44-655d-8050-8007-8e89f4bdc0c5\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:outer\",\"~:stroke-width\",10,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"~:inner\",\"^N\",10,\"^O\",\"#ff0000\",\"^P\",1]],\"~:x\",337.00002002716064,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",337.00002002716064,\"~:y\",-287.0000057220459,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",337.00002002716064,\"~:y1\",-287.0000057220459,\"~:x2\",637.000030040741,\"~:y2\",12.999991416931152]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^10\",\"#ff0000\",\"^11\",1]],\"~:flip-x\",null,\"^V\",299.99999713897705,\"~:flip-y\",null]]",
"~udb80df91-a3a3-803b-8007-8e37c09b4084": "[\"~#shape\",[\"^ \",\"~:y\",450.99999806284904,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Ellipse\",\"~:width\",300.0000065565109,\"~:type\",\"~:circle\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",679.0000203847885,\"~:y\",450.99999806284904]],[\"^<\",[\"^ \",\"~:x\",979.0000269412994,\"~:y\",450.99999806284904]],[\"^<\",[\"^ \",\"~:x\",979.0000269412994,\"~:y\",751.0000142753124]],[\"^<\",[\"^ \",\"~:x\",679.0000203847885,\"~:y\",751.0000142753124]]],\"~: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\",\"~udb80df91-a3a3-803b-8007-8e37c09b4084\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",679.0000203847885,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",679.0000203847885,\"~:y\",450.99999806284904,\"^8\",300.0000065565109,\"~:height\",300.0000162124634,\"~:x1\",679.0000203847885,\"~:y1\",450.99999806284904,\"~:x2\",979.0000269412994,\"~:y2\",751.0000142753124]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^L\",\"#ff0000\",\"^M\",1]],\"~:flip-x\",null,\"^F\",300.0000162124634,\"~:flip-y\",null]]",
"~u18522c44-655d-8050-8007-8e89f4bdc0c4": "[\"~#shape\",[\"^ \",\"~:y\",-287.0000057220459,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",686.7500123977661,\"~:y\",-287.000005722046]],[\"^<\",[\"^ \",\"~:x\",986.7500224113464,\"~:y\",-287.000005722046]],[\"^<\",[\"^ \",\"~:x\",986.7500224113464,\"~:y\",12.999991416931152]],[\"^<\",[\"^ \",\"~:x\",686.7500123977661,\"~:y\",12.999991416931152]]],\"~: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,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~u18522c44-655d-8050-8007-8e89f4bdc0c4\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-width\",10,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",0.5],[\"^ \",\"^J\",\"^K\",\"^L\",\"^M\",\"^N\",10,\"^O\",\"#ff0000\",\"^P\",1]],\"~:x\",686.7500123977661,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",686.7500123977661,\"~:y\",-287.0000057220459,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",686.7500123977661,\"~:y1\",-287.0000057220459,\"~:x2\",986.7500224113464,\"~:y2\",12.999991416931152]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^[\",\"#ff0000\",\"^10\",1]],\"~:flip-x\",null,\"^U\",299.99999713897705,\"~:flip-y\",null]]",
"~udb80df91-a3a3-803b-8007-8e38034ff7c8": "[\"~#shape\",[\"^ \",\"~:y\",82.00000368146124,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1036.5000048295049,\"~:y\",82.00000368146122]],[\"^<\",[\"^ \",\"~:x\",1336.5000148430852,\"~:y\",82.00000368146122]],[\"^<\",[\"^ \",\"~:x\",1336.5000148430852,\"~:y\",382.0000008204383]],[\"^<\",[\"^ \",\"~:x\",1036.5000048295049,\"~:y\",382.0000008204383]]],\"~: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,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~udb80df91-a3a3-803b-8007-8e38034ff7c8\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-width\",10,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",1]],\"~:x\",1036.5000048295049,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1036.5000048295049,\"~:y\",82.00000368146124,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",1036.5000048295049,\"~:y1\",82.00000368146124,\"~:x2\",1336.5000148430852,\"~:y2\",382.0000008204383]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^[\",\"#ff0000\",\"^10\",1]],\"~:flip-x\",null,\"^U\",299.99999713897705,\"~:flip-y\",null]]",
"~u18522c44-655d-8050-8007-8e89f4bdc0c6": "[\"~#shape\",[\"^ \",\"~:y\",-287.0000057220459,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",300.0000100135803,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1036.5000047683716,\"~:y\",-287.000005722046]],[\"^<\",[\"^ \",\"~:x\",1336.500014781952,\"~:y\",-287.000005722046]],[\"^<\",[\"^ \",\"~:x\",1336.500014781952,\"~:y\",12.999991416931152]],[\"^<\",[\"^ \",\"~:x\",1036.5000047683716,\"~:y\",12.999991416931152]]],\"~: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,\"~:blur\",[\"^ \",\"~:id\",\"~udb80df91-a3a3-803b-8007-8e380b12ac2a\",\"^9\",\"~:layer-blur\",\"~:value\",7,\"~:hidden\",false],\"~:r1\",0,\"^B\",\"~u18522c44-655d-8050-8007-8e89f4bdc0c6\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-width\",10,\"~:stroke-color\",\"#333fbd\",\"~:stroke-opacity\",1],[\"^ \",\"^J\",\"^K\",\"^L\",\"~:outer\",\"^N\",10,\"^O\",\"#ff0000\",\"^P\",1]],\"~:x\",1036.5000047683716,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1036.5000047683716,\"~:y\",-287.0000057220459,\"^8\",300.0000100135803,\"~:height\",299.99999713897705,\"~:x1\",1036.5000047683716,\"~:y1\",-287.0000057220459,\"~:x2\",1336.500014781952,\"~:y2\",12.999991416931152]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1],[\"^ \",\"^10\",\"#ff0000\",\"^11\",1]],\"~:flip-x\",null,\"^V\",299.99999713897705,\"~:flip-y\",null]]"
}
},
"~:id": "~ub15901d7-d46d-8056-8007-8d5e34fc1f0d",
"~:name": "Page 1"
}
},
"~:id": "~ub15901d7-d46d-8056-8007-8d5e34fc1f0c",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@ export const WASM_FLAGS = [
export class WasmWorkspacePage extends WorkspacePage {
static async init(page) {
await super.init(page);
await WorkspacePage.mockConfigFlags(page, WASM_FLAGS);
await WasmWorkspacePage.mockConfigFlags(page, WASM_FLAGS);
await page.addInitScript(() => {
document.addEventListener("penpot:wasm:loaded", () => {
@@ -27,6 +27,14 @@ export class WasmWorkspacePage extends WorkspacePage {
});
}
static async mockConfigFlags(page, flags) {
await super.mockConfigFlags(page, [...WASM_FLAGS, ...flags]);
}
async mockConfigFlags(flags) {
return WasmWorkspacePage.mockConfigFlags(this.page, flags);
}
constructor(page) {
super(page);
this.canvas = page.getByTestId("canvas-wasm-shapes");

View File

@@ -459,8 +459,8 @@ export class WorkspacePage extends BaseWebSocketPage {
await this.page.mouse.up();
}
async clickLeafLayer(name, clickOptions = {}) {
const layer = this.layers.getByText(name).first();
async clickLeafLayer(name, clickOptions = {}, index = 0) {
const layer = this.layers.getByText(name).nth(index);
await layer.waitFor();
await layer.click(clickOptions);
await this.page.waitForTimeout(500);
@@ -471,10 +471,11 @@ export class WorkspacePage extends BaseWebSocketPage {
await this.clickLeafLayer(name, clickOptions);
}
async clickToggableLayer(name, clickOptions = {}) {
async clickToggableLayer(name, clickOptions = {}, index = 0) {
const layer = this.layers
.getByTestId("layer-row")
.filter({ hasText: name });
.filter({ hasText: name })
.nth(index);
const button = layer.getByTestId("toggle-content");
await expect(button).toBeVisible();

View File

@@ -165,6 +165,7 @@ test("Updates canvas background", async ({ page }) => {
});
await canvasBackgroundInput.fill("FABADA");
await workspace.page.keyboard.press("Enter");
await workspace.waitForFirstRenderWithoutUI();
await expect(workspace.canvas).toHaveScreenshot();
});
@@ -196,7 +197,7 @@ test("Renders a file with blurs applied to any kind of shape", async ({
test("Renders a file with shadows applied to any kind of shape", async ({
page,
}) => {
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("render-wasm/get-file-shadows.json");
@@ -290,6 +291,24 @@ test("Renders a file with nested clipping frames", async ({ page }) => {
await expect(workspace.canvas).toHaveScreenshot();
});
test("Renders clipped frames with strokes correctly (no double painting)", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile(
"render-wasm/get-file-frame-strokes-opacity.json",
);
await workspace.goToWorkspace({
id: "3144ac7c-a5cc-80e8-8007-8bbb29a4e56e",
pageId: "3144ac7c-a5cc-80e8-8007-8bbb29a510ac",
});
await workspace.waitForFirstRenderWithoutUI();
await expect(workspace.canvas).toHaveScreenshot();
});
test("Renders a clipped frame with a large blur drop shadow", async ({
page,
}) => {
@@ -305,3 +324,35 @@ test("Renders a clipped frame with a large blur drop shadow", async ({
await expect(workspace.canvas).toHaveScreenshot();
});
test("Renders a file with solid, dotted, dashed and mixed stroke styles", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("render-wasm/get-file-stroke-styles.json");
await workspace.goToWorkspace({
id: "b888b894-3697-80d3-8006-51cc8a55c200",
pageId: "b888b894-3697-80d3-8006-51cc8a55c210",
});
await workspace.waitForFirstRenderWithoutUI();
await expect(workspace.canvas).toHaveScreenshot();
});
test("Renders shapes with multiple fills and blur", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("render-wasm/get-file-fill-blend-blurs.json");
await workspace.goToWorkspace({
id: "b15901d7-d46d-8056-8007-8d5e34fc1f0c",
pageId: "b15901d7-d46d-8056-8007-8d5e34fc1f0d",
});
await workspace.waitForFirstRenderWithoutUI();
await expect(workspace.canvas).toHaveScreenshot();
});

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 360 KiB

After

Width:  |  Height:  |  Size: 348 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 220 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 126 KiB

View File

@@ -0,0 +1,33 @@
import { test, expect } from "@playwright/test";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
test.beforeEach(async ({ page }) => {
await WasmWorkspacePage.init(page);
});
test("BUG 13267 - Component instance is not synced with parent for geometry changes", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockGetFile("components/get-file-13267.json");
await workspacePage.goToWorkspace({
fileId: "e9c84e12-dd29-80fc-8007-86d559dced7f",
pageId: "e9c84e12-dd29-80fc-8007-86d559dced80",
});
// Create a component instance
await workspacePage.clickLeafLayer("A Component");
await workspacePage.page.keyboard.press("ControlOrMeta+d");
// Select the main component
await workspacePage.clickLeafLayer("A Component", {}, 1);
const rotationInput = workspacePage.rightSidebar.getByTestId("rotation").getByRole("textbox");
await rotationInput.fill("45");
await rotationInput.press("Enter");
// Select the instance rect
await workspacePage.clickToggableLayer("A Component", {}, 0);
await workspacePage.clickLeafLayer("Rectangle");
await expect(rotationInput).toHaveValue("45");
});

3868
frontend/pnpm-lock.yaml generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -8,3 +8,4 @@ packages:
- "packages/mousetrap"
- "packages/tokenscript"
- "text-editor"
- "packages/ui"

View File

@@ -18,6 +18,7 @@
<meta name="twitter:creator" content="@penpotapp">
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)">
<link id="theme" href="css/main.css?version={{& version_tag}}" rel="stylesheet" type="text/css" />
<link href="css/ui.css?ts={{& ts}}" rel="stylesheet" type="text/css" />
{{#isDebug}}
<link href="css/debug.css?version={{& version_tag}}" rel="stylesheet" type="text/css" />
{{/isDebug}}

View File

@@ -4,8 +4,6 @@
set -ex
export INCLUDE_STORYBOOK=${BUILD_STORYBOOK:-no};
export INCLUDE_WASM=${BUILD_WASM:-yes};
export EXTRA_PARAMS=$SHADOWCLJS_EXTRA_PARAMS;
export BUILD_DATE=$(date -R);
@@ -18,6 +16,8 @@ export VERSION_TAG="${VERSION}-${BUILD_TS}";
# performant code on macros (example: rumext)
export NODE_ENV=production;
rm -rf node_modules;
corepack enable;
corepack install;
pnpm install;
@@ -28,10 +28,17 @@ rm -rf resources/public;
mkdir -p resources/public;
mkdir -p target/dist;
# Build render wasm binary
pushd ../render-wasm;
./build
popd
pushd ../mcp;
rm -rf node_modules;
./scripts/setup
WS_URI="/mcp/ws" pnpm run --filter "mcp-plugin" build:multi-user
popd;
pnpm run build:app:main $EXTRA_PARAMS;
pnpm run build:app:libs;
pnpm run build:app:assets;
@@ -40,8 +47,6 @@ sed -i "s/\.\/render.js/.\/render.js?version=$VERSION_TAG/g" resources/public/js
rsync -avr resources/public/ target/dist/
if [ "$INCLUDE_STORYBOOK" = "yes" ]; then
# build storybook
pnpm run build:storybook || exit 1;
rsync -avr storybook-static/ target/dist/storybook-static;
fi
# Include the MCP plugin on the bundle
mkdir -p target/dist/plugins/mcp/;
rsync -avr ../mcp/packages/plugin/dist/ target/dist/plugins/mcp/

View File

@@ -1,6 +1,20 @@
import * as esbuild from "esbuild";
import { readFile } from "node:fs/promises";
/**
* esbuild plugin to watch a directory recursively
*/
const watchExtraDirPlugin = {
name: 'watch-extra-dir',
setup(build) {
build.onLoad({ filter: /target\/index.js/, namespace: 'file' }, async (args) => {
return {
watchDirs: ["packages/ui/dist"],
};
});
}
};
const filter =
/react-virtualized[/\\]dist[/\\]es[/\\]WindowScroller[/\\]utils[/\\]onScroll\.js$/;
@@ -36,7 +50,7 @@ const config = {
js: '"use strict";\nvar global = globalThis;',
},
outfile: "resources/public/js/libs.js",
plugins: [fixReactVirtualized, rebuildNotify],
plugins: [fixReactVirtualized, rebuildNotify, watchExtraDirPlugin],
};
async function watch() {

View File

@@ -13,7 +13,7 @@ export VERSION_TAG="${VERSION}-${BUILD_TS}";
export NODE_ENV=production;
corepack enable;
corepack install || exit 1;
pnpm install || exit 1;
corepack install;
pnpm install;
pnpm run build:storybook || exit 1;
pnpm run build:storybook;

View File

@@ -8,7 +8,7 @@
(:require
["@tokens-studio/sd-transforms" :as sd-transforms]
["style-dictionary$default" :as sd]
[app.common.files.tokens :as cft]
[app.common.files.tokens :as cfo]
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.time :as ct]
@@ -85,7 +85,7 @@
[value]
(let [number? (or (number? value)
(numeric-string? value))
parsed-value (cft/parse-token-value value)
parsed-value (cfo/parse-token-value value)
out-of-bounds (or (>= (:value parsed-value) sm/max-safe-int)
(<= (:value parsed-value) sm/min-safe-int))]
@@ -111,7 +111,7 @@
"Parses `value` of a number `sd-token` into a map like `{:value 1 :unit \"px\"}`.
If the `value` is not parseable and/or has missing references returns a map with `:errors`."
[value]
(let [parsed-value (cft/parse-token-value value)
(let [parsed-value (cfo/parse-token-value value)
out-of-bounds (or (>= (:value parsed-value) sm/max-safe-int)
(<= (:value parsed-value) sm/min-safe-int))]
(if (and parsed-value (not out-of-bounds))
@@ -129,7 +129,7 @@
If the `value` is parseable but is out of range returns a map with `warnings`."
[value]
(let [missing-references? (seq (cto/find-token-value-references value))
parsed-value (cft/parse-token-value value)
parsed-value (cfo/parse-token-value value)
out-of-scope (not (<= 0 (:value parsed-value) 1))
references (seq (cto/find-token-value-references value))]
(cond (and parsed-value (not out-of-scope))
@@ -153,7 +153,7 @@
If the `value` is parseable but is out of range returns a map with `warnings`."
[value]
(let [missing-references? (seq (cto/find-token-value-references value))
parsed-value (cft/parse-token-value value)
parsed-value (cfo/parse-token-value value)
out-of-scope (< (:value parsed-value) 0)]
(cond
(and parsed-value (not out-of-scope))
@@ -251,7 +251,7 @@
:font-size-value font-size-value})]
(or error
(try
(when-let [{:keys [unit value]} (cft/parse-token-value line-height-value)]
(when-let [{:keys [unit value]} (cfo/parse-token-value line-height-value)]
(case unit
"%" (/ value 100)
"px" (/ value font-size-value)

View File

@@ -179,6 +179,56 @@
(map #(get objects %))
(reduce get-ignore-tree nil))))
(defn calculate-ignore-tree-wasm
"Retrieves a map with the flag `ignore-geometry?` given a tree of modifiers"
[transforms objects]
(letfn [(get-ignore-tree
([ignore-tree shape]
(let [shape-id (dm/get-prop shape :id)
transformed-shape (gsh/apply-transform shape (get transforms shape-id))
root
(if (:component-root shape)
shape
(ctn/get-component-shape objects shape {:allow-main? true}))
transformed-root
(if (:component-root shape)
transformed-shape
(gsh/apply-transform root (get transforms (:id root))))]
(get-ignore-tree ignore-tree shape transformed-shape root transformed-root)))
([ignore-tree shape root transformed-root]
(let [shape-id (dm/get-prop shape :id)
transformed-shape (gsh/apply-transform shape (get transforms shape-id))]
(get-ignore-tree ignore-tree shape transformed-shape root transformed-root)))
([ignore-tree shape transformed-shape root transformed-root]
(let [shape-id (dm/get-prop shape :id)
ignore-tree
(cond-> ignore-tree
(and (some? root) (ctk/in-component-copy? shape))
(assoc
shape-id
(check-delta shape root transformed-shape transformed-root)))
set-child
(fn [ignore-tree child]
(get-ignore-tree ignore-tree child root transformed-root))]
(->> (:shapes shape)
(map (d/getf objects))
(reduce set-child ignore-tree)))))]
;; we check twice because we want only to search parents of components but once the
;; tree is traversed we only want to process the objects in components
(->> (keys transforms)
(map #(get objects %))
(reduce get-ignore-tree nil))))
(defn assoc-position-data
[shape position-data old-shape]
(let [deltav (gpt/to-vec (gpt/point (:selrect old-shape))
@@ -625,17 +675,6 @@
(let [objects (dsh/lookup-page-objects state)
ignore-tree
(calculate-ignore-tree modif-tree objects)
options
(-> params
(assoc :reg-objects? true)
(assoc :ignore-tree ignore-tree)
;; Attributes that can change in the transform. This
;; way we don't have to check all the attributes
(assoc :attrs transform-attrs))
geometry-entries
(parse-geometry-modifiers modif-tree)
@@ -645,6 +684,17 @@
transforms
(into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?))
ignore-tree
(calculate-ignore-tree-wasm transforms objects)
options
(-> params
(assoc :reg-objects? true)
(assoc :ignore-tree ignore-tree)
;; Attributes that can change in the transform. This
;; way we don't have to check all the attributes
(assoc :attrs transform-attrs))
modif-tree
(propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state))

View File

@@ -104,7 +104,7 @@
(watch [_ state _]
(let [page-id (or page-id (:current-page-id state))
objects (dsh/lookup-page-objects state page-id)
ids (->> ids (filter #(contains? objects %)))]
ids (->> ids (remove uuid/zero?) (filter #(contains? objects %)))]
(if (d/not-empty? ids)
(let [modif-tree (dwm/create-modif-tree ids (ctm/reflow-modifiers))]
(if (features/active-feature? state "render-wasm/v1")

View File

@@ -32,6 +32,7 @@
[app.main.features :as features]
[app.main.fonts :as fonts]
[app.main.router :as rt]
[app.render-wasm.api :as wasm.api]
[app.util.text-editor :as ted]
[app.util.text.content.styles :as styles]
[app.util.timers :as ts]
@@ -46,6 +47,7 @@
(def ^function create-editor editor.v2/create)
(def ^function set-editor-root! editor.v2/setRoot)
(def ^function get-editor-root editor.v2/getRoot)
(def ^function is-empty? editor.v2/isEmpty)
(def ^function dispose! editor.v2/dispose)
(declare v2-update-text-shape-content)
@@ -507,12 +509,12 @@
ptk/EffectEvent
(effect [_ state _]
(when (features/active-feature? state "text-editor/v2")
(let [instance (:workspace-editor state)
styles (some-> (editor.v2/getCurrentStyle instance)
(styles/get-styles-from-style-declaration :removed-mixed true)
((comp update-node-fn migrate-node))
(styles/attrs->styles))]
(editor.v2/applyStylesToSelection instance styles)))))))
(when-let [instance (:workspace-editor state)]
(let [styles (some-> (editor.v2/getCurrentStyle instance)
(styles/get-styles-from-style-declaration :removed-mixed true)
((comp update-node-fn migrate-node))
(styles/attrs->styles))]
(editor.v2/applyStylesToSelection instance styles))))))))
;; --- RESIZE UTILS
@@ -776,21 +778,30 @@
(rx/of (v2-update-text-editor-styles id attrs)))
(when (features/active-feature? state "render-wasm/v1")
;; This delay is to give time for the font to be correctly rendered
;; in wasm.
(cond->> (rx/of (dwwt/resize-wasm-text id))
(contains? attrs :font-id)
(rx/delay 200)))))))
(rx/concat
;; Apply style to selected spans and sync content
(when (wasm.api/text-editor-is-active?)
(let [span-attrs (select-keys attrs txt/text-node-attrs)]
(when (not (empty? span-attrs))
(let [result (wasm.api/apply-style-to-selection span-attrs)]
(when result
(rx/of (v2-update-text-shape-content
(:shape-id result) (:content result)
:update-name? true)))))))
;; Resize (with delay for font-id changes)
(cond->> (rx/of (dwwt/resize-wasm-text id))
(contains? attrs :font-id)
(rx/delay 200))))))))
ptk/EffectEvent
(effect [_ state _]
(when (features/active-feature? state "text-editor/v2")
(let [instance (:workspace-editor state)
attrs-to-override (some-> (editor.v2/getCurrentStyle instance)
(styles/get-styles-from-style-declaration))
overriden-attrs (merge attrs-to-override attrs)
styles (styles/attrs->styles overriden-attrs)]
(editor.v2/applyStylesToSelection instance styles))))))
(when-let [instance (:workspace-editor state)]
(let [attrs-to-override (some-> (editor.v2/getCurrentStyle instance)
(styles/get-styles-from-style-declaration))
overriden-attrs (merge attrs-to-override attrs)
styles (styles/attrs->styles overriden-attrs)]
(editor.v2/applyStylesToSelection instance styles)))))))
(defn update-all-attrs
[ids attrs]
@@ -905,15 +916,22 @@
(update-in state [:workspace-text-modifier shape-id] {:position-data position-data}))))
(defn v2-update-text-shape-content
[id content & {:keys [update-name? name finalize?]
:or {update-name? false name nil finalize? false}}]
[id content & {:keys [update-name? name finalize? save-undo?]
:or {update-name? false name nil finalize? false save-undo? true}}]
(ptk/reify ::v2-update-text-shape-content
ptk/WatchEvent
(watch [_ state _]
(if (features/active-feature? state "render-wasm/v1")
(let [objects (dsh/lookup-page-objects state)
shape (get objects id)
new-shape? (nil? (:content shape))]
new-shape? (nil? (:content shape))
prev-content (:content shape)
has-prev-content? (not (nil? (:prev-content shape)))
has-content? (when-not new-shape?
(v2-content-has-text? content))
did-has-content? (when-not new-shape?
(v2-content-has-text? prev-content))]
(rx/concat
(rx/of
(dwsh/update-shapes
@@ -921,10 +939,16 @@
(fn [shape]
(let [new-shape (-> shape
(assoc :content content)
(cond-> (and has-content?
has-prev-content?)
(dissoc :prev-content))
(cond-> (and did-has-content?
(not has-content?))
(assoc :prev-content prev-content))
(cond-> (and update-name? (some? name))
(assoc :name name)))]
new-shape))
{:undo-group (when new-shape? id)})
{:save-undo? save-undo? :undo-group (when new-shape? id)})
(if (and (not= :fixed (:grow-type shape)) finalize?)
(dwm/apply-wasm-modifiers
@@ -937,8 +961,16 @@
(when finalize?
(rx/concat
(when (and (not (v2-content-has-text? content)) (some? id))
(when (and (not has-content?) (some? id))
(rx/of
(when has-prev-content?
(dwsh/update-shapes
[id]
(fn [shape]
(let [new-shape (-> shape
(assoc :content (:prev-content shape)))]
new-shape))
{:save-undo? false}))
(dws/deselect-shape id)
(dwsh/delete-shapes #{id})))
(rx/of (dwt/finish-transform))))))

View File

@@ -7,7 +7,7 @@
(ns app.main.data.workspace.tokens.application
(:require
[app.common.data :as d]
[app.common.files.tokens :as cft]
[app.common.files.tokens :as cfo]
[app.common.types.component :as ctk]
[app.common.types.shape.layout :as ctsl]
[app.common.types.shape.radius :as ctsr]
@@ -648,11 +648,11 @@
shape-ids (d/nilv (keys shapes) [])
any-variant? (->> shapes vals (some ctk/is-variant?) boolean)
resolved-value (get-in resolved-tokens [(cft/token-identifier token) :resolved-value])
resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value])
resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value)
resolved-value)
tokenized-attributes (cft/attributes-map attributes token)
tokenized-attributes (cfo/attributes-map attributes token)
type (:type token)]
(rx/concat
(rx/of
@@ -711,7 +711,7 @@
ptk/WatchEvent
(watch [_ _ _]
(rx/of
(let [remove-token #(when % (cft/remove-attributes-for-token attributes token-name %))]
(let [remove-token #(when % (cfo/remove-attributes-for-token attributes token-name %))]
(dwsh/update-shapes
shape-ids
(fn [shape]
@@ -740,7 +740,7 @@
(get token-properties (:type token))
unapply-tokens?
(cft/shapes-token-applied? token shapes (or attrs all-attributes attributes))
(cfo/shapes-token-applied? token shapes (or attrs all-attributes attributes))
shape-ids (map :id shapes)]

View File

@@ -6,7 +6,7 @@
(ns app.main.data.workspace.tokens.color
(:require
[app.common.files.tokens :as cft]
[app.common.files.tokens :as cfo]
[app.config :as cf]
[app.main.data.tinycolor :as tinycolor]
[app.main.data.tokenscript :as ts]))
@@ -22,5 +22,5 @@
(if (contains? cf/flags :tokenscript)
(when (and resolved-value (ts/color-symbol? resolved-value))
(ts/color-symbol->penpot-color resolved-value))
(when (and resolved-value (cft/color-token? token))
(when (and resolved-value (cfo/color-token? token))
(color-bullet-color resolved-value))))

View File

@@ -195,27 +195,30 @@
(defn create-token-set
[token-set]
(assert (ctob/token-set? token-set) "a token set is required") ;; TODO should check token-set-schema?
(ptk/reify ::create-token-set
ptk/UpdateEvent
(update [_ state]
;; Clear possible local state
(update state :workspace-tokens dissoc :token-set-new-path))
ptk/WatchEvent
(watch [it state _]
(let [data (dsh/lookup-file-data state)
tokens-lib (get data :tokens-lib)
token-set (ctob/rename token-set (ctob/normalize-set-name (ctob/get-name token-set)))]
(if (and tokens-lib (ctob/get-set-by-name tokens-lib (ctob/get-name token-set)))
(rx/of (ntf/show {:content (tr "errors.token-set-already-exists")
:type :toast
:level :error
:timeout 9000}))
(let [changes (-> (pcb/empty-changes it)
(pcb/with-library-data data)
(pcb/set-token-set (ctob/get-id token-set) token-set))]
(rx/of (set-selected-token-set-id (ctob/get-id token-set))
(dch/commit-changes changes))))))))
(let [data (dsh/lookup-file-data state)
changes (-> (pcb/empty-changes it)
(pcb/with-library-data data)
(pcb/set-token-set (ctob/get-id token-set) token-set))]
(rx/of (set-selected-token-set-id (ctob/get-id token-set))
(dch/commit-changes changes))))))
(defn rename-token-set
[token-set new-name]
(assert (ctob/token-set? token-set) "a token set is required") ;; TODO should check token-set-schema after renaming?
(assert (string? new-name) "a new name is required") ;; TODO should assert normalized-set-name?
(ptk/reify ::update-token-set
ptk/WatchEvent
(watch [it state _]
(let [data (dsh/lookup-file-data state)
changes (-> (pcb/empty-changes it)
(pcb/with-library-data data)
(pcb/rename-token-set (ctob/get-id token-set) new-name))]
(rx/of (set-selected-token-set-id (ctob/get-id token-set))
(dch/commit-changes changes))))))
(defn rename-token-set-group
[set-group-path set-group-fname]
@@ -227,26 +230,6 @@
(rx/of
(dch/commit-changes changes))))))
(defn update-token-set
[token-set name]
(ptk/reify ::update-token-set
ptk/WatchEvent
(watch [it state _]
(let [data (dsh/lookup-file-data state)
name (ctob/normalize-set-name name (ctob/get-name token-set))
tokens-lib (get data :tokens-lib)]
(if (ctob/get-set-by-name tokens-lib name)
(rx/of (ntf/show {:content (tr "errors.token-set-already-exists")
:type :toast
:level :error
:timeout 9000}))
(let [changes (-> (pcb/empty-changes it)
(pcb/with-library-data data)
(pcb/rename-token-set (ctob/get-id token-set) name))]
(rx/of (set-selected-token-set-id (ctob/get-id token-set))
(dch/commit-changes changes))))))))
(defn duplicate-token-set
[id]
(ptk/reify ::duplicate-token-set
@@ -522,7 +505,7 @@
(ctob/get-id token-set)
token-id)]
(let [tokens (vals (ctob/get-tokens tokens-lib (ctob/get-id token-set)))
unames (map :name tokens)
unames (map :name tokens) ;; TODO: add function duplicate-token in tokens-lib
suffix (tr "workspace.tokens.duplicate-suffix")
copy-name (cfh/generate-unique-name (:name token) unames :suffix suffix)
new-token (-> token

View File

@@ -409,7 +409,7 @@
modif-tree (dwm/build-modif-tree ids objects get-modifier)]
(if (features/active-feature? state "render-wasm/v1")
(rx/of (dwm/apply-wasm-modifiers modif-tree {:ignore-snap-pixel true}))
(rx/of (dwm/apply-wasm-modifiers modif-tree (assoc options :ignore-snap-pixel true)))
(let [modif-tree (gm/set-objects-modifiers modif-tree objects)]
(rx/of (dwm/apply-modifiers* objects modif-tree nil options)))))))))
@@ -621,7 +621,7 @@
(->> stream
(rx/filter (ptk/type? ::dws/duplicate-selected))
(rx/take 1)
(rx/map #(start-move from-position))))))
(rx/map #(start-move from-position nil true))))))
(defn get-drop-cell
[target-frame objects position]
@@ -641,8 +641,9 @@
(dom/set-property! node "transform" (gmt/translate-matrix move-vector))))))
(defn start-move
([from-position] (start-move from-position nil))
([from-position ids]
([from-position] (start-move from-position nil false))
([from-position ids] (start-move from-position ids false))
([from-position ids from-duplicate?]
(ptk/reify ::start-move
ptk/UpdateEvent
(update [_ state]
@@ -750,38 +751,47 @@
(rx/share))]
(if (features/active-feature? state "render-wasm/v1")
(rx/merge
(->> modifiers-stream
(rx/map
(fn [[modifiers snap-ignore-axis]]
(dwm/set-wasm-modifiers modifiers :snap-ignore-axis snap-ignore-axis))))
(let [duplicate-stopper
(->> ms/mouse-position-alt
(rx/mapcat
(fn [alt?]
(if (and alt? (not from-duplicate?))
(rx/of true)
(rx/empty)))))]
(rx/merge
(->> modifiers-stream
(rx/take-until duplicate-stopper)
(rx/map
(fn [[modifiers snap-ignore-axis]]
(dwm/set-wasm-modifiers modifiers :snap-ignore-axis snap-ignore-axis))))
(->> move-stream
(rx/with-latest-from ms/mouse-position-alt)
(rx/filter (fn [[_ alt?]] alt?))
(rx/take 1)
(rx/mapcat
(fn [[_ alt?]]
(if (and (not duplicate-move-started?) alt?)
(rx/of (start-move-duplicate from-position)
(dws/duplicate-selected false true))
(rx/empty)))))
(->> move-stream
(rx/with-latest-from ms/mouse-position-alt)
(rx/filter (fn [[_ alt?]] alt?))
(rx/take 1)
(rx/mapcat
(fn [[_ alt?]]
(if (and (not from-duplicate?) alt?)
(rx/of (start-move-duplicate from-position)
(dws/duplicate-selected false true))
(rx/empty)))))
;; Last event will write the modifiers creating the changes
(->> move-stream
(rx/last)
(rx/with-latest-from modifiers-stream)
(rx/mapcat
(fn [[[_ target-frame drop-index drop-cell] [modifiers snap-ignore-axis]]]
(let [undo-id (js/Symbol)]
(rx/of
(dwu/start-undo-transaction undo-id)
(dwm/apply-wasm-modifiers modifiers
:snap-ignore-axis snap-ignore-axis
:undo-transation? false)
(move-shapes-to-frame ids target-frame drop-index drop-cell)
(finish-transform)
(dwu/commit-undo-transaction undo-id)))))))
;; Last event will write the modifiers creating the changes
(->> move-stream
(rx/last)
(rx/take-until duplicate-stopper)
(rx/with-latest-from modifiers-stream)
(rx/mapcat
(fn [[[_ target-frame drop-index drop-cell] [modifiers snap-ignore-axis]]]
(let [undo-id (js/Symbol)]
(rx/of
(dwu/start-undo-transaction undo-id)
(dwm/apply-wasm-modifiers modifiers
:snap-ignore-axis snap-ignore-axis
:undo-transation? false)
(move-shapes-to-frame ids target-frame drop-index drop-cell)
(finish-transform)
(dwu/commit-undo-transaction undo-id))))))))
(rx/merge
(->> modifiers-stream

View File

@@ -10,6 +10,7 @@
This exists to avoid circular deps:
workspace.texts -> workspace.libraries -> workspace.texts"
(:require
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
@@ -17,6 +18,7 @@
[app.main.data.helpers :as dsh]
[app.main.data.workspace.modifiers :as dwm]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.api.fonts :as wasm.fonts]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
@@ -62,6 +64,84 @@
(rx/of (dwm/apply-wasm-modifiers (resize-wasm-text-modifiers shape)))
(rx/empty))))))
(defn resize-wasm-text-debounce-commit
[]
(ptk/reify ::resize-wasm-text-debounce-commit
ptk/WatchEvent
(watch [_ state _]
(let [ids (get state ::resize-wasm-text-debounce-ids)
objects (dsh/lookup-page-objects state)
modifiers
(reduce
(fn [modifiers id]
(let [shape (get objects id)]
(cond-> modifiers
(and (some? shape)
(cfh/text-shape? shape)
(not= :fixed (:grow-type shape)))
(merge (resize-wasm-text-modifiers shape)))))
{}
ids)]
(if (not (empty? modifiers))
(rx/of (dwm/apply-wasm-modifiers modifiers))
(rx/empty))))))
;; This event will debounce the resize events so, if there are many, they
;; are processed at the same time and not one-by-one. This will improve
;; performance because it's better to make only one layout calculation instead
;; of (potentialy) hundreds.
(defn resize-wasm-text-debounce-inner
[id]
(let [cur-event (js/Symbol)]
(ptk/reify ::resize-wasm-text-debounce-inner
ptk/UpdateEvent
(update [_ state]
(-> state
(update ::resize-wasm-text-debounce-ids (fnil conj []) id)
(cond-> (nil? (::resize-wasm-text-debounce-event state))
(assoc ::resize-wasm-text-debounce-event cur-event))))
ptk/WatchEvent
(watch [_ state stream]
(if (= (::resize-wasm-text-debounce-event state) cur-event)
(let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))]
(rx/concat
(rx/merge
(->> stream
(rx/filter (ptk/type? ::resize-wasm-text-debounce-inner))
(rx/debounce 40)
(rx/take 1)
(rx/map #(resize-wasm-text-debounce-commit))
(rx/take-until stopper))
(rx/of (resize-wasm-text-debounce-inner id)))
(rx/of #(dissoc %
::resize-wasm-text-debounce-ids
::resize-wasm-text-debounce-event))))
(rx/empty))))))
(defn resize-wasm-text-debounce
[id]
(ptk/reify ::resize-wasm-text-debounce
ptk/WatchEvent
(watch [_ state _]
(let [page-id (:current-page-id state)
objects (dsh/lookup-page-objects state page-id)
content (dm/get-in objects [id :content])
fonts (wasm.fonts/get-content-fonts content)
fonts-loaded?
(->> fonts
(every?
(fn [font]
(let [font-data (wasm.fonts/make-font-data font)]
(wasm.fonts/font-stored? font-data (:emoji? font-data))))))]
(if (not fonts-loaded?)
(->> (rx/of (resize-wasm-text-debounce id))
(rx/delay 20))
(rx/of (resize-wasm-text-debounce-inner id)))))))
(defn resize-wasm-text-all
"Resize all text shapes (auto-width/auto-height) from a collection of ids."
[ids]

View File

@@ -16,6 +16,7 @@
(def ^:private schema:icon-button
[:map
[:class {:optional true} :string]
[:tooltip-class {:optional true} [:maybe :string]]
[:icon-class {:optional true} :string]
[:icon
[:and :string [:fn #(contains? icon-list %)]]]
@@ -28,7 +29,7 @@
(mf/defc icon-button*
{::mf/schema schema:icon-button
::mf/memo true}
[{:keys [class icon icon-class variant aria-label children tooltip-placement] :rest props}]
[{:keys [class icon icon-class variant aria-label children tooltip-placement tooltip-class] :rest props}]
(let [variant
(d/nilv variant "primary")
@@ -49,6 +50,7 @@
:aria-labelledby tooltip-id})]
[:> tooltip* {:content aria-label
:class tooltip-class
:placement tooltip-placement
:id tooltip-id}
[:> :button props

View File

@@ -18,7 +18,6 @@
[app.main.ui.ds.controls.utilities.input-field :refer [input-field*]]
[app.main.ui.ds.controls.utilities.token-field :refer [token-field*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon* icon-list] :as i]
[app.main.ui.ds.tooltip :refer [tooltip*]]
[app.main.ui.formats :as fmt]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
@@ -638,27 +637,17 @@
:on-change store-raw-value
:variant "comfortable"
:disabled disabled
:slot-start (when (or icon text-icon)
:icon icon
:aria-label property
:slot-start (when text-icon
(mf/html
[:> tooltip*
{:content property
:id property}
(cond
icon
[:> icon*
{:icon-id icon
:size "s"
:aria-labelledby property
:class (stl/css :icon)}]
text-icon
[:div {:class (stl/css :text-icon)
:aria-labelledby property}
text-icon])]))
[:div {:class (stl/css :text-icon)}
text-icon]))
:slot-end (when-not disabled
(when (some? tokens)
(mf/html [:> icon-button* {:variant "ghost"
:icon i/tokens
:tooltip-class (stl/css :button-tooltip)
:class (stl/css :invisible-button)
:aria-label (tr "ds.inputs.numeric-input.open-token-list-dropdown")
:ref open-dropdown-ref
@@ -686,23 +675,19 @@
:disabled disabled
:on-blur on-blur
:class inner-class
:property property
:slot-start (when (or icon text-icon)
(mf/html
[:> tooltip*
{:content property
:id property}
(cond
icon
[:> icon*
{:icon-id icon
:size "s"
:aria-labelledby property
:class (stl/css :icon)}]
(cond
icon
[:> icon*
{:icon-id icon
:size "s"
:class (stl/css :icon)}]
text-icon
[:div {:class (stl/css :text-icon)
:aria-labelledby property}
text-icon])]))
text-icon
[:div {:class (stl/css :text-icon)}
text-icon])))
:token-wrapper-ref token-wrapper-ref
:token-detach-btn-ref token-detach-btn-ref
:detach-token detach-token})))]
@@ -737,40 +722,21 @@
(mf/with-effect [dropdown-options]
(mf/set-ref-val! options-ref dropdown-options))
(if (some? icon)
[:div {:class [class (stl/css :input-wrapper)]
:ref wrapper-ref}
[:div {:class [class (stl/css :input-wrapper)]
:ref wrapper-ref}
(if (and (some? token-applied)
(not= :multiple token-applied))
[:> token-field* token-props]
[:> input-field* input-props])
(if (and (some? token-applied)
(not= :multiple token-applied))
[:> token-field* token-props]
[:> input-field* input-props])
(when ^boolean is-open
(let [options (if (delay? dropdown-options) @dropdown-options dropdown-options)]
[:> options-dropdown* {:on-click on-option-click
:id listbox-id
:options options
:selected selected-id
:focused focused-id
:align align
:empty-to-end empty-to-end
:ref set-option-ref}]))]
[:div {:class [class (stl/css :input-wrapper)]
:ref wrapper-ref}
(if (and (some? token-applied)
(not= :multiple token-applied))
[:> token-field* token-props]
[:> input-field* input-props])
(when ^boolean is-open
(let [options (if (delay? dropdown-options) @dropdown-options dropdown-options)]
[:> options-dropdown* {:on-click on-option-click
:id listbox-id
:options options
:selected selected-id
:focused focused-id
:align align
:empty-to-end empty-to-end
:ref set-option-ref}]))])))
(when ^boolean is-open
(let [options (if (delay? dropdown-options) @dropdown-options dropdown-options)]
[:> options-dropdown* {:on-click on-option-click
:id listbox-id
:options options
:selected selected-id
:focused focused-id
:align align
:empty-to-end empty-to-end
:ref set-option-ref}]))]))

View File

@@ -55,3 +55,8 @@
--opacity-button: 1;
}
}
.button-tooltip {
inline-size: var($sz-28);
block-size: 100%;
}

View File

@@ -42,7 +42,6 @@
type (d/nilv type "text")
variant (d/nilv variant "dense")
tooltip-id (mf/use-id)
props (mf/spread-props props
{:class [class
(stl/css-case
@@ -54,15 +53,11 @@
"true")
:aria-describedby (when has-hint
(str id "-hint"))
:aria-labelledby tooltip-id
:type (d/nilv type "text")
:id id
:max-length (d/nilv max-length max-input-length)})
props (if (and aria-label (not (some? icon)))
(mf/spread-props props
{:aria-label aria-label})
(mf/spread-props props
{:aria-labelledby tooltip-id}))
inside-class (stl/css-case :input-wrapper true
:has-hint has-hint
:hint-type-hint (= hint-type "hint")
@@ -83,11 +78,14 @@
(when (some? slot-start)
slot-start)
(when (some? icon)
(if aria-label
[:> tooltip* {:content aria-label
:id tooltip-id}
[:> icon* {:icon-id icon :class (stl/css :icon) :on-click on-icon-click}]]
[:> icon* {:icon-id icon :class (stl/css :icon) :on-click on-icon-click}]))
[:> "input" props]
[:> icon* {:icon-id icon
:class (stl/css :icon)
:size "s"
:on-click on-icon-click}])
(if aria-label
[:> tooltip* {:content aria-label
:id tooltip-id}
[:> "input" props]]
[:> "input" props])
(when (some? slot-end)
slot-end)]))

View File

@@ -118,4 +118,5 @@
.icon {
color: var(--color-foreground-secondary);
min-inline-size: var(--sp-l);
}

View File

@@ -22,6 +22,7 @@
[:class {:optional true} [:maybe :string]]
[:id {:optional true} [:maybe :string]]
[:label {:optional true} [:maybe :string]]
[:property {:optional true} [:maybe :string]]
[:value :any]
[:disabled {:optional true} :boolean]
[:slot-start {:optional true} [:maybe some?]]
@@ -35,7 +36,7 @@
{::mf/schema schema:token-field}
[{:keys [id label value slot-start disabled class
on-click on-token-key-down on-blur detach-token
token-wrapper-ref token-detach-btn-ref on-focus]}]
token-wrapper-ref token-detach-btn-ref on-focus property]}]
(let [set-active? (some? id)
content (if set-active?
label
@@ -50,37 +51,42 @@
(when-not ^boolean disabled
(dom/prevent-default event)
(dom/focus! (mf/ref-val token-wrapper-ref)))))]
[:> tooltip* {:content property
:class (stl/css :token-field-wrapper)
:id (dm/str default-id "-input")}
[:div {:class [class (stl/css-case :token-field true
:with-icon (some? slot-start)
:token-field-disabled disabled)]
:on-click focus-wrapper
:disabled disabled
:on-key-down on-token-key-down
:ref token-wrapper-ref
:on-blur on-blur
:on-focus on-focus
:aria-labelledby (dm/str default-id "-input")
:tab-index (if disabled -1 0)}
[:div {:class [class (stl/css-case :token-field true
:with-icon (some? slot-start)
:token-field-disabled disabled)]
:on-click focus-wrapper
:disabled disabled
:on-key-down on-token-key-down
:ref token-wrapper-ref
:on-blur on-blur
:on-focus on-focus
:tab-index (if disabled -1 0)}
(when (some? slot-start) slot-start)
(when (some? slot-start) slot-start)
[:div {:class (stl/css :content-wrapper)}
[:> tooltip* {:content content
:id (dm/str id "-pill")}
[:button {:on-click on-click
:class (stl/css-case :pill true
:no-set-pill (not set-active?)
:pill-disabled disabled)
:disabled disabled
:aria-labelledby (dm/str id "-pill")
:on-key-down on-token-key-down}
value
(when-not set-active?
[:div {:class (stl/css :pill-dot)}])]]]
[:> tooltip* {:content content
:id (dm/str id "-pill")}
[:button {:on-click on-click
:class (stl/css-case :pill true
:no-set-pill (not set-active?)
:pill-disabled disabled)
:disabled disabled
:aria-labelledby (dm/str id "-pill")
:on-key-down on-token-key-down}
value
(when-not set-active?
[:div {:class (stl/css :pill-dot)}])]]
(when-not ^boolean disabled
[:> icon-button* {:variant "ghost"
:class (stl/css :invisible-button)
:icon i/broken-link
:ref token-detach-btn-ref
:aria-label (tr "ds.inputs.token-field.detach-token")
:on-click detach-token}])]))
(when-not ^boolean disabled
[:> icon-button* {:variant "ghost"
:class (stl/css :invisible-button)
:tooltip-class (stl/css :button-tooltip)
:icon i/broken-link
:ref token-detach-btn-ref
:aria-label (tr "ds.inputs.token-field.detach-token")
:on-click detach-token}])]]))

View File

@@ -9,6 +9,7 @@
@use "ds/typography.scss" as t;
@use "ds/colors.scss" as *;
@use "ds/mixins.scss" as *;
@use "ds/_utils.scss" as *;
.token-field {
--token-field-bg-color: var(--color-background-tertiary);
@@ -37,6 +38,9 @@
--token-field-outline-color: var(--color-accent-primary);
}
}
.token-field-wrapper {
inline-size: 100%;
}
.with-icon {
grid-template-columns: auto 1fr;
@@ -132,3 +136,12 @@
--opacity-button: 1;
}
}
.content-wrapper {
inline-size: 100%;
}
.button-tooltip {
inline-size: px2rem(28);
block-size: 100%;
}

View File

@@ -171,7 +171,7 @@
(def ^:private schema:tooltip
[:map
[:class {:optional true} :string]
[:class {:optional true} [:maybe :string]]
[:id {:optional true} :string]
[:offset {:optional true} :int]
[:delay {:optional true} :int]
@@ -184,6 +184,7 @@
[{:keys [class id children content placement offset delay] :rest props}]
(let [internal-id
(mf/use-id)
trigger-ref (mf/use-ref nil)
id
(d/nilv id internal-id)
@@ -204,19 +205,23 @@
(mf/use-fn
(mf/deps id placement offset)
(fn [event]
(clear-schedule schedule-ref)
(when-let [tooltip (dom/get-element id)]
(let [origin-brect
(->> (dom/get-target event)
(dom/get-bounding-rect))
update-position
(fn []
(let [new-placement (update-tooltip-position tooltip placement origin-brect offset)]
(when (not= new-placement placement)
(reset! placement* new-placement))))]
(let [current (dom/get-current-target event)
related (dom/get-related-target event)
is-node? (fn [node] (and node (.-nodeType node)))]
(when-not (and related (is-node? related) (.contains current related))
(clear-schedule schedule-ref)
(when-let [tooltip (dom/get-element id)]
(let [origin-brect
(dom/get-bounding-rect (mf/ref-val trigger-ref))
(add-schedule schedule-ref delay update-position)))))
update-position
(fn []
(let [new-placement (update-tooltip-position tooltip placement origin-brect offset)]
(when (not= new-placement placement)
(reset! placement* new-placement))))]
(add-schedule schedule-ref delay update-position)))))))
on-hide
(mf/use-fn
@@ -252,6 +257,7 @@
:on-focus on-show
:on-blur on-hide
:on-key-down handle-key-down
:ref trigger-ref
:class [class (stl/css :tooltip-trigger)]
:aria-describedby id})
content

View File

@@ -49,7 +49,6 @@
(mf/defc form-submit*
[{:keys [disabled on-submit] :rest props}]
(let [form (mf/use-ctx context)
disabled? (or (and (some? form)
(or (not (:valid @form))

View File

@@ -11,6 +11,7 @@
[app.common.math :as mth]
[app.common.types.color :as cc]
[app.util.dom :as dom]
[app.util.keyboard :as kbd]
[rumext.v2 :as mf]))
(defn parse-hex
@@ -67,34 +68,60 @@
(when (some? val)
(setup-hex-color val))))
apply-property-change
(fn [property val]
(let [val (case property
:s (/ val 100)
:v (value->hsv-value val)
:alpha (/ val 100)
val)]
(cond
(= property :alpha)
(on-change {:alpha val})
(#{:r :g :b} property)
(let [{:keys [r g b]} (merge color (hash-map property val))
hex (cc/rgb->hex [r g b])
[h s v] (cc/hex->hsv hex)]
(on-change {:hex hex
:h h :s s :v v
:r r :g g :b b}))
:else
(let [{:keys [h s v]} (merge color (hash-map property val))
hex (cc/hsv->hex [h s v])
[r g b] (cc/hex->rgb hex)]
(on-change {:hex hex
:h h :s s :v v
:r r :g g :b b})))))
on-change-property
(fn [property max-value]
(fn [e]
(let [val (-> e dom/get-target-val d/parse-double (mth/clamp 0 max-value))
val (case property
:s (/ val 100)
:v (value->hsv-value val)
val)]
(when (not (nil? val))
(if (#{:r :g :b} property)
(let [{:keys [r g b]} (merge color (hash-map property val))
hex (cc/rgb->hex [r g b])
[h s v] (cc/hex->hsv hex)]
(on-change {:hex hex
:h h :s s :v v
:r r :g g :b b}))
(let [val (-> e dom/get-target-val d/parse-double (mth/clamp 0 max-value))]
(when (some? val)
(apply-property-change property val)))))
(let [{:keys [h s v]} (merge color (hash-map property val))
hex (cc/hsv->hex [h s v])
[r g b] (cc/hex->rgb hex)]
(on-change {:hex hex
:h h :s s :v v
:r r :g g :b b})))))))
on-key-down-step
(fn [max-value on-step]
(fn [e]
(let [up? (kbd/up-arrow? e)
down? (kbd/down-arrow? e)]
(when (and (or up? down?)
(or (kbd/shift? e) (kbd/alt? e)))
(dom/prevent-default e)
(when-let [current-value (-> e dom/get-target-val d/parse-double)]
(let [step (cond
(kbd/shift? e) (if up? 10 -10)
(kbd/alt? e) (if up? 0.1 -0.1))
new-value (mth/clamp (+ current-value step) 0 max-value)
node (dom/get-target e)]
(dom/set-value! node new-value)
(on-step new-value)))))))
on-change-opacity
(fn [e]
(when-let [new-alpha (-> e dom/get-target-val (mth/clamp 0 100) (/ 100))]
(on-change {:alpha new-alpha})))]
on-key-down-property
(fn [property max-value]
(on-key-down-step max-value #(apply-property-change property %)))]
;; Updates the inputs values when a property is changed in the parent
@@ -127,7 +154,8 @@
:min 0
:max 255
:default-value red
:on-change (on-change-property :r 255)}]]
:on-change (on-change-property :r 255)
:on-key-down (on-key-down-property :r 255)}]]
[:div {:class (stl/css :input-wrapper)}
[:label {:for "green-value" :class (stl/css :input-label)} "G"]
[:input {:id "green-value"
@@ -136,7 +164,8 @@
:min 0
:max 255
:default-value green
:on-change (on-change-property :g 255)}]]
:on-change (on-change-property :g 255)
:on-key-down (on-key-down-property :g 255)}]]
[:div {:class (stl/css :input-wrapper)}
[:label {:for "blue-value" :class (stl/css :input-label)} "B"]
[:input {:id "blue-value"
@@ -145,7 +174,8 @@
:min 0
:max 255
:default-value blue
:on-change (on-change-property :b 255)}]]]
:on-change (on-change-property :b 255)
:on-key-down (on-key-down-property :b 255)}]]]
[:*
[:div {:class (stl/css :input-wrapper)}
@@ -156,7 +186,8 @@
:min 0
:max 360
:default-value hue
:on-change (on-change-property :h 360)}]]
:on-change (on-change-property :h 360)
:on-key-down (on-key-down-property :h 360)}]]
[:div {:class (stl/css :input-wrapper)}
[:label {:for "saturation-value" :class (stl/css :input-label)} "S"]
[:input {:id "saturation-value"
@@ -166,7 +197,8 @@
:max 100
:step 1
:default-value saturation
:on-change (on-change-property :s 100)}]]
:on-change (on-change-property :s 100)
:on-key-down (on-key-down-property :s 100)}]]
[:div {:class (stl/css :input-wrapper)}
[:label {:for "value-value" :class (stl/css :input-label)} "V"]
[:input {:id "value-value"
@@ -175,7 +207,8 @@
:min 0
:max 100
:default-value value
:on-change (on-change-property :v 100)}]]])]
:on-change (on-change-property :v 100)
:on-key-down (on-key-down-property :v 100)}]]])]
[:div {:class (stl/css :hex-alpha-wrapper)}
[:div {:class (stl/css-case :input-wrapper true
:hex true)}
@@ -195,4 +228,5 @@
:step 1
:max 100
:default-value (if (= alpha :multiple) "" alpha)
:on-change on-change-opacity}]])]]))
:on-change (on-change-property :alpha 100)
:on-key-down (on-key-down-property :alpha 100)}]])]]))

View File

@@ -95,10 +95,10 @@
[]
(let [plugins-state* (mf/use-state #(preg/plugins-list))
plugins-state @plugins-state*
plugins-state (deref plugins-state*)
plugin-url* (mf/use-state "")
plugin-url @plugin-url*
plugin-url* (mf/use-state "")
plugin-url (deref plugin-url*)
fetching-manifest? (mf/use-state false)

View File

@@ -117,7 +117,8 @@
(st/emit! (dwt/v2-update-text-shape-content shape-id content
:update-name? update-name?
:name generated-name
:finalize? true))))
:finalize? true
:save-undo? false))))
(let [container-node (mf/ref-val container-ref)]
(dom/set-style! container-node "opacity" 0)))
@@ -135,15 +136,21 @@
on-needs-layout
(fn []
(when-let [content (content/dom->cljs (dwt/get-editor-root instance))]
(st/emit! (dwt/v2-update-text-shape-content shape-id content :update-name? true)))
(st/emit! (dwt/v2-update-text-shape-content shape-id content
:update-name? true
:save-undo? false)))
;; FIXME: We need to find a better way to trigger layout changes.
#_(st/emit!
(dwt/v2-update-text-shape-position-data shape-id [])))
on-change
(fn []
(when-let [content (content/dom->cljs (dwt/get-editor-root instance))]
(st/emit! (dwt/v2-update-text-shape-content shape-id content :update-name? true))))
(let [is-empty? (dwt/is-empty? instance)
save-undo? (not is-empty?)]
(when-let [content (content/dom->cljs (dwt/get-editor-root instance))]
(st/emit! (dwt/v2-update-text-shape-content shape-id content
:update-name? true
:save-undo? save-undo?)))))
on-clipboard-change
(fn [event]
@@ -247,16 +254,16 @@
:ref container-ref
:data-testid "text-editor-container"
:style {:width "var(--editor-container-width)"
:height "var(--editor-container-height)"}
;; We hide the editor when is blurred because otherwise the
;; selection won't let us see the underlying text. Use opacity
;; because display or visibility won't allow to recover focus
;; afterwards.
:height "var(--editor-container-height)"}}
;; We hide the editor when is blurred because otherwise the
;; selection won't let us see the underlying text. Use opacity
;; because display or visibility won't allow to recover focus
;; afterwards.
;; IMPORTANT! This is now done through DOM mutations (see
;; on-blur and on-focus) but I keep this for future references.
;; :opacity (when @blurred 0)}}
;; IMPORTANT! This is now done through DOM mutations (see
;; on-blur and on-focus) but I keep this for future references.
;; :opacity (when @blurred 0)}}
}
[:div
{:class (dm/str
"mousetrap "
@@ -345,11 +352,9 @@
max-height (max height selrect-height)
valign (-> shape :content :vertical-align)
y (:y selrect)
y (if (and valign (> height selrect-height))
(case valign
"bottom" (- y (- height selrect-height))
"center" (- y (/ (- height selrect-height) 2))
y)
y (case valign
"bottom" (+ y (- selrect-height height))
"center" (+ y (/ (- selrect-height height) 2))
y)]
[(assoc selrect :y y :width max-width :height max-height) transform])

View File

@@ -29,6 +29,23 @@
color: transparent;
// Match Skia's text layout precision: prevent browser text-size
// adjustments and ensure consistent kerning across browsers.
text-size-adjust: none;
-webkit-text-size-adjust: none;
font-kerning: normal;
&::selection,
*::selection {
color: transparent;
-webkit-text-fill-color: transparent; // WebKit/Safari
}
&::-moz-selection,
*::-moz-selection {
color: transparent;
}
[data-itype="paragraph"] {
line-height: inherit;
user-select: text;

View File

@@ -300,7 +300,7 @@
on-drop
(mf/use-fn
(mf/deps id index objects expanded? selected)
(mf/deps id objects expanded? selected)
(fn [side _data]
(let [single? (= (count selected) 1)
same? (and single? (= (first selected) id))]
@@ -321,14 +321,18 @@
[parent-id _] (ctn/find-valid-parent-and-frame-ids parent-id objects (map #(get objects %) selected) false files)
parent (get objects parent-id)
parent (get objects parent-id)
current-index (d/index-of (:shapes parent) id)
to-index (cond
(= side :center) 0
(and expanded? (= side :bot) (d/not-empty? (:shapes shape))) (count (:shapes parent))
(= side :top) (inc index)
:else index)]
(st/emit! (dw/relocate-selected-shapes parent-id to-index)))))))
;; target not found in parent (while lazy loading)
(neg? current-index) nil
(= side :top) (inc current-index)
:else current-index)]
(when (some? to-index)
(st/emit! (dw/relocate-selected-shapes parent-id to-index))))))))
on-hold
(mf/use-fn
@@ -417,11 +421,7 @@
current @children-count*
new-count (min total (max current chunk-size min-count))]
(reset! children-count* new-count))
(reset! children-count* 0)))
(fn []
(when-let [obs ^js @observer-var]
(.disconnect obs)
(reset! observer-var nil))))
(reset! children-count* 0))))
;; Re-observe sentinel whenever children-count changes (sentinel moves)
;; and (shapes item) to reconnect observer after shape changes
@@ -502,4 +502,4 @@
:component-child? component-tree?}])))
(when (< children-count (count (:shapes item)))
[:div {:ref lazy-ref
:style {:min-height 1}}])])]))
:class (stl/css :lazy-load-sentinel)}])])]))

View File

@@ -298,3 +298,11 @@
.filtered {
min-inline-size: $sz-12;
}
.lazy-load-sentinel {
min-height: 1px;
pointer-events: none;
}
.lazy-load-sentinel {
min-height: 1px;
pointer-events: none;
}

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