Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Andrey Antukh
2025-10-31 12:13:29 +01:00
62 changed files with 1089 additions and 330 deletions

View File

@@ -85,6 +85,20 @@
- Fix internal Error when selecting a set by name in the token theme editor [Taiga #12310](https://tree.taiga.io/project/penpot/issue/12310) - Fix internal Error when selecting a set by name in the token theme editor [Taiga #12310](https://tree.taiga.io/project/penpot/issue/12310)
- Fix drag & drop functionality is swapping instead or reordering [Taiga #12254](https://tree.taiga.io/project/penpot/issue/12254) - Fix drag & drop functionality is swapping instead or reordering [Taiga #12254](https://tree.taiga.io/project/penpot/issue/12254)
- Fix variants not syncronizing tokens on switch [Taiga #12290](https://tree.taiga.io/project/penpot/issue/12290) - Fix variants not syncronizing tokens on switch [Taiga #12290](https://tree.taiga.io/project/penpot/issue/12290)
- Fix incorrect behavior of Alt + Drag for variants [Taiga #12309](https://tree.taiga.io/project/penpot/issue/12309)
- Fix text override is lost after switch [Taiga #12269](https://tree.taiga.io/project/penpot/issue/12269)
- Fix exporting a board crashing the app [Taiga #12384](https://tree.taiga.io/project/penpot/issue/12384)
- Fix nested variant in a component doesn't keep inherited overrides [Taiga #12299](https://tree.taiga.io/project/penpot/issue/12299)
- Fix selected colors not showing colors from children shapes in multiple selection [Taiga #12384](https://tree.taiga.io/project/penpot/issue/12385)
- Fix scrollbar issue in design tab [Taiga #12367](https://tree.taiga.io/project/penpot/issue/12367)
- Fix library update notificacions showing when they should not [Taiga #12397](https://tree.taiga.io/project/penpot/issue/12397)
- Fix remove flex button doesnt work within variant [Taiga #12314](https://tree.taiga.io/project/penpot/issue/12314)
- Fix an error translation [Taiga #12402](https://tree.taiga.io/project/penpot/issue/12402)
- Fix problem with certain text input in some editable labels (pages, components, tokens...) being in conflict with the drag/drop functionality [Taiga #12316](https://tree.taiga.io/project/penpot/issue/12316)
- Fix not controlled theme renaming [Taiga #12411](https://tree.taiga.io/project/penpot/issue/12411)
- Fix paste without selection sends the new element in the back [Taiga #12382](https://tree.taiga.io/project/penpot/issue/12382)
- Fix options button does not work for comments created in the lower part of the screen [Taiga #12422](https://tree.taiga.io/project/penpot/issue/12422)
- Fix problem when checking usage with removed teams [Taiga #12442](https://tree.taiga.io/project/penpot/issue/12442)
## 2.10.1 ## 2.10.1

View File

@@ -749,7 +749,7 @@
l.version l.version
FROM libs AS l FROM libs AS l
INNER JOIN project AS p ON (p.id = l.project_id) INNER JOIN project AS p ON (p.id = l.project_id)
WHERE l.deleted_at IS NULL OR l.deleted_at > now();") WHERE l.deleted_at IS NULL;")
(defn get-file-libraries (defn get-file-libraries
[conn file-id] [conn file-id]

View File

@@ -291,7 +291,7 @@
(defn- get-unread-comment-threads (defn- get-unread-comment-threads
[cfg profile-id team-id] [cfg profile-id team-id]
(let [profile (-> (db/get cfg :profile {:id profile-id}) (let [profile (-> (db/get cfg :profile {:id profile-id} ::db/remove-deleted false)
(profile/decode-row)) (profile/decode-row))
notify (or (-> profile :props :notifications :dashboard-comments) :all) notify (or (-> profile :props :notifications :dashboard-comments) :all)
result (case notify result (case notify

View File

@@ -45,6 +45,7 @@
params {:email email params {:email email
:fullname fullname :fullname fullname
:is-active true :is-active true
:is-demo true
:deleted-at (ct/in-future (cf/get-deletion-delay)) :deleted-at (ct/in-future (cf/get-deletion-delay))
:password (derive-password password) :password (derive-password password)
:props {}} :props {}}

View File

@@ -107,7 +107,9 @@
(defn get-profile (defn get-profile
"Get profile by id. Throws not-found exception if no profile found." "Get profile by id. Throws not-found exception if no profile found."
[conn id & {:as opts}] [conn id & {:as opts}]
(-> (db/get-by-id conn :profile id opts) ;; NOTE: We need to set ::db/remove-deleted to false because demo profiles
;; are created with a set deleted-at value
(-> (db/get-by-id conn :profile id (assoc opts ::db/remove-deleted false))
(decode-row))) (decode-row)))
;; --- MUTATION: Update Profile (own) ;; --- MUTATION: Update Profile (own)
@@ -473,13 +475,17 @@
p.fullname AS name, p.fullname AS name,
p.email AS email p.email AS email
FROM team_profile_rel AS tpr1 FROM team_profile_rel AS tpr1
JOIN team as t
ON tpr1.team_id = t.id
JOIN team_profile_rel AS tpr2 JOIN team_profile_rel AS tpr2
ON (tpr1.team_id = tpr2.team_id) ON (tpr1.team_id = tpr2.team_id)
JOIN profile AS p JOIN profile AS p
ON (tpr2.profile_id = p.id) ON (tpr2.profile_id = p.id)
WHERE tpr1.profile_id = ? WHERE tpr1.profile_id = ?
AND tpr1.is_owner IS true AND tpr1.is_owner IS true
AND tpr2.can_edit IS true") AND tpr2.can_edit IS true
AND NOT t.is_default
AND t.deleted_at IS NULL")
(sv/defmethod ::get-subscription-usage (sv/defmethod ::get-subscription-usage
{::doc/added "2.9"} {::doc/added "2.9"}

View File

@@ -37,14 +37,14 @@
;; --- Helpers & Specs ;; --- Helpers & Specs
(def ^:private sql:team-permissions (def ^:private sql:team-permissions
"select tpr.is_owner, "SELECT tpr.is_owner,
tpr.is_admin, tpr.is_admin,
tpr.can_edit tpr.can_edit
from team_profile_rel as tpr FROM team_profile_rel AS tpr
join team as t on (t.id = tpr.team_id) JOIN team AS t ON (t.id = tpr.team_id)
where tpr.profile_id = ? WHERE tpr.profile_id = ?
and tpr.team_id = ? AND tpr.team_id = ?
and t.deleted_at is null") AND t.deleted_at IS NULL")
(defn get-permissions (defn get-permissions
[conn profile-id team-id] [conn profile-id team-id]

View File

@@ -2801,7 +2801,7 @@
(defn generate-duplicate-changes (defn generate-duplicate-changes
"Prepare objects to duplicate: generate new id, give them unique names, "Prepare objects to duplicate: generate new id, give them unique names,
move to the desired position, and recalculate parents and frames as needed." move to the desired position, and recalculate parents and frames as needed."
[changes all-objects page ids delta libraries library-data file-id & {:keys [variant-props]}] [changes all-objects page ids delta libraries library-data file-id & {:keys [variant-props alt-duplication?]}]
(let [shapes (map (d/getf all-objects) ids) (let [shapes (map (d/getf all-objects) ids)
unames (volatile! (cfh/get-used-names (:objects page))) unames (volatile! (cfh/get-used-names (:objects page)))
update-unames! (fn [new-name] (vswap! unames conj new-name)) update-unames! (fn [new-name] (vswap! unames conj new-name))
@@ -2811,10 +2811,22 @@
;; we calculate a new one because the components will have created new shapes. ;; we calculate a new one because the components will have created new shapes.
ids-map (into {} (map #(vector % (uuid/next))) all-ids) ids-map (into {} (map #(vector % (uuid/next))) all-ids)
;; If there is an alt-duplication of a variant, change its parent to root
;; so the copy is made as a child of root
;; This is because inside a variant-container can't be a copy
shapes (map (fn [shape]
(if (and alt-duplication? (ctk/is-variant? shape))
(assoc shape :parent-id uuid/zero :frame-id nil)
shape))
shapes)
changes (-> changes changes (-> changes
(pcb/with-page page) (pcb/with-page page)
(pcb/with-objects all-objects) (pcb/with-objects all-objects)
(pcb/with-library-data library-data)) (pcb/with-library-data library-data))
changes changes
(->> shapes (->> shapes
(reduce #(generate-duplicate-shape-change %1 (reduce #(generate-duplicate-shape-change %1

View File

@@ -28,11 +28,7 @@
(pcb/update-component (pcb/update-component
changes (:id component) changes (:id component)
(fn [component] (fn [component]
(d/update-in-when component [:variant-properties pos] (d/update-in-when component [:variant-properties pos] #(assoc % :name new-name)))
(fn [property]
(-> property
(assoc :name new-name)
(with-meta nil)))))
{:apply-changes-local-library? true})) {:apply-changes-local-library? true}))
changes changes
related-components))) related-components)))

View File

@@ -11,7 +11,8 @@
[app.common.types.container :as ctn] [app.common.types.container :as ctn]
[app.common.types.file :as ctf] [app.common.types.file :as ctf]
[app.common.types.variant :as ctv] [app.common.types.variant :as ctv]
[app.common.uuid :as uuid])) [app.common.uuid :as uuid]
[clojure.set :as set]))
(defn generate-add-new-variant (defn generate-add-new-variant
[changes shape variant-id new-component-id new-shape-id prop-num] [changes shape variant-id new-component-id new-shape-id prop-num]
@@ -137,6 +138,27 @@
ref-shape ref-shape
(find-shape-ref-child-of ref-shape-container libraries ref-shape parent-id)))) (find-shape-ref-child-of ref-shape-container libraries ref-shape parent-id))))
(defn- get-ref-chain
"Returns a vector with the shape ref chain including itself"
[container libraries shape]
(loop [chain [shape]
current shape]
(if-let [ref (ctf/find-ref-shape nil container libraries current :with-context? true)]
(recur (conj chain ref) ref)
chain)))
(defn- add-touched-from-ref-chain
"Adds to the :touched attr of a shape the content of
the :touched of all its chain of ref shapes"
[container libraries shape]
(let [chain (get-ref-chain container libraries shape)
more-touched (->> chain
(map :touched)
(remove nil?)
(apply set/union)
(remove ctk/swap-slot?)
set)]
(update shape :touched #(set/union (or % #{}) more-touched))))
(defn generate-keep-touched (defn generate-keep-touched
"This is used as part of the switch process, when you switch from "This is used as part of the switch process, when you switch from
@@ -157,6 +179,9 @@
;; they will be moved without change when ;; they will be moved without change when
;; managing their swapped ancestor ;; managing their swapped ancestor
orig-touched (->> original-shapes orig-touched (->> original-shapes
;; Add to each shape also the touched of its ref chain
(map #(add-touched-from-ref-chain container libraries %))
(filter (comp seq :touched))
(remove (remove
#(child-of-swapped? % #(child-of-swapped? %
page-objects page-objects

View File

@@ -249,12 +249,16 @@
(defn equal-attrs? (defn equal-attrs?
"Given a text structure, and a map of attrs, check that all the internal attrs in "Given a text structure, and a map of attrs, check that all the internal attrs in
paragraphs and sentences have the same attrs" paragraphs and sentences have the same attrs"
[item attrs] ([item attrs]
(let [item-attrs (dissoc item :text :type :key :children)] ;; Ignore the root attrs of the content. We only want to check paragraphs and sentences
(and (equal-attrs? item attrs true))
(or (empty? item-attrs) ([item attrs ignore?]
(= attrs (dissoc item :text :type :key :children))) (let [item-attrs (dissoc item :text :type :key :children)]
(every? #(equal-attrs? % attrs) (:children item))))) (and
(or ignore?
(empty? item-attrs)
(= attrs (dissoc item :text :type :key :children)))
(every? #(equal-attrs? % attrs false) (:children item))))))
(defn get-first-paragraph-text-attrs (defn get-first-paragraph-text-attrs
"Given a content text structure, extract it's first paragraph "Given a content text structure, extract it's first paragraph

View File

@@ -68,8 +68,6 @@
;; The rect has width 15 after the switch ;; The rect has width 15 after the switch
(t/is (= (:width rect02') 15)))) (t/is (= (:width rect02') 15))))
(t/deftest test-basic-switch-override (t/deftest test-basic-switch-override
(let [;; ==== Setup (let [;; ==== Setup
file (-> (thf/sample-file :file1) file (-> (thf/sample-file :file1)
@@ -104,7 +102,6 @@
;; The override is keept: The copy still has width 25 after the switch ;; The override is keept: The copy still has width 25 after the switch
(t/is (= (:width copy01') 25)))) (t/is (= (:width copy01') 25))))
(t/deftest test-switch-with-override (t/deftest test-switch-with-override
(let [;; ==== Setup (let [;; ==== Setup
file (-> (thf/sample-file :file1) file (-> (thf/sample-file :file1)

View File

@@ -247,6 +247,11 @@ services:
networks: networks:
- penpot - penpot
environment:
# You can increase the max memory size if you have sufficient resources,
# although this should not be necessary.
- VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu
## A mailcatch service, used as temporal SMTP server. You can access via HTTP to the ## A mailcatch service, used as temporal SMTP server. You can access via HTTP to the
## port 1080 for read all emails the penpot platform has sent. Should be only used as a ## port 1080 for read all emails the penpot platform has sent. Should be only used as a
## temporal solution while no real SMTP provider is configured. ## temporal solution while no real SMTP provider is configured.

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -23,7 +23,7 @@ Flags and evironment variables are also used together; for example:
```bash ```bash
# This flag enables the use of SMTP email # This flag enables the use of SMTP email
PENPOT_FLAGS: enable-smtp PENPOT_FLAGS: [...] enable-smtp
# These environment variables configure the specific SMPT service # These environment variables configure the specific SMPT service
# Backend # Backend
@@ -36,7 +36,7 @@ the exporter, or all of them; on the other hand, **environment variables** are c
each specific service. For example: each specific service. For example:
```bash ```bash
PENPOT_FLAGS: enable-login-with-google PENPOT_FLAGS: [...] enable-login-with-google
# Backend # Backend
PENPOT_GOOGLE_CLIENT_ID: <client-id> PENPOT_GOOGLE_CLIENT_ID: <client-id>
@@ -56,7 +56,7 @@ Penpot uses anonymous telemetries from the self-hosted instances to improve the
Consider sharing these anonymous telemetries enabling the corresponding flag: Consider sharing these anonymous telemetries enabling the corresponding flag:
```bash ```bash
PENPOT_FLAGS: enable-telemetries PENPOT_FLAGS: [...] enable-telemetries
``` ```
## Registration and authentication ## Registration and authentication
@@ -402,7 +402,7 @@ This is implemented as specific locations in the penpot-front Nginx. If your org
in a 100% air-gapped environment, you can use the following configuration: in a 100% air-gapped environment, you can use the following configuration:
```bash ```bash
PENPOT_FLAGS: enable-air-gapped-conf PENPOT_FLAGS: [...] enable-air-gapped-conf
``` ```
When Penpot starts, it will leave out the Nginx configuration related to external requests. This means that, When Penpot starts, it will leave out the Nginx configuration related to external requests. This means that,
@@ -459,11 +459,15 @@ POSTGRES_PASSWORD: penpot
### Storage ### Storage
Storage refers to storing the user uploaded assets. Storage refers to storing the user uploaded different objects in Penpot (assets, file data,...).
Assets storage is implemented using "plugable" backends. Currently there are two Objects storage is implemented using "plugable" backends. Currently there are two
backends available: <code class="language-bash">fs</code> and <code class="language-bash">s3</code> (for AWS S3). backends available: <code class="language-bash">fs</code> and <code class="language-bash">s3</code> (for AWS S3).
__Since version 2.11.0__
The configuration variables related to storage has been renamed, `PENPOT_STORAGE_ASSETS_*` are now `PENPOT_OBJECTS_STORAGE_*`.
`PENPOT_ASSETS_STORAGE_BACKEND` becomes `PENPOT_OBJECTS_STORAGE_BACKEND` and its values now are `fs` and `s3` instead of `assets-fs` or `assets-s3`.
#### FS Backend (default) #### FS Backend (default)
This is the default backend when you use the official docker images and the default This is the default backend when you use the official docker images and the default
@@ -471,8 +475,8 @@ configuration looks like this:
```bash ```bash
# Backend # Backend
PENPOT_ASSETS_STORAGE_BACKEND: assets-fs PENPOT_OBJECTS_STORAGE_BACKEND: fs
PENPOT_STORAGE_ASSETS_FS_DIRECTORY: /opt/data/assets PENPOT_OBJECTS_STORAGE_FS_DIRECTORY: /opt/data/objects
``` ```
The main downside of this backend is the hard dependency on nginx approach to serve files The main downside of this backend is the hard dependency on nginx approach to serve files
@@ -485,7 +489,7 @@ configuration file][4] used in the docker images.
#### AWS S3 Backend #### AWS S3 Backend
This backend uses AWS S3 bucket for store the user uploaded assets. For use it you should This backend uses AWS S3 bucket for store the user uploaded objects. For use it you should
have an appropriate account on AWS cloud and have the credentials, region and the bucket. have an appropriate account on AWS cloud and have the credentials, region and the bucket.
This is how configuration looks for S3 backend: This is how configuration looks for S3 backend:
@@ -494,18 +498,36 @@ This is how configuration looks for S3 backend:
# Backend # Backend
AWS_ACCESS_KEY_ID: <you-access-key-id-here> AWS_ACCESS_KEY_ID: <you-access-key-id-here>
AWS_SECRET_ACCESS_KEY: <your-secret-access-key-here> AWS_SECRET_ACCESS_KEY: <your-secret-access-key-here>
PENPOT_ASSETS_STORAGE_BACKEND: assets-s3 PENPOT_OBJECTS_STORAGE_BACKEND: s3
PENPOT_STORAGE_ASSETS_S3_REGION: <aws-region> PENPOT_OBJECTS_STORAGE_S3_REGION: <aws-region>
PENPOT_STORAGE_ASSETS_S3_BUCKET: <bucket-name> PENPOT_OBJECTS_STORAGE_S3_BUCKET: <bucket-name>
# Optional if you want to use it with non AWS, S3 compatible service: # Optional if you want to use it with non AWS, S3 compatible service:
PENPOT_STORAGE_ASSETS_S3_ENDPOINT: <endpoint-uri> PENPOT_OBJECTS_STORAGE_S3_ENDPOINT: <endpoint-uri>
``` ```
<p class="advice"> <p class="advice">
These settings are equally useful if you have a Minio storage system. These settings are equally useful if you have a Minio storage system.
</p> </p>
### File Data Storage
__Since version 2.11.0__
You can change the default file data storage backend with `PENPOT_FILE_DATA_BACKEND` environment variable. Possible values are:
- `legacy-db`: the current default backend, continues storing the file data of files and snapshots in the same location as previous versions of Penpot (< 2.11.0), this is a conservative default behaviour and will be changed to `db` in next versions.
- `db`: stores the file data on an specific table (the future default backend).
- `storage`: stores the file data using the objects storage system (S3 or FS, depending on which one is configured)
This also comes with an additional feature that allows offload the "inactive" files on file storage backend and leaves the database only for the active files. To enable it, you should use the `enable-tiered-file-data-storage` flag and `db` as file data storage backend.
```bash
# Backend
PENPOT_FLAGS: [...] enable-tiered-file-data-storage
PENPOT_FILE_DATA_BACKEND: db
```
### Autosave ### Autosave
By default, Penpot stores manually saved versions indefinitely; these can be found in the History tab and can be renamed, restored, deleted, etc. Additionally, the default behavior of on-premise instances is to not keep automatic version history. This automatic behavior can be modified and adapted to each on-premise installation with the corresponding configuration. By default, Penpot stores manually saved versions indefinitely; these can be found in the History tab and can be renamed, restored, deleted, etc. Additionally, the default behavior of on-premise instances is to not keep automatic version history. This automatic behavior can be modified and adapted to each on-premise installation with the corresponding configuration.
@@ -517,7 +539,7 @@ You need to be very careful when configuring automatic versioning, as it can sig
This is how configuration looks for auto-file-snapshot This is how configuration looks for auto-file-snapshot
```bash ```bash
PENPOT_FLAGS: enable-auto-file-snapshot # Enable automatic version saving PENPOT_FLAGS: [...] enable-auto-file-snapshot # Enable automatic version saving
# Backend # Backend
PENPOT_AUTO_FILE_SNAPSHOT_EVERY: 5 # How many save operations trigger the auto-save-version? PENPOT_AUTO_FILE_SNAPSHOT_EVERY: 5 # How many save operations trigger the auto-save-version?

View File

@@ -1,5 +1,5 @@
--- ---
title: 1.1 Recommended storage title: 1.1 Recommended settings
desc: Learn recommended self-hosting settings, Docker & Kubernetes installs, configuration, and troubleshooting tips in Penpot's technical guide. desc: Learn recommended self-hosting settings, Docker & Kubernetes installs, configuration, and troubleshooting tips in Penpot's technical guide.
--- ---
@@ -10,3 +10,33 @@ Disk requirements depend on your usage, with the primary factors being database
As a rule of thumb, start with a **minimum** database size of **50GB** to **100GB** with elastic sizing capability — this configuration should adequately support up to 10 editors. For environments with **more than 10 users**, we recommend adding approximately **5GB** of capacity per additional editor. As a rule of thumb, start with a **minimum** database size of **50GB** to **100GB** with elastic sizing capability — this configuration should adequately support up to 10 editors. For environments with **more than 10 users**, we recommend adding approximately **5GB** of capacity per additional editor.
Keep in mind that database size doesn't grow strictly proportionally with user count, as it depends heavily on how Penpot is used and the complexity of files created. Most organizations begin with this baseline and elastic sizing approach, then monitor usage patterns monthly until resource requirements stabilize. Keep in mind that database size doesn't grow strictly proportionally with user count, as it depends heavily on how Penpot is used and the complexity of files created. Most organizations begin with this baseline and elastic sizing approach, then monitor usage patterns monthly until resource requirements stabilize.
# 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.
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:
```bash
## Recommended values for most Penpot instances.
## You can modify this value to follow your policies.
# Set maximum memory Valkey/Redis will use.
# Accepted units: b, k, kb, m, mb, g, gb
maxmemory 128mb
# Choose an eviction policy (see Valkey docs:
# https://valkey.io/topics/memory-optimization/ or for Redis
# https://redis.io/docs/latest/develop/reference/eviction/
# Common choices:
# noeviction, allkeys-lru, volatile-lru, allkeys-random, volatile-random,
# volatile-ttl, volatile-lfu, allkeys-lfu
#
# For Penpot, volatile-lfu is recommended
maxmemory-policy volatile-lfu
```
The `maxmemory` configuration directive specifies the maximum amount of memory to use for the cache data. If you are using a dedicated instance to host Valkey/Redis, we do not recommend using more than 60% of the available RAM.
With `maxmemory-policy` configuration directive, you can select the eviction policy you want to use when the limit set by `maxmemory` is reached. Penpot works fine with `volatile-lfu`, which evicts the least frequently used keys that have been marked as expired.

View File

@@ -423,6 +423,40 @@ ExtraBold Italic
<p>This token can be applied directly to a text element or be used as a reference in a Typography Composite Token.</p> <p>This token can be applied directly to a text element or be used as a reference in a Typography Composite Token.</p>
<h3 id="typography-composite-tokens">Typography composite token</h3>
<p><strong>Typography tokens</strong> are composite entities that group several text properties into a single token definition. They allow you to define and reuse complete text styles in a consistent way.</p>
<p>Each property within a typography token can either reference an existing <a href="#design-tokens-typography">individual typography token</a> (for example, <em>font-size</em> or <em>font-family</em>) or use a hardcoded value. The behavior and syntax of individual typography tokens are described in the previous section of this guide.</p>
<figure>
<img src="/img/design-tokens/36-tokens-composite-typography.webp" alt="Typography composite token" />
</figure>
<h4 id="reference-composite-token">Reference another Typography Composite Token</h4>
<p>You can also reference another existing <strong>Typography Composite Token</strong> instead of defining each property manually. When doing so, Penpot resolves all individual properties from the referenced token.</p>
<figure>
<img src="/img/design-tokens/34-tokens-composite-typography-alias.webp" alt="Typography composite token" />
</figure>
<h4 id="line-height-property">Line height property</h4>
<p>The <strong>Typography Token</strong> includes a <em>line-height</em> property, which is not available as an individual token. This is because line-height depends on the font size to be calculated properly. Make sure the <em>font-size</em> property is defined before setting <em>line-height</em>.</p>
<figure>
<img src="/img/design-tokens/35-tokens-composite-typography-lineheight.webp" alt="Typography composite token" />
</figure>
<p>Accepted values for the line-height input:</p>
<ul>
<li><strong>Unitless number:</strong> interpreted as a multiplier of the font size. This is Penpots default behavior.</li>
<li><strong>Percentage (%):</strong> converted internally to a multiplier.</li>
<li><strong>Pixel (px) or rem value:</strong> if using rem, Penpot calculates the proportion relative to the font size and converts it to a multiplier.</li>
<li><strong>References:</strong> you can also reference <a href="#design-tokens-number">number</a> or <a href="#design-tokens-dimensions">dimension</a> tokens.</li>
</ul>
<h4 id="apply-typography-token">Apply a Typography token</h4>
<p>A <strong>Typography composite token</strong> can be applied to a full text layer to set all typography properties at once. This lets you manage complete text styles using a single token instead of combining multiple individual ones.</p>
<p>When applying a Typography composite token to a layer, any previously applied <em>Typography composite token</em> or <em>style</em> will be detached. The same happens in reverse. Only one of them can be active at a time.</p>
<h2 id="design-tokens-sets">Token Sets</h2> <h2 id="design-tokens-sets">Token Sets</h2>
<p>Token Sets allow you to split your tokens up into multiple files in order to create organized groups or collections of tokens. It enables efficient management and customization within design files. For example you can group all your color sets, sizing sets or platform-specific sets. The purpose of tokens sets is to organize them in a way that matches your needs.</p> <p>Token Sets allow you to split your tokens up into multiple files in order to create organized groups or collections of tokens. It enables efficient management and customization within design files. For example you can group all your color sets, sizing sets or platform-specific sets. The purpose of tokens sets is to organize them in a way that matches your needs.</p>
<figure> <figure>

View File

@@ -0,0 +1,134 @@
{
"~: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": "~u3e5ffd68-2819-8084-8006-eb1c616a5afd",
"~: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 12384",
"~:revn": 4,
"~:modified-at": "~m1761124840773",
"~:vern": 0,
"~:id": "~ufa6ce865-34dd-80ac-8006-fe0dab5539a7",
"~: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",
"0004-clean-shadow-color",
"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"
]
},
"~:version": 67,
"~:project-id": "~u3e5ffd68-2819-8084-8006-eb1c616e69bf",
"~:created-at": "~m1761123649876",
"~:backend": "legacy-db",
"~:data": {
"~:pages": [
"~ufa6ce865-34dd-80ac-8006-fe0dab5539a8"
],
"~:pages-index": {
"~ufa6ce865-34dd-80ac-8006-fe0dab5539a8": {
"~: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\",[\"~u3fc80ad6-7d08-8031-8006-fe0dba3fddf7\"]]]",
"~u3fc80ad6-7d08-8031-8006-fe0dba3fddf7": "[\"~#shape\",[\"^ \",\"~:y\",250,\"~: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,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Board\",\"~:width\",265,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",616,\"~:y\",250]],[\"^=\",[\"^ \",\"~:x\",881,\"~:y\",250]],[\"^=\",[\"^ \",\"~:x\",881,\"~:y\",494]],[\"^=\",[\"^ \",\"~:x\",616,\"~:y\",494]]],\"~: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]],\"~:exports\",[[\"^ \",\"^:\",\"~:png\",\"~:suffix\",\"\",\"~:scale\",1]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u3fc80ad6-7d08-8031-8006-fe0dba3fddf7\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",616,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",616,\"~:y\",250,\"^9\",265,\"~:height\",244,\"~:x1\",616,\"~:y1\",250,\"~:x2\",881,\"~:y2\",494]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^O\",244,\"~:flip-y\",null,\"~:shapes\",[\"~u20a28a94-4ab0-801b-8006-fe0e8cee02c3\"]]]",
"~u20a28a94-4ab0-801b-8006-fe0e8cee02c3": "[\"~#shape\",[\"^ \",\"~:y\",297.00000381469727,\"~: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\",65,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",644.0000085830688,\"~:y\",297.00000381469727]],[\"^<\",[\"^ \",\"~:x\",709.0000085830688,\"~:y\",297.00000381469727]],[\"^<\",[\"^ \",\"~:x\",709.0000085830688,\"~:y\",362.00000381469727]],[\"^<\",[\"^ \",\"~:x\",644.0000085830688,\"~:y\",362.00000381469727]]],\"~: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\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:r1\",0,\"~:id\",\"~u20a28a94-4ab0-801b-8006-fe0e8cee02c3\",\"~:parent-id\",\"~u3fc80ad6-7d08-8031-8006-fe0dba3fddf7\",\"~:frame-id\",\"~u3fc80ad6-7d08-8031-8006-fe0dba3fddf7\",\"~:strokes\",[],\"~:x\",644.0000085830688,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",644.0000085830688,\"~:y\",297.00000381469727,\"^8\",65,\"~:height\",65,\"~:x1\",644.0000085830688,\"~:y1\",297.00000381469727,\"~:x2\",709.0000085830688,\"~:y2\",362.00000381469727]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^N\",65,\"~:flip-y\",null]]"
}
},
"~:id": "~ufa6ce865-34dd-80ac-8006-fe0dab5539a8",
"~:name": "Page 1"
}
},
"~:id": "~ufa6ce865-34dd-80ac-8006-fe0dab5539a7",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}

View File

@@ -0,0 +1,31 @@
import { test, expect } from "@playwright/test";
import DashboardPage from "../pages/DashboardPage";
test.beforeEach(async ({ page }) => {
await DashboardPage.init(page);
await DashboardPage.mockRPC(
page,
"get-profile",
"logged-in-user/get-profile-logged-in-no-onboarding.json",
);
});
test("BUG 12359 - Selected invitations count is not pluralized", async ({
page,
}) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.setupDashboardFull();
await dashboardPage.setupTeamInvitations();
await dashboardPage.goToSecondTeamInvitationsSection();
await expect(page.getByText("test1@mail.com")).toBeVisible();
// NOTE: we cannot use check() or getByLabel() because the checkbox
// is hidden inside the label.
await page.getByText("test1@mail.com").click();
await expect(page.getByText("1 invitation selected")).toBeVisible();
await page.getByText("test2@mail.com").check();
await expect(page.getByText("2 invitations selected")).toBeVisible();
});

View File

@@ -320,3 +320,50 @@ test("BUG 12287 Fix identical text fills not being added/removed", async ({
workspace.page.getByRole("button", { name: "#B1B2B5" }), workspace.page.getByRole("button", { name: "#B1B2B5" }),
).toHaveCount(3); ).toHaveCount(3);
}); });
test("BUG 12384 - Export crashing when exporting a board", async ({ page }) => {
const workspace = new WorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockRPC(/get\-file\?/, "design/get-file-12384.json");
let hasExportRequestBeenIntercepted = false;
await workspace.page.route("**/api/export", (route) => {
if (hasExportRequestBeenIntercepted) {
route.continue();
return;
}
hasExportRequestBeenIntercepted = true;
const payload = route.request().postData();
const parsedPayload = JSON.parse(payload);
expect(parsedPayload["~:exports"]).toHaveLength(1);
expect(parsedPayload["~:exports"][0]["~:file-id"]).toBe(
"~ufa6ce865-34dd-80ac-8006-fe0dab5539a7",
);
expect(parsedPayload["~:exports"][0]["~:page-id"]).toBe(
"~ufa6ce865-34dd-80ac-8006-fe0dab5539a8",
);
route.fulfill({
status: 200,
contentType: "application/json",
response: {},
});
});
await workspace.goToWorkspace({
fileId: "fa6ce865-34dd-80ac-8006-fe0dab5539a7",
pageId: "fa6ce865-34dd-80ac-8006-fe0dab5539a8",
});
await workspace.clickLeafLayer("Board");
let exportRequest = workspace.page.waitForRequest("**/api/export");
await workspace.rightSidebar
.getByRole("button", { name: "Export 1 element" })
.click();
await exportRequest;
});

View File

@@ -101,6 +101,24 @@ const setupTypographyTokensFile = async (page, options = {}) => {
}); });
}; };
const checkInputFieldWithError = async (tokenThemeUpdateCreateModal, inputLocator) => {
await expect(inputLocator).toHaveAttribute("aria-invalid", "true");
const errorMessageId = await inputLocator.getAttribute("aria-describedby");
await expect(
tokenThemeUpdateCreateModal.locator(`#${errorMessageId}`),
).toBeVisible();
};
const checkInputFieldWithoutError = async (tokenThemeUpdateCreateModal, inputLocator) => {
expect(
await inputLocator.getAttribute("aria-invalid")
).toBeNull();
expect(
await inputLocator.getAttribute("aria-describedby")
).toBeNull();
};
test.describe("Tokens: Tokens Tab", () => { test.describe("Tokens: Tokens Tab", () => {
test("Clicking tokens tab button opens tokens sidebar tab", async ({ test("Clicking tokens tab button opens tokens sidebar tab", async ({
page, page,
@@ -812,18 +830,25 @@ test.describe("Tokens: Themes modal", () => {
}) })
.click(); .click();
await tokenThemeUpdateCreateModal const groupInput = tokenThemeUpdateCreateModal.getByLabel("Group");
.getByLabel("Group") const nameInput = tokenThemeUpdateCreateModal.getByLabel("Theme");
.fill("New Group name"); const saveButton = tokenThemeUpdateCreateModal.getByRole("button", {
await tokenThemeUpdateCreateModal name: "Save theme",
.getByLabel("Theme") });
.fill("New Theme name");
await tokenThemeUpdateCreateModal await groupInput.fill("Core"); // Invalid because "Core / Light" theme already exists
.getByRole("button", { await nameInput.fill("Light");
name: "Save theme",
}) await checkInputFieldWithError(tokenThemeUpdateCreateModal, nameInput);
.click(); await expect(saveButton).toBeDisabled();
await groupInput.fill("New Group name");
await nameInput.fill("New Theme name");
await checkInputFieldWithoutError(tokenThemeUpdateCreateModal, nameInput);
await expect(saveButton).not.toBeDisabled();
await saveButton.click();
await expect( await expect(
tokenThemeUpdateCreateModal.getByText("New Theme name"), tokenThemeUpdateCreateModal.getByText("New Theme name"),
@@ -851,12 +876,36 @@ test.describe("Tokens: Themes modal", () => {
.first() .first()
.click(); .click();
await tokenThemeUpdateCreateModal const groupInput = tokenThemeUpdateCreateModal.getByLabel("Group");
.getByLabel("Theme") const nameInput = tokenThemeUpdateCreateModal.getByLabel("Theme");
.fill("Changed Theme name"); const saveButton = tokenThemeUpdateCreateModal.getByRole("button", {
await tokenThemeUpdateCreateModal name: "Save theme",
.getByLabel("Group") });
.fill("Changed Group name");
await groupInput.fill("Core"); // Invalid because "Core / Dark" theme already exists
await nameInput.fill("Dark");
await checkInputFieldWithError(tokenThemeUpdateCreateModal, nameInput);
await expect(saveButton).toBeDisabled();
await groupInput.fill("Core"); // Valid because "Core / Light" theme already exists
await nameInput.fill("Light"); // but it's the same theme we are editing
await checkInputFieldWithoutError(tokenThemeUpdateCreateModal, nameInput);
await expect(saveButton).not.toBeDisabled();
await nameInput.fill("Changed Theme name"); // New names should be also valid
await groupInput.fill("Changed Group name");
await checkInputFieldWithoutError(tokenThemeUpdateCreateModal, nameInput);
await expect(saveButton).not.toBeDisabled();
expect(
await nameInput.getAttribute("aria-invalid")
).toBeNull();
expect(
await nameInput.getAttribute("aria-describedby")
).toBeNull();
const checkboxes = await tokenThemeUpdateCreateModal const checkboxes = await tokenThemeUpdateCreateModal
.locator('[role="checkbox"]') .locator('[role="checkbox"]')
@@ -876,11 +925,9 @@ test.describe("Tokens: Themes modal", () => {
await firstButton.click(); await firstButton.click();
await tokenThemeUpdateCreateModal await expect(saveButton).not.toBeDisabled();
.getByRole("button", {
name: "Save theme", await saveButton.click();
})
.click();
await expect( await expect(
tokenThemeUpdateCreateModal.getByText("Changed Theme name"), tokenThemeUpdateCreateModal.getByText("Changed Theme name"),

View File

@@ -150,8 +150,8 @@ test("User copy paste a variant container", async ({ page }) => {
await workspacePage.clickAt(500, 500); await workspacePage.clickAt(500, 500);
await workspacePage.page.keyboard.press("Control+v"); await workspacePage.page.keyboard.press("Control+v");
const variant_original = await findVariant(workspacePage, 0); const variant_original = await findVariant(workspacePage, 1);
const variant_duplicate = await findVariant(workspacePage, 1); const variant_duplicate = await findVariant(workspacePage, 0);
// Expand the layers // Expand the layers
await variant_duplicate.container.getByRole("button").first().click(); await variant_duplicate.container.getByRole("button").first().click();

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -139,9 +139,9 @@
(fn [data] (fn [data]
(assoc file :data (d/removem (comp t/pointer? val) data)))))) (assoc file :data (d/removem (comp t/pointer? val) data))))))
(defn- check-libraries-synchronozation (defn- check-libraries-synchronization
[file-id libraries] [file-id libraries]
(ptk/reify ::check-libraries-synchronozation (ptk/reify ::check-libraries-synchronization
ptk/WatchEvent ptk/WatchEvent
(watch [_ state _] (watch [_ state _]
(let [file (dsh/lookup-file state file-id) (let [file (dsh/lookup-file state file-id)
@@ -154,7 +154,7 @@
libraries)] libraries)]
(when needs-check? (when needs-check?
(->> (rx/of (dwl/notify-sync-file file-id)) (->> (rx/of (dwl/notify-sync-file))
(rx/delay 1000))))))) (rx/delay 1000)))))))
(defn- library-resolved (defn- library-resolved
@@ -168,30 +168,32 @@
[file-id features] [file-id features]
(ptk/reify ::fetch-libries (ptk/reify ::fetch-libries
ptk/WatchEvent ptk/WatchEvent
(watch [_ _ _] (watch [_ _ stream]
(rx/concat (let [stopper-s (rx/filter (ptk/type? ::finalize-workspace) stream)]
(->> (rp/cmd! :get-file-libraries {:file-id file-id}) (->> (rx/concat
(rx/mapcat (->> (rp/cmd! :get-file-libraries {:file-id file-id})
(fn [libraries] (rx/mapcat
(rx/concat (fn [libraries]
(rx/of (dwl/libraries-fetched file-id libraries)) (rx/concat
(rx/merge (rx/of (dwl/libraries-fetched file-id libraries))
(->> (rx/from libraries) (rx/merge
(rx/merge-map (->> (rx/from libraries)
(fn [{:keys [id synced-at]}] (rx/merge-map
(->> (rp/cmd! :get-file {:id id :features features}) (fn [{:keys [id synced-at]}]
(rx/map #(assoc % :synced-at synced-at :library-of file-id))))) (->> (rp/cmd! :get-file {:id id :features features})
(rx/mapcat resolve-file) (rx/map #(assoc % :synced-at synced-at :library-of file-id)))))
(rx/map library-resolved)) (rx/mapcat resolve-file)
(->> (rx/from libraries) (rx/map library-resolved))
(rx/map :id) (->> (rx/from libraries)
(rx/mapcat (fn [file-id] (rx/map :id)
(rp/cmd! :get-file-object-thumbnails {:file-id file-id :tag "component"}))) (rx/mapcat (fn [file-id]
(rx/map dwl/library-thumbnails-fetched))) (rp/cmd! :get-file-object-thumbnails {:file-id file-id :tag "component"})))
(rx/of (check-libraries-synchronozation file-id libraries)))))) (rx/map dwl/library-thumbnails-fetched)))
(rx/of (check-libraries-synchronization file-id libraries))))))
;; This events marks that all the libraries have been resolved ;; This events marks that all the libraries have been resolved
(rx/of (ptk/data-event ::all-libraries-resolved)))))) (rx/of (ptk/data-event ::all-libraries-resolved)))
(rx/take-until stopper-s))))))
(defn- workspace-initialized (defn- workspace-initialized
[file-id] [file-id]

View File

@@ -876,6 +876,10 @@
index index
0) 0)
index (if index
index
(dec (count (dm/get-in page-objects [parent-id :shapes]))))
selected (if (and (ctl/flex-layout? page-objects parent-id) (not (ctl/reverse? page-objects parent-id))) selected (if (and (ctl/flex-layout? page-objects parent-id) (not (ctl/reverse? page-objects parent-id)))
(into (d/ordered-set) (reverse selected)) (into (d/ordered-set) (reverse selected))
selected) selected)

View File

@@ -1195,19 +1195,22 @@
(ctf/used-assets-changed-since file-data library sync-date)))))) (ctf/used-assets-changed-since file-data library sync-date))))))
(defn notify-sync-file (defn notify-sync-file
;; file-id is the id of the modified library "Notify the user that there are updates in the libraries used by the
[file-id] current file, and ask if he wants to update them now."
(dm/assert! (uuid? file-id)) []
(ptk/reify ::notify-sync-file (ptk/reify ::notify-sync-file
ptk/WatchEvent ptk/WatchEvent
(watch [_ state _] (watch [_ state _]
(let [file (dsh/lookup-file state (:current-file-id state)) (let [file-id (:current-file-id state)
file (dsh/lookup-file state file-id)
file-data (get file :data) file-data (get file :data)
ignore-until (get file :ignore-sync-until) ignore-until (get file :ignore-sync-until)
libraries-need-sync libraries-need-sync
(filter #(seq (assets-need-sync % file-data ignore-until)) (->> (vals (get state :files))
(vals (get state :files))) (filter #(= (:library-of %) file-id))
(filter #(seq (assets-need-sync % file-data ignore-until))))
do-more-info do-more-info
#(modal/show! :libraries-dialog {:starting-tab "updates" :file-id file-id}) #(modal/show! :libraries-dialog {:starting-tab "updates" :file-id file-id})

View File

@@ -191,30 +191,6 @@
(d/not-empty? position-data) (d/not-empty? position-data)
(assoc :position-data position-data)))) (assoc :position-data position-data))))
(defn update-grow-type
[shape old-shape]
(let [auto-width? (= :auto-width (:grow-type shape))
auto-height? (= :auto-height (:grow-type shape))
changed-width? (> (mth/abs (- (:width shape) (:width old-shape))) 0.1)
changed-height? (> (mth/abs (- (:height shape) (:height old-shape))) 0.1)
;; Check if the shape is in a flex layout context that might cause layout-driven changes
;; We should be more conservative about converting auto-width to fixed when the shape
;; is part of a layout system that could cause automatic resizing
has-layout-item-sizing? (or (:layout-item-h-sizing shape) (:layout-item-v-sizing shape))
;; Only convert auto-width to fixed if:
;; 1. For auto-width: both width AND height changed (indicating user manipulation, not layout)
;; 2. For auto-height: only height changed
;; 3. The shape is not in a layout context where automatic sizing changes are expected
change-to-fixed? (and (not has-layout-item-sizing?)
(or (and auto-width? changed-width? changed-height?)
(and auto-height? changed-height?)))]
(cond-> shape
change-to-fixed?
(assoc :grow-type :fixed))))
(defn- set-wasm-props! (defn- set-wasm-props!
[objects prev-wasm-props wasm-props] [objects prev-wasm-props wasm-props]
(let [;; Set old value for previous properties (let [;; Set old value for previous properties
@@ -810,9 +786,7 @@
(-> shape (-> shape
(gsh/transform-shape modifiers) (gsh/transform-shape modifiers)
(cond-> (d/not-empty? pos-data) (cond-> (d/not-empty? pos-data)
(assoc-position-data pos-data shape)) (assoc-position-data pos-data shape)))))]
(cond-> text-shape?
(update-grow-type shape)))))]
(rx/of (ptk/event ::dwg/move-frame-guides {:ids ids-with-children :modifiers object-modifiers}) (rx/of (ptk/event ::dwg/move-frame-guides {:ids ids-with-children :modifiers object-modifiers})
(ptk/event ::dwcm/move-frame-comment-threads ids-with-children) (ptk/event ::dwcm/move-frame-comment-threads ids-with-children)
@@ -857,23 +831,20 @@
(rx/empty)))))))) (rx/empty))))))))
;; Pure function to determine next grow-type for text layers ;; Pure function to determine next grow-type for text layers
(defn next-grow-type [current-grow-type resize-direction] (defn next-grow-type
[current-grow-type scalev]
(cond (cond
(= current-grow-type :fixed) (= current-grow-type :fixed)
:fixed :fixed
(and (= resize-direction :horizontal) (and (not (mth/close? (:y scalev) 1.0))
(= current-grow-type :auto-width))
:auto-height
(and (= resize-direction :horizontal)
(= current-grow-type :auto-height))
:auto-height
(and (= resize-direction :vertical)
(or (= current-grow-type :auto-width) (or (= current-grow-type :auto-width)
(= current-grow-type :auto-height))) (= current-grow-type :auto-height)))
:fixed :fixed
(and (not (mth/close? (:x scalev) 1.0))
(= current-grow-type :auto-width))
:auto-height
:else :else
current-grow-type)) current-grow-type))

View File

@@ -331,4 +331,4 @@
(watch [_ state _] (watch [_ state _]
(when (contains? (:files state) file-id) (when (contains? (:files state) file-id)
(rx/of (dwl/ext-library-changed file-id modified-at revn changes) (rx/of (dwl/ext-library-changed file-id modified-at revn changes)
(dwl/notify-sync-file file-id)))))) (dwl/notify-sync-file))))))

View File

@@ -463,7 +463,7 @@
library-data (dsh/lookup-file-data state file-id) library-data (dsh/lookup-file-data state file-id)
changes (-> (pcb/empty-changes it) changes (-> (pcb/empty-changes it)
(cll/generate-duplicate-changes objects page ids delta libraries library-data file-id) (cll/generate-duplicate-changes objects page ids delta libraries library-data file-id {:alt-duplication? alt-duplication?})
(cll/generate-duplicate-changes-update-indices objects ids)) (cll/generate-duplicate-changes-update-indices objects ids))
tags (or (:tags changes) #{}) tags (or (:tags changes) #{})

View File

@@ -235,13 +235,8 @@
[ids] [ids]
(ptk/reify ::remove-shape-layout (ptk/reify ::remove-shape-layout
ptk/WatchEvent ptk/WatchEvent
(watch [_ state _] (watch [_ _ _]
(let [objects (dsh/lookup-page-objects state) (let [undo-id (js/Symbol)]
ids (->> ids
(remove #(->> %
(get objects)
(ctc/is-variant?))))
undo-id (js/Symbol)]
(rx/of (rx/of
(dwu/start-undo-transaction undo-id) (dwu/start-undo-transaction undo-id)
(dwsh/update-shapes ids #(apply dissoc % layout-keys)) (dwsh/update-shapes ids #(apply dissoc % layout-keys))

View File

@@ -17,6 +17,7 @@
[app.common.math :as mth] [app.common.math :as mth]
[app.common.types.fills :as types.fills] [app.common.types.fills :as types.fills]
[app.common.types.modifiers :as ctm] [app.common.types.modifiers :as ctm]
[app.common.types.shape.layout :as ctl]
[app.common.types.text :as txt] [app.common.types.text :as txt]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.main.data.event :as ev] [app.main.data.event :as ev]
@@ -581,12 +582,17 @@
shape shape
(cond-> shape (cond-> shape
(and (not-changed? shape-width new-width) (= grow-type :auto-width)) (and (or (not (ctl/any-layout-immediate-child? objects shape))
(not (ctl/fill-width? shape)))
(not-changed? shape-width new-width)
(= grow-type :auto-width))
(gsh/transform-shape (ctm/change-dimensions-modifiers shape :width new-width {:ignore-lock? true}))) (gsh/transform-shape (ctm/change-dimensions-modifiers shape :width new-width {:ignore-lock? true})))
shape shape
(cond-> shape (cond-> shape
(and (not-changed? shape-height new-height) (and (or (not (ctl/any-layout-immediate-child? objects shape))
(not (ctl/fill-height? shape)))
(not-changed? shape-height new-height)
(or (= grow-type :auto-height) (= grow-type :auto-width))) (or (= grow-type :auto-height) (= grow-type :auto-width)))
(gsh/transform-shape (ctm/change-dimensions-modifiers shape :height new-height {:ignore-lock? true})))] (gsh/transform-shape (ctm/change-dimensions-modifiers shape :height new-height {:ignore-lock? true})))]
@@ -594,7 +600,8 @@
(let [ids (into #{} (filter changed-text?) (keys props))] (let [ids (into #{} (filter changed-text?) (keys props))]
(rx/of (dwu/start-undo-transaction undo-id) (rx/of (dwu/start-undo-transaction undo-id)
(dwsh/update-shapes ids update-fn {:reg-objects? true (dwsh/update-shapes ids update-fn {:with-objects? true
:reg-objects? true
:stack-undo? true :stack-undo? true
:ignore-touched true}) :ignore-touched true})
(ptk/data-event :layout/update {:ids ids}) (ptk/data-event :layout/update {:ids ids})

View File

@@ -218,15 +218,10 @@
(gpt/add resize-origin displacement) (gpt/add resize-origin displacement)
resize-origin) resize-origin)
;; Determine resize direction for grow-type logic
resize-direction (cond
(or (= handler :left) (= handler :right)) :horizontal
(or (= handler :top) (= handler :bottom)) :vertical
:else nil)
;; Calculate new grow-type for text layers ;; Calculate new grow-type for text layers
new-grow-type (when (cfh/text-shape? shape) new-grow-type
(dwm/next-grow-type (dm/get-prop shape :grow-type) resize-direction)) (when (cfh/text-shape? shape)
(dwm/next-grow-type (dm/get-prop shape :grow-type) scalev))
;; When the horizontal/vertical scale a flex children with auto/fill ;; When the horizontal/vertical scale a flex children with auto/fill
;; we change it too fixed ;; we change it too fixed
@@ -393,7 +388,19 @@
get-modifier get-modifier
(fn [shape] (fn [shape]
(ctm/change-dimensions-modifiers shape attr value)) (let [modifiers (ctm/change-dimensions-modifiers shape attr value)]
;; For text shapes, also update grow-type based on the resize
(if (cfh/text-shape? shape)
(let [{sr-width :width sr-height :height} (:selrect shape)
new-width (if (= attr :width) value sr-width)
new-height (if (= attr :height) value sr-height)
scalev (gpt/point (/ new-width sr-width) (/ new-height sr-height))
current-grow-type (dm/get-prop shape :grow-type)
new-grow-type (dwm/next-grow-type current-grow-type scalev)]
(cond-> modifiers
(not= new-grow-type current-grow-type)
(ctm/change-property :grow-type new-grow-type)))
modifiers)))
modif-tree modif-tree
(-> (dwm/build-modif-tree ids objects get-modifier) (-> (dwm/build-modif-tree ids objects get-modifier)

View File

@@ -99,9 +99,25 @@
(defn update-property-name (defn update-property-name
"Update the variant property name on the position pos "Update the variant property name on the position pos
in all the components with this variant-id" in all the components with this variant-id and remove the focus"
[variant-id pos new-name {:keys [trigger]}] [variant-id pos new-name {:keys [trigger]}]
(ptk/reify ::update-property-name (ptk/reify ::update-property-name
ptk/UpdateEvent
(update [_ state]
(let [file-id (:current-file-id state)
data (dsh/lookup-file-data state)
objects (dsh/lookup-page-objects state)
related-components (cfv/find-variant-components data objects variant-id)]
(reduce
(fn [s related-component]
(update-in s
[:files file-id :data :components (:id related-component) :variant-properties]
(fn [props] (mapv #(with-meta % nil) props))))
state
related-components)))
ptk/WatchEvent ptk/WatchEvent
(watch [it state _] (watch [it state _]
(let [page-id (:current-page-id state) (let [page-id (:current-page-id state)

View File

@@ -84,6 +84,9 @@
(l/derived :shared-files st/state)) (l/derived :shared-files st/state))
(defn select-libraries (defn select-libraries
"Find between all the given files, those who are libraries of the file-id.
Also include the file-id file itself.
Return a map of id -> library."
[files file-id] [files file-id]
(persistent! (persistent!
(reduce-kv (fn [result id file] (reduce-kv (fn [result id file]

View File

@@ -666,11 +666,12 @@
[:div {:class (stl/css :form-buttons-wrapper)} [:div {:class (stl/css :form-buttons-wrapper)}
[:> mentions-button*] [:> mentions-button*]
[:> button* {:variant "ghost" (when (some? on-cancel)
:type "button" [:> button* {:variant "ghost"
:on-key-down handle-cancel :type "button"
:on-click on-cancel} :on-key-down handle-cancel
(tr "ds.confirm-cancel")] :on-click on-cancel}
(tr "ds.confirm-cancel")])
[:> button* {:variant "primary" [:> button* {:variant "primary"
:type "button" :type "button"
:on-key-down handle-submit :on-key-down handle-submit
@@ -686,52 +687,39 @@
{::mf/props :obj {::mf/props :obj
::mf/private true} ::mf/private true}
[{:keys [on-submit]}] [{:keys [on-submit]}]
(let [show-buttons? (mf/use-state false) (let [content (mf/use-state "")
content (mf/use-state "")
disabled? (or (blank-content? @content) disabled? (or (blank-content? @content)
(exceeds-length? @content)) (exceeds-length? @content))
on-focus on-cancel
(mf/use-fn (mf/use-fn
#(reset! show-buttons? true)) #(st/emit! :interrupt))
on-blur
(mf/use-fn
#(reset! show-buttons? false))
on-change on-change
(mf/use-fn (mf/use-fn
#(reset! content %)) #(reset! content %))
on-cancel
(mf/use-fn
#(do (reset! content "")
(reset! show-buttons? false)))
on-submit* on-submit*
(mf/use-fn (mf/use-fn
(mf/deps @content) (mf/deps @content)
(fn [] (fn []
(on-submit @content) (on-submit @content)
(on-cancel)))] (reset! content "")))]
[:div {:class (stl/css :form)} [:div {:class (stl/css :form)}
[:> comment-input* [:> comment-input*
{:value @content {:value @content
:placeholder (tr "labels.reply.thread") :placeholder (tr "labels.reply.thread")
:autofocus true :autofocus true
:on-blur on-blur
:on-focus on-focus
:on-ctrl-enter on-submit* :on-ctrl-enter on-submit*
:on-change on-change}] :on-change on-change}]
(when (exceeds-length? @content) (when (exceeds-length? @content)
[:div {:class (stl/css :error-text)} [:div {:class (stl/css :error-text)}
(tr "errors.character-limit-exceeded")]) (tr "errors.character-limit-exceeded")])
(when (or @show-buttons? (seq @content)) [:> comment-form-buttons* {:on-submit on-submit*
[:> comment-form-buttons* {:on-submit on-submit* :on-cancel on-cancel
:on-cancel on-cancel :is-disabled disabled?}]]))
:is-disabled disabled?}])]))
(mf/defc comment-edit-form* (mf/defc comment-edit-form*
{::mf/private true} {::mf/private true}

View File

@@ -1057,16 +1057,17 @@
{:profile profile {:profile profile
:on-show-comments handle-show-comments}])] :on-show-comments handle-show-comments}])]
(case sub-menu (when show-profile-menu?
:help-learning (case sub-menu
[:> help-learning-menu* {:on-close close-sub-menu :on-click on-click}] :help-learning
[:> help-learning-menu* {:on-close close-sub-menu :on-click on-click}]
:community-contributions :community-contributions
[:> community-contributions-menu* {:on-close close-sub-menu}] [:> community-contributions-menu* {:on-close close-sub-menu}]
:about-penpot :about-penpot
[:> about-penpot-menu* {:on-close close-sub-menu}] [:> about-penpot-menu* {:on-close close-sub-menu}]
nil)])) nil))]))
(mf/defc sidebar* (mf/defc sidebar*
{::mf/props :obj {::mf/props :obj

View File

@@ -690,18 +690,18 @@
[:div {:class (stl/css :table-row :table-row-invitations)} [:div {:class (stl/css :table-row :table-row-invitations)}
[:div {:class (stl/css :table-field :field-email)} [:div {:class (stl/css :table-field :field-email)}
[:div {:class (stl/css :input-wrapper)} [:div {:class (stl/css :input-wrapper)}
[:label {:for (str "email-" email)} [:label
[:span {:class (stl/css-case :input-checkbox true [:span {:class (stl/css-case :input-checkbox true
:global/checked (is-selected? email))} :global/checked (is-selected? email))}
deprecated-icon/status-tick] deprecated-icon/status-tick]
[:input {:type "checkbox" [:input {:type "checkbox"
:id (str "email-" email) :id (dm/str "email-" email)
:data-attr email :data-attr email
:value email :value email
:checked (is-selected? email) :checked (is-selected? email)
:on-change on-change}]]] :on-change on-change}]
email] email]]]
[:div {:class (stl/css :table-field :field-roles)} [:div {:class (stl/css :table-field :field-roles)}
[:> invitation-role-selector* [:> invitation-role-selector*
@@ -930,7 +930,7 @@
[:* [:*
[:div {:class (stl/css :invitations-actions)} [:div {:class (stl/css :invitations-actions)}
[:div [:div
(str (count @selected) " invitations selected")] (tr "team.invitations-selected" (i18n/c (count @selected)))]
[:div [:div
[:> button* {:variant "secondary" [:> button* {:variant "secondary"
:type "button" :type "button"

View File

@@ -780,28 +780,19 @@
@extend .input-base; @extend .input-base;
height: auto; height: auto;
} }
// TODO: Fix this nested classes. // FIXME: This does not conform to our CSS Guidelines. Need to unnest and to use
// custom properties to handle state changes.
.input-wrapper { .input-wrapper {
display: flex; display: flex;
align-items: center; align-items: center;
@include t.use-typography("body-large");
label { label {
@include t.use-typography("body-small");
display: flex; display: flex;
align-items: center; align-items: center;
gap: px2rem(6); gap: px2rem(6);
cursor: pointer; cursor: pointer;
color: var(--color-foreground-secondary); color: var(--color-foreground-primary);
span {
@extend .checkbox-icon;
}
input {
margin: 0;
}
&:hover {
span {
border-color: var(--color-accent-primary-muted);
}
}
&:focus, &:focus,
&:focus-within { &:focus-within {
@@ -809,6 +800,22 @@
border-color: var(--color-accent-primary); border-color: var(--color-accent-primary);
} }
} }
&:hover {
span {
border-color: var(--color-accent-primary-muted);
}
}
}
span {
@extend .checkbox-icon;
@include t.use-typography("body-small");
color: var(--color-foreground-secondary);
}
input {
margin: 0;
@include t.use-typography("body-small");
color: var(--color-foreground-secondary);
} }
} }

View File

@@ -19,6 +19,7 @@ $sz-36: px2rem(36);
$sz-40: px2rem(40); $sz-40: px2rem(40);
$sz-48: px2rem(48); $sz-48: px2rem(48);
$sz-88: px2rem(88); $sz-88: px2rem(88);
$sz-96: px2rem(96);
$sz-120: px2rem(120); $sz-120: px2rem(120);
$sz-154: px2rem(154); $sz-154: px2rem(154);
$sz-160: px2rem(160); $sz-160: px2rem(160);

View File

@@ -29,6 +29,7 @@
[app.main.ui.releases.v2-0] [app.main.ui.releases.v2-0]
[app.main.ui.releases.v2-1] [app.main.ui.releases.v2-1]
[app.main.ui.releases.v2-10] [app.main.ui.releases.v2-10]
[app.main.ui.releases.v2-11]
[app.main.ui.releases.v2-2] [app.main.ui.releases.v2-2]
[app.main.ui.releases.v2-3] [app.main.ui.releases.v2-3]
[app.main.ui.releases.v2-4] [app.main.ui.releases.v2-4]
@@ -101,4 +102,4 @@
(defmethod rc/render-release-notes "0.0" (defmethod rc/render-release-notes "0.0"
[params] [params]
(rc/render-release-notes (assoc params :version "2.10"))) (rc/render-release-notes (assoc params :version "2.11")))

View File

@@ -0,0 +1,189 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.releases.v2-11
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
(defmethod c/render-release-notes "2.11"
[{:keys [slide klass next finish navigate version]}]
(mf/html
(case slide
:start
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.11-slide-0.jpg"
:class (stl/css :start-image)
:border "0"
:alt "Penpot 2.11 is here!"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"Whats new in Penpot?"]
[:div {:class (stl/css :version-tag)}
(dm/str "Version " version)]]
[:div {:class (stl/css :features-block)}
[:span {:class (stl/css :feature-title)}
"Typography tokens take the stage!"]
[:p {:class (stl/css :feature-content)}
"This release brings one of our most anticipated design system upgrades yet: Typography tokens."]
[:p {:class (stl/css :feature-content)}
"But thats not all. Variants get a nice boost with multi-switching, new creation shortcuts, and draggable property reordering. Invitations are now easier to manage and the user menu has been reorganized. Now showing your current Penpot version and direct access to release info."]
[:p {:class (stl/css :feature-content)}
"And as always, youll notice performance improvements throughout. Faster, smoother, and just a bit more magical every time."]
[:p {:class (stl/css :feature-content)}
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.11-typography-token.gif"
:class (stl/css :start-image)
:border "0"
:alt "Typography token: one token to rule your text"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"Typography token: one token to rule your text"]]
[:div {:class (stl/css :feature)}
[:p {:class (stl/css :feature-content)}
"Imagine having just one token to manage all your typography. With the new Typography token, you can create presets that bundle all your text styles (font, weight, size, line height, spacing, and more) into a single reusable definition. Just one clean, flexible token to keep your type consistent across your designs."]
[:p {:class (stl/css :feature-content)}
"The Typography token also marks a big step forward for Penpot: its our first composite token! Composite tokens are special because they can hold multiple properties within one token. Shadow token will be the next composite token coming your way."]]
[:div {:class (stl/css :navigation)}
[:& c/navigation-bullets
{:slide slide
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.11-variants.gif"
:class (stl/css :start-image)
:border "0"
:alt "Variants get a power-up"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"Variants get a power-up"]]
[:div {:class (stl/css :feature)}
[:p {:class (stl/css :feature-content)}
"Variants just got published and they already got a serious quality-of-life boost!"]
[:p {:class (stl/css :feature-content)}
"- Switch several variant copies at once: No more clicking through each one individually when you want to update a property. Just select multiple copies and change their values in one go — fast, smooth, and efficient."]
[:p {:class (stl/css :feature-content)}
"- New ways to create variants, right from the design viewport: No need to dig through menus. The new buttons make it super quick to spin up variant sets directly where youre working."]
[:p {:class (stl/css :feature-content)}
"- Reorder your component properties by drag & drop: Because organization matters, now you can arrange your properties however makes the most sense to you, so you can keep the ones you use most often right where you want them."]]
[:div {:class (stl/css :navigation)}
[:& c/navigation-bullets
{:slide slide
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
2
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.11-invitations.gif"
:class (stl/css :start-image)
:border "0"
:alt "A smoother way to manage invitations"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"A smoother way to manage invitations"]]
[:div {:class (stl/css :feature)}
[:p {:class (stl/css :feature-content)}
"The Invitations section just got a big usability upgrade for admins. Heres whats new:"]
[:p {:class (stl/css :feature-content)}
"Sorting - Organize invitations by role type or status to keep track of whos in and whos pending."]
[:p {:class (stl/css :feature-content)}
"Quicker actions - Main actions (resend and delete) are now visible upfront for quicker access."]
[:p {:class (stl/css :feature-content)}
"Bulk management - Select multiple invitations to resend or delete them all at once."]
[:p {:class (stl/css :feature-content)}
"Invited users will also get clearer emails, including a reminder sent one day before the invite expires (after seven days). Simple, clean, and much more efficient."]]
[:div {:class (stl/css :navigation)}
[:& c/navigation-bullets
{:slide slide
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
3
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.11-menu.gif"
:class (stl/css :start-image)
:border "0"
:alt "User menu makeover"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"User menu makeover"]]
[:div {:class (stl/css :feature)}
[:p {:class (stl/css :feature-content)}
"The user menu got a well-deserved cleanup. Options are now grouped into clear sections like Help & Learning and Community & Contributions, making navigation faster and easier."]
[:p {:class (stl/css :feature-content)}
"Youll also notice a handy new detail: the menu now shows your current Penpot version and gives you quick access to changelog information. This is especially useful for self-hosted setups that want to stay in sync with the latest updates. Simple, organized, and more informative."]]
[:div {:class (stl/css :navigation)}
[:& c/navigation-bullets
{:slide slide
:navigate navigate
:total 4}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))

View File

@@ -0,0 +1,102 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend .modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
a {
color: var(--button-primary-background-color-rest);
}
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flexCenter;
@include deprecated.headlineSmallTypography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headlineLargeTypography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.bodyLargeTypography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.bodyMediumTypography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.bodyMediumTypography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend .button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}

View File

@@ -16,7 +16,6 @@
[app.common.types.modifiers :as ctm] [app.common.types.modifiers :as ctm]
[app.common.types.text :as txt] [app.common.types.text :as txt]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.main.data.workspace.modifiers :as mdwm]
[app.main.data.workspace.texts :as dwt] [app.main.data.workspace.texts :as dwt]
[app.main.fonts :as fonts] [app.main.fonts :as fonts]
[app.main.refs :as refs] [app.main.refs :as refs]
@@ -44,7 +43,6 @@
(gpt/point old-sr))] (gpt/point old-sr))]
(-> shape (-> shape
(gsh/transform-shape (ctm/move modifiers deltav)) (gsh/transform-shape (ctm/move modifiers deltav))
(mdwm/update-grow-type shape)
(dissoc :modifiers))) (dissoc :modifiers)))
shape)) shape))

View File

@@ -169,9 +169,9 @@
on-color-drag-start on-color-drag-start
(mf/use-fn (mf/use-fn
(mf/deps color file-id selected item-ref read-only?) (mf/deps color file-id selected item-ref read-only? editing?)
(fn [event] (fn [event]
(if read-only? (if (or read-only? editing?)
(dom/prevent-default event) (dom/prevent-default event)
(cmm/on-asset-drag-start event file-id color selected item-ref :colors identity)))) (cmm/on-asset-drag-start event file-id color selected item-ref :colors identity))))

View File

@@ -65,6 +65,7 @@
component-id (:id component) component-id (:id component)
visible? (h/use-visible item-ref :once? true) visible? (h/use-visible item-ref :once? true)
renaming? (= renaming (:id component))
;; NOTE: we don't use reactive deref for it because we don't ;; NOTE: we don't use reactive deref for it because we don't
;; really need rerender on any change on the file change. If ;; really need rerender on any change on the file change. If
@@ -82,12 +83,13 @@
on-component-double-click on-component-double-click
(mf/use-fn (mf/use-fn
(mf/deps file-id component is-local) (mf/deps file-id component is-local renaming?)
(fn [event] (fn [event]
(dom/stop-propagation event) (dom/stop-propagation event)
(if is-local (when-not renaming?
(st/emit! (dwl/go-to-local-component :id component-id)) (if is-local
(st/emit! (dwl/go-to-component-file file-id component false))))) (st/emit! (dwl/go-to-local-component :id component-id))
(st/emit! (dwl/go-to-component-file file-id component false))))))
on-drop on-drop
(mf/use-fn (mf/use-fn
@@ -113,18 +115,16 @@
on-component-drag-start on-component-drag-start
(mf/use-fn (mf/use-fn
(mf/deps file-id component selected item-ref on-drag-start read-only? is-local) (mf/deps file-id component selected item-ref on-drag-start read-only? renaming? is-local)
(fn [event] (fn [event]
(if read-only? (if (or read-only? renaming?)
(dom/prevent-default event) (dom/prevent-default event)
(cmm/on-asset-drag-start event file-id component selected item-ref :components on-drag-start)))) (cmm/on-asset-drag-start event file-id component selected item-ref :components on-drag-start))))
on-context-menu on-context-menu
(mf/use-fn (mf/use-fn
(mf/deps on-context-menu component-id) (mf/deps on-context-menu component-id)
(partial on-context-menu component-id)) (partial on-context-menu component-id))]
renaming? (= renaming (:id component))]
[:div {:ref item-ref [:div {:ref item-ref
:class (stl/css-case :component-item true :class (stl/css-case :component-item true

View File

@@ -76,9 +76,9 @@
on-typography-drag-start on-typography-drag-start
(mf/use-fn (mf/use-fn
(mf/deps typography file-id selected item-ref read-only?) (mf/deps typography file-id selected item-ref read-only? renaming? open?)
(fn [event] (fn [event]
(if read-only? (if (or read-only? renaming? open?)
(dom/prevent-default event) (dom/prevent-default event)
(cmm/on-asset-drag-start event file-id typography selected item-ref :typographies identity)))) (cmm/on-asset-drag-start event file-id typography selected item-ref :typographies identity))))

View File

@@ -54,7 +54,7 @@
modifiers (dm/get-in modifiers [shape-id :modifiers]) modifiers (dm/get-in modifiers [shape-id :modifiers])
shape (gsh/transform-shape shape modifiers) shape (gsh/transform-shape shape modifiers)
props (mf/spread-props props {:shape shape})] props (mf/spread-props props {:shape shape :file-id file-id :page-id page-id})]
(case shape-type (case shape-type
:frame [:> frame/options* props] :frame [:> frame/options* props]

View File

@@ -5,7 +5,6 @@
// Copyright (c) KALEIDOS INC // Copyright (c) KALEIDOS INC
@use "ds/_sizes.scss" as *; @use "ds/_sizes.scss" as *;
@use "refactor/common-refactor.scss" as deprecated;
.tool-window { .tool-window {
position: relative; position: relative;
@@ -15,26 +14,26 @@
} }
.tab-spacing { .tab-spacing {
margin-right: deprecated.$s-12; margin-inline-end: var(--sp-m);
} }
.content-class { .content-class {
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
height: calc(100vh - deprecated.$s-96); height: calc(100vh - #{$sz-96});
scrollbar-gutter: stable; scrollbar-gutter: stable;
} }
.element-options { .element-options {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: deprecated.$s-8; gap: var(--sp-s);
width: 100%; width: 100%;
/* FIXME: This is hacky and prone to break, we should tackle the whole layout /* FIXME: This is hacky and prone to break, we should tackle the whole layout
of the sidebar differently */ of the sidebar differently */
--sidebar-element-options-height: calc(100vh - $sz-88); --sidebar-element-options-height: calc(100vh - #{$sz-88});
height: var(--sidebar-element-options-height); height: var(--sidebar-element-options-height);
padding-top: deprecated.$s-8; padding-block-start: var(--sp-s);
} }
.read-only { .read-only {

View File

@@ -498,7 +498,7 @@
[:> color-selection-menu* [:> color-selection-menu*
{:file-id file-id {:file-id file-id
:type type :type type
:shapes shapes :shapes (vals objects)
:libraries libraries}]) :libraries libraries}])
(when-not (empty? shadow-ids) (when-not (empty? shadow-ids)

View File

@@ -113,7 +113,7 @@
:data {:id id :data {:id id
:index index :index index
:name (:name page)} :name (:name page)}
:draggable? (not read-only?)) :draggable? (and (not read-only?) (not editing?)))
on-context-menu on-context-menu
(mf/use-fn (mf/use-fn

View File

@@ -29,7 +29,6 @@
[app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.input :refer [input*]] [app.main.ui.ds.controls.input :refer [input*]]
[app.main.ui.ds.controls.utilities.hint-message :refer [hint-message*]]
[app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]] [app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.notifications.context-notification :refer [context-notification*]] [app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
@@ -51,41 +50,6 @@
[malli.error :as me] [malli.error :as me]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
;; Schemas ---------------------------------------------------------------------
(def valid-token-name-regexp
"Only allow letters and digits for token names.
Also allow one `.` for a namespace separator.
Caution: This will allow a trailing dot like `token-name.`,
But we will trim that in the `finalize-name`,
to not throw too many errors while the user is editing."
#"(?!\$)([a-zA-Z0-9-$_]+\.?)*")
(def valid-token-name-schema
(m/-simple-schema
{:type :token/invalid-token-name
:pred #(re-matches valid-token-name-regexp %)
:type-properties {:error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))}}))
(defn token-name-schema
"Generate a dynamic schema validation to check if a token path derived from the name already exists at `tokens-tree`."
[{:keys [tokens-tree]}]
(let [path-exists-schema
(m/-simple-schema
{:type :token/name-exists
:pred #(not (cft/token-name-path-exists? % tokens-tree))
:type-properties {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}})]
(m/schema
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
valid-token-name-schema
path-exists-schema])))
(def token-description-schema
(m/schema
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]))
;; Helpers --------------------------------------------------------------------- ;; Helpers ---------------------------------------------------------------------
(defn finalize-name [name] (defn finalize-name [name]
@@ -103,7 +67,53 @@
(defn valid-value? [value] (defn valid-value? [value]
(seq (finalize-value value))) (seq (finalize-value value)))
;; Validation ------------------------------------------------------------------ ;; Schemas ---------------------------------------------------------------------
(def ^:private well-formed-token-name-regexp
"Only allow letters and digits for token names.
Also allow one `.` for a namespace separator.
Caution: This will allow a trailing dot like `token-name.`,
But we will trim that in the `finalize-name`,
to not throw too many errors while the user is editing."
#"(?!\$)([a-zA-Z0-9-$_]+\.?)*")
(def ^:private well-formed-token-name-schema
(m/-simple-schema
{:type :token/invalid-token-name
:pred #(re-matches well-formed-token-name-regexp %)
:type-properties {:error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))}}))
(defn- token-name-schema
"Generate a dynamic schema validation to check if a token path derived from the name already exists at `tokens-tree`."
[{:keys [tokens-tree]}]
(let [path-exists-schema
(m/-simple-schema
{:type :token/name-exists
:pred #(not (cft/token-name-path-exists? % tokens-tree))
:type-properties {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}})]
(m/schema
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
well-formed-token-name-schema
path-exists-schema])))
(defn- validate-token-name
[tokens-tree name]
(let [schema (token-name-schema {:tokens-tree tokens-tree})
validation (m/explain schema (finalize-name name))]
(me/humanize validation)))
(def ^:private token-description-schema
(m/schema
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]))
(defn- validate-token-description
[description]
(let [validation (m/explain token-description-schema description)]
(me/humanize validation)))
;; Value Validation -------------------------------------------------------------
(defn check-empty-value [token-value] (defn check-empty-value [token-value]
(when (empty? (str/trim token-value)) (when (empty? (str/trim token-value))
@@ -369,33 +379,26 @@
warning-name-change? (deref warning-name-change*) warning-name-change? (deref warning-name-change*)
token-name-ref (mf/use-var (:name token)) token-name-ref (mf/use-var (:name token))
name-ref (mf/use-ref nil) name-ref (mf/use-ref nil)
name-errors (mf/use-state nil) name-errors* (mf/use-state nil)
name-errors (deref name-errors*)
validate-name
(mf/use-fn
(mf/deps tokens-tree-in-selected-set)
(fn [value]
(let [schema (token-name-schema {:token token
:tokens-tree tokens-tree-in-selected-set})]
(m/explain schema (finalize-name value)))))
on-blur-name on-blur-name
(mf/use-fn (mf/use-fn
(mf/deps touched-name? warning-name-change?) (mf/deps touched-name? warning-name-change? tokens-tree-in-selected-set)
(fn [e] (fn [e]
(let [value (dom/get-target-val e) (let [value (dom/get-target-val e)
errors (validate-name value)] errors (validate-token-name tokens-tree-in-selected-set value)]
(when touched-name? (when touched-name?
(reset! warning-name-change* true)) (reset! warning-name-change* true))
(reset! name-errors errors)))) (reset! name-errors* errors))))
on-update-name-debounced on-update-name-debounced
(mf/use-fn (mf/use-fn
(mf/deps touched-name? validate-name) (mf/deps touched-name? tokens-tree-in-selected-set)
(uf/debounce (fn [token-name] (uf/debounce (fn [token-name]
(let [errors (validate-name token-name)] (let [errors (validate-token-name tokens-tree-in-selected-set token-name)]
(when touched-name? (when touched-name?
(reset! name-errors errors)))) (reset! name-errors* errors))))
300)) 300))
on-update-name on-update-name
@@ -409,7 +412,7 @@
(on-update-name-debounced token-name)))) (on-update-name-debounced token-name))))
valid-name-field? (and valid-name-field? (and
(not @name-errors) (not name-errors)
(valid-name? @token-name-ref)) (valid-name? @token-name-ref))
;; Value ;; Value
@@ -469,19 +472,20 @@
description-errors* (mf/use-state nil) description-errors* (mf/use-state nil)
description-errors (deref description-errors*) description-errors (deref description-errors*)
validate-descripion (mf/use-fn #(m/explain token-description-schema %)) on-update-description-debounced
on-update-description-debounced (mf/use-fn (mf/use-fn
(uf/debounce (fn [e] (uf/debounce (fn [e]
(let [value (dom/get-target-val e) (let [value (dom/get-target-val e)
errors (validate-descripion value)] errors (validate-token-description value)]
(reset! description-errors* errors))))) (reset! description-errors* errors)))))
on-update-description on-update-description
(mf/use-fn (mf/use-fn
(mf/deps on-update-description-debounced) (mf/deps on-update-description-debounced)
(fn [e] (fn [e]
(reset! description-ref (dom/get-target-val e)) (reset! description-ref (dom/get-target-val e))
(on-update-description-debounced e))) (on-update-description-debounced e)))
valid-description-field? (not description-errors) valid-description-field? (empty? description-errors)
;; Form ;; Form
disabled? (or (not valid-name-field?) disabled? (or (not valid-name-field?)
@@ -490,7 +494,7 @@
on-submit on-submit
(mf/use-fn (mf/use-fn
(mf/deps is-create validate-name validate-descripion token active-theme-tokens validate-token) (mf/deps is-create tokens-tree-in-selected-set token active-theme-tokens validate-token)
(fn [e] (fn [e]
(dom/prevent-default e) (dom/prevent-default e)
;; We have to re-validate the current form values before submitting ;; We have to re-validate the current form values before submitting
@@ -499,13 +503,13 @@
;; and press enter before the next validations could return. ;; and press enter before the next validations could return.
(let [final-name (finalize-name @token-name-ref) (let [final-name (finalize-name @token-name-ref)
valid-name? (try valid-name? (try
(not (:errors (validate-name final-name))) (empty? (:errors (validate-token-name tokens-tree-in-selected-set final-name)))
(catch js/Error _ nil)) (catch js/Error _ nil))
value (mf/ref-val value-ref) value (mf/ref-val value-ref)
final-description @description-ref final-description @description-ref
valid-description? (if final-description valid-description? (if final-description
(try (try
(not (:errors (validate-descripion final-description))) (empty? (:errors (validate-token-description final-description)))
(catch js/Error _ nil)) (catch js/Error _ nil))
true)] true)]
(when (and valid-name? valid-description?) (when (and valid-name? valid-description?)
@@ -599,21 +603,12 @@
:variant "comfortable" :variant "comfortable"
:auto-focus true :auto-focus true
:default-value @token-name-ref :default-value @token-name-ref
:hint-type (when (seq (:errors @name-errors)) "error") :hint-type (when-not (empty? name-errors) "error")
:hint-message (first name-errors)
:ref name-ref :ref name-ref
:on-blur on-blur-name :on-blur on-blur-name
:on-change on-update-name}]) :on-change on-update-name}])
(for [error (->> (:errors @name-errors)
(map #(-> (assoc @name-errors :errors [%])
(me/humanize)))
(map first))]
[:> hint-message* {:key error
:message error
:type "error"
:id "token-name-hint"}])
(when (and warning-name-change? (= action "edit")) (when (and warning-name-change? (= action "edit"))
[:div {:class (stl/css :warning-name-change-notification-wrapper)} [:div {:class (stl/css :warning-name-change-notification-wrapper)}
[:> context-notification* [:> context-notification*
@@ -650,6 +645,8 @@
:max-length max-input-length :max-length max-input-length
:variant "comfortable" :variant "comfortable"
:default-value @description-ref :default-value @description-ref
:hint-type (when-not (empty? description-errors) "error")
:hint-message (first description-errors)
:on-blur on-update-description :on-blur on-update-description
:on-change on-update-description}]] :on-change on-update-description}]]
@@ -1352,7 +1349,7 @@
:font-size :font-size
{:label "Font Size" {:label "Font Size"
:icon i/text-font-size :icon i/text-font-size
:placeholder (tr "workspace.tokens.token-value-enter")} :placeholder (tr "workspace.tokens.font-size-value-enter")}
:font-weight :font-weight
{:label "Font Weight" {:label "Font Weight"
:icon i/text-font-weight :icon i/text-font-weight

View File

@@ -162,7 +162,7 @@
:data {:index index :data {:index index
:is-group true} :is-group true}
:detect-center? true :detect-center? true
:draggable? is-draggable)] :draggable? (and is-draggable (not is-editing)))]
[:div {:ref dref [:div {:ref dref
:data-testid "tokens-set-group-item" :data-testid "tokens-set-group-item"
@@ -271,7 +271,7 @@
:on-drop on-drop :on-drop on-drop
:data {:index index :data {:index index
:is-group false} :is-group false}
:draggable? is-draggable) :draggable? (and is-draggable (not is-editing)))
drop-over drop-over
(get dprops :over)] (get dprops :over)]

View File

@@ -31,9 +31,32 @@
[app.util.i18n :refer [tr]] [app.util.i18n :refer [tr]]
[app.util.keyboard :as k] [app.util.keyboard :as k]
[cuerdas.core :as str] [cuerdas.core :as str]
[malli.core :as m]
[malli.error :as me]
[potok.v2.core :as ptk] [potok.v2.core :as ptk]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
;; Schemas ---------------------------------------------------------------------
(defn- theme-name-schema
"Generate a dynamic schema validation to check if a theme path derived from the name already exists at `tokens-tree`."
[{:keys [group theme-id tokens-lib]}]
(m/-simple-schema
{:type :token/name-exists
:pred (fn [name]
(let [theme (ctob/get-theme-by-name tokens-lib group name)]
(or (nil? theme)
(= (ctob/get-id theme) theme-id))))
:type-properties {:error/fn #(tr "workspace.tokens.theme-name-already-exists")}}))
(defn- validate-theme-name
[tokens-lib group theme-id name]
(let [schema (theme-name-schema {:tokens-lib tokens-lib :theme-id theme-id :group group})
validation (m/explain schema (str/trim name))]
(me/humanize validation)))
;; Form Component --------------------------------------------------------------
(mf/defc empty-themes (mf/defc empty-themes
[{:keys [change-view]}] [{:keys [change-view]}]
(let [create-theme (let [create-theme
@@ -166,25 +189,36 @@
(mf/defc theme-inputs* (mf/defc theme-inputs*
[{:keys [theme on-change-field]}] [{:keys [theme on-change-field]}]
(let [theme-groups (mf/deref refs/workspace-token-theme-groups) (let [tokens-lib (mf/deref refs/tokens-lib)
theme-groups (mf/deref refs/workspace-token-theme-groups)
theme-name-ref (mf/use-ref (:name theme)) theme-name-ref (mf/use-ref (:name theme))
options (map (fn [group] options (map (fn [group]
{:label group {:label group
:id group}) :id group})
theme-groups) theme-groups)
current-group* (mf/use-state (:group theme))
current-group (deref current-group*)
name-errors* (mf/use-state nil)
name-errors (deref name-errors*)
on-update-group on-update-group
(mf/use-fn (mf/use-fn
(mf/deps on-change-field) (mf/deps on-change-field)
#(on-change-field :group %)) (fn [value]
(reset! current-group* value)
(on-change-field :group value)))
on-update-name on-update-name
(mf/use-fn (mf/use-fn
(mf/deps on-change-field) (mf/deps on-change-field tokens-lib current-group)
(fn [event] (fn [event]
(let [value (-> event dom/get-target dom/get-value)] (let [value (-> event dom/get-target dom/get-value)
(on-change-field :name value) errors (validate-theme-name tokens-lib current-group (ctob/get-id theme) value)]
(mf/set-ref-val! theme-name-ref value))))] (reset! name-errors* errors)
(mf/set-ref-val! theme-name-ref value)
(if (empty? errors)
(on-change-field :name value)
(on-change-field :name "")))))]
[:div {:class (stl/css :edit-theme-inputs-wrapper)} [:div {:class (stl/css :edit-theme-inputs-wrapper)}
[:div {:class (stl/css :group-input-wrapper)} [:div {:class (stl/css :group-input-wrapper)}
@@ -202,6 +236,8 @@
:variant "comfortable" :variant "comfortable"
:default-value (mf/ref-val theme-name-ref) :default-value (mf/ref-val theme-name-ref)
:auto-focus true :auto-focus true
:hint-type (when-not (empty? name-errors) "error")
:hint-message (first name-errors)
:on-change on-update-name}]]])) :on-change on-update-name}]]]))
(mf/defc theme-modal-buttons* (mf/defc theme-modal-buttons*

View File

@@ -34,7 +34,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: deprecated.$s-16; gap: deprecated.$s-16;
max-height: deprecated.$s-688;
} }
.edit-theme-form { .edit-theme-form {

View File

@@ -12,15 +12,15 @@
(t/deftest test-valid-token-name-schema (t/deftest test-valid-token-name-schema
;; Allow regular namespace token names ;; Allow regular namespace token names
(t/is (some? (m/validate wtf/valid-token-name-schema "Foo"))) (t/is (some? (m/validate wtf/well-formed-token-name-schema "Foo")))
(t/is (some? (m/validate wtf/valid-token-name-schema "foo"))) (t/is (some? (m/validate wtf/well-formed-token-name-schema "foo")))
(t/is (some? (m/validate wtf/valid-token-name-schema "FOO"))) (t/is (some? (m/validate wtf/well-formed-token-name-schema "FOO")))
(t/is (some? (m/validate wtf/valid-token-name-schema "Foo.Bar.Baz"))) (t/is (some? (m/validate wtf/well-formed-token-name-schema "Foo.Bar.Baz")))
;; Allow trailing tokens ;; Allow trailing tokens
(t/is (nil? (m/validate wtf/valid-token-name-schema "Foo.Bar.Baz...."))) (t/is (nil? (m/validate wtf/well-formed-token-name-schema "Foo.Bar.Baz....")))
;; Disallow multiple separator dots ;; Disallow multiple separator dots
(t/is (nil? (m/validate wtf/valid-token-name-schema "Foo..Bar.Baz"))) (t/is (nil? (m/validate wtf/well-formed-token-name-schema "Foo..Bar.Baz")))
;; Disallow any special characters ;; Disallow any special characters
(t/is (nil? (m/validate wtf/valid-token-name-schema "Hey Foo.Bar"))) (t/is (nil? (m/validate wtf/well-formed-token-name-schema "Hey Foo.Bar")))
(t/is (nil? (m/validate wtf/valid-token-name-schema "Hey😈Foo.Bar"))) (t/is (nil? (m/validate wtf/well-formed-token-name-schema "Hey😈Foo.Bar")))
(t/is (nil? (m/validate wtf/valid-token-name-schema "Hey%Foo.Bar")))) (t/is (nil? (m/validate wtf/well-formed-token-name-schema "Hey%Foo.Bar"))))

View File

@@ -813,6 +813,12 @@ msgstr "Resend invitations"
msgid "dashboard.invite-profile" msgid "dashboard.invite-profile"
msgstr "Invite people" msgstr "Invite people"
#: src/app/main/ui/dashboard/team.cljs:933
msgid "team.invitations-selected"
msgid_plural "team.invitations-selected"
msgstr[0] "1 invitation selected"
msgstr[1] "%s invitations selected"
#: src/app/main/ui/dashboard/sidebar.cljs:459, src/app/main/ui/dashboard/sidebar.cljs:466, src/app/main/ui/dashboard/sidebar.cljs:471, src/app/main/ui/dashboard/team.cljs:351 #: src/app/main/ui/dashboard/sidebar.cljs:459, src/app/main/ui/dashboard/sidebar.cljs:466, src/app/main/ui/dashboard/sidebar.cljs:471, src/app/main/ui/dashboard/team.cljs:351
msgid "dashboard.leave-team" msgid "dashboard.leave-team"
msgstr "Leave team" msgstr "Leave team"
@@ -7633,6 +7639,10 @@ msgstr ""
msgid "workspace.tokens.font-weight-value-enter" msgid "workspace.tokens.font-weight-value-enter"
msgstr "Font weight (300, Bold Italic...) or an {alias}" msgstr "Font weight (300, Bold Italic...) or an {alias}"
#: src/app/main/ui/workspace/tokens/management/create/form.cljs
msgid "workspace.tokens.font-size-value-enter"
msgstr "Font size or {alias}"
#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:228 #: src/app/main/ui/workspace/tokens/management/context_menu.cljs:228
msgid "workspace.tokens.gaps" msgid "workspace.tokens.gaps"
msgstr "Gaps" msgstr "Gaps"
@@ -7929,6 +7939,10 @@ msgstr "none | underline | strike-through or {alias}"
msgid "workspace.tokens.theme-name" msgid "workspace.tokens.theme-name"
msgstr "Theme %s" msgstr "Theme %s"
#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:48
msgid "workspace.tokens.theme-name-already-exists"
msgstr "A theme with this name already exists"
#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:96 #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:96
msgid "workspace.tokens.themes-description" msgid "workspace.tokens.themes-description"
msgstr "" msgstr ""

View File

@@ -827,6 +827,12 @@ msgstr "Reenviar invitaciones"
msgid "dashboard.invite-profile" msgid "dashboard.invite-profile"
msgstr "Invitar a la gente" msgstr "Invitar a la gente"
#: src/app/main/ui/dashboard/team.cljs:933
msgid "team.invitations-selected"
msgid_plural "team.invitations-selected"
msgstr[0] "1 invitación seleccionada"
msgstr[1] "%s invitaciones seleccionadas"
#: src/app/main/ui/dashboard/sidebar.cljs:459, src/app/main/ui/dashboard/sidebar.cljs:466, src/app/main/ui/dashboard/sidebar.cljs:471, src/app/main/ui/dashboard/team.cljs:351 #: src/app/main/ui/dashboard/sidebar.cljs:459, src/app/main/ui/dashboard/sidebar.cljs:466, src/app/main/ui/dashboard/sidebar.cljs:471, src/app/main/ui/dashboard/team.cljs:351
msgid "dashboard.leave-team" msgid "dashboard.leave-team"
msgstr "Abandonar equipo" msgstr "Abandonar equipo"
@@ -7587,6 +7593,10 @@ msgstr ""
msgid "workspace.tokens.font-weight-value-enter" msgid "workspace.tokens.font-weight-value-enter"
msgstr "Font weight (300, Bold, Regular Italic...) o un {alias}" msgstr "Font weight (300, Bold, Regular Italic...) o un {alias}"
#: src/app/main/ui/workspace/tokens/management/create/form.cljs
msgid "workspace.tokens.font-size-value-enter"
msgstr "Font size o {alias}"
#: src/app/main/ui/workspace/tokens/style_dictionary.cljs #: src/app/main/ui/workspace/tokens/style_dictionary.cljs
#, unused #, unused
msgid "workspace.tokens.generic-error" msgid "workspace.tokens.generic-error"
@@ -7824,6 +7834,10 @@ msgstr "none | underline | strike-through o {alias}"
msgid "workspace.tokens.theme-name" msgid "workspace.tokens.theme-name"
msgstr "Tema %s" msgstr "Tema %s"
#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:48
msgid "workspace.tokens.theme-name-already-exists"
msgstr "Ya existe un tema con este nombre"
#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:96 #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:96
msgid "workspace.tokens.themes-description" msgid "workspace.tokens.themes-description"
msgstr "" msgstr ""