diff --git a/.circleci/config.yml b/.circleci/config.yml
index 3fb56cbef..6b54b1893 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -27,12 +27,6 @@ jobs:
yarn run fmt:clj:check
yarn run lint:clj
- - run:
- name: "JS tests"
- working_directory: "./common"
- command: |
- yarn run test
-
- run:
name: "JVM tests"
working_directory: "./common"
diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc
index 32bc1512a..0f59271f8 100644
--- a/common/src/app/common/data.cljc
+++ b/common/src/app/common/data.cljc
@@ -57,6 +57,58 @@
#?(:cljs (instance? lkm/LinkedMap o)
:clj (instance? LinkedMap o)))
+(defn oassoc
+ [o & kvs]
+ (apply assoc (or o (ordered-map)) kvs))
+
+(defn oassoc-in
+ [o [k & ks] v]
+ (if ks
+ (oassoc o k (oassoc-in (get o k) ks v))
+ (oassoc o k v)))
+
+(defn oupdate-in
+ [m ks f & args]
+ (let [up (fn up [m ks f args]
+ (let [[k & ks] ks]
+ (if ks
+ (oassoc m k (up (get m k) ks f args))
+ (oassoc m k (apply f (get m k) args)))))]
+ (up m ks f args)))
+
+(declare index-of)
+
+(defn oassoc-before
+ "Assoc a k v pair, in the order position just before the other key"
+ [o before-k k v]
+ (if-let [index (index-of (keys o) before-k)]
+ (-> (ordered-map)
+ (into (take index o))
+ (assoc k v)
+ (into (drop index o)))
+ (oassoc o k v)))
+
+(defn oassoc-in-before
+ [o [before-k & before-ks] [k & ks] v]
+ (if-let [index (index-of (keys o) before-k)]
+ (let [new-v (if ks
+ (oassoc-in-before (get o k) before-ks ks v)
+ v)
+ current-index (index-of (keys o) k)
+ new-index (if (and current-index (< current-index index))
+ (dec index)
+ index)]
+ (if (= k before-k)
+ (-> (ordered-map)
+ (into (take new-index o))
+ (assoc k new-v)
+ (into (drop (inc new-index) o)))
+ (-> (ordered-map)
+ (into (take new-index (dissoc o k)))
+ (assoc k new-v)
+ (into (drop new-index (dissoc o k))))))
+ (oassoc-in o (cons k ks) v)))
+
(defn vec2
"Creates a optimized vector compatible type of length 2 backed
internally with MapEntry impl because it has faster access method
diff --git a/common/src/app/common/features.cljc b/common/src/app/common/features.cljc
index c8fcb4b10..bd6cb6b7b 100644
--- a/common/src/app/common/features.cljc
+++ b/common/src/app/common/features.cljc
@@ -50,6 +50,7 @@
"styles/v2"
"layout/grid"
"plugins/runtime"
+ "design-tokens/v1"
"text-editor/v2"})
;; A set of features enabled by default
@@ -84,6 +85,7 @@
"layout/grid"
"fdata/shape-data-type"
"plugins/runtime"
+ "design-tokens/v1"
"text-editor/v2"}
(into frontend-only-features)))
@@ -104,6 +106,7 @@
:feature-fdata-objects-map "fdata/objects-map"
:feature-fdata-pointer-map "fdata/pointer-map"
:feature-plugins "plugins/runtime"
+ :feature-design-tokens "design-tokens/v1"
:feature-text-editor-v2 "text-editor/v2"
nil))
@@ -312,5 +315,3 @@
:feature (first not-supported)
:hint (str/ffmt "paste features '%' not enabled on the application"
(str/join "," not-supported))))))
-
-
diff --git a/common/src/app/common/files/changes.cljc b/common/src/app/common/files/changes.cljc
index 1ff501f34..c909f1924 100644
--- a/common/src/app/common/files/changes.cljc
+++ b/common/src/app/common/files/changes.cljc
@@ -26,6 +26,9 @@
[app.common.types.pages-list :as ctpl]
[app.common.types.shape :as cts]
[app.common.types.shape-tree :as ctst]
+ [app.common.types.token :as cto]
+ [app.common.types.token-theme :as ctot]
+ [app.common.types.tokens-lib :as ctob]
[app.common.types.typographies-list :as ctyl]
[app.common.types.typography :as ctt]
[app.common.uuid :as uuid]
@@ -366,7 +369,87 @@
[:del-typography
[:map {:title "DelTypogrphyChange"}
[:type [:= :del-typography]]
- [:id ::sm/uuid]]]]])
+ [:id ::sm/uuid]]]
+
+ [:add-temporary-token-theme
+ [:map {:title "AddTemporaryTokenThemeChange"}
+ [:type [:= :add-temporary-token-theme]]
+ [:token-theme ::ctot/token-theme]]]
+
+ [:update-active-token-themes
+ [:map {:title "UpdateActiveTokenThemes"}
+ [:type [:= :update-active-token-themes]]
+ [:theme-ids [:set :string]]]]
+
+ [:delete-temporary-token-theme
+ [:map {:title "DeleteTemporaryTokenThemeChange"}
+ [:type [:= :delete-temporary-token-theme]]
+ [:id ::sm/uuid]
+ [:name :string]]]
+
+ [:add-token-theme
+ [:map {:title "AddTokenThemeChange"}
+ [:type [:= :add-token-theme]]
+ [:token-theme ::ctot/token-theme]]]
+
+ [:mod-token-theme
+ [:map {:title "ModTokenThemeChange"}
+ [:type [:= :mod-token-theme]]
+ [:group :string]
+ [:name :string]
+ [:token-theme ::ctot/token-theme]]]
+
+ [:del-token-theme
+ [:map {:title "DelTokenThemeChange"}
+ [:type [:= :del-token-theme]]
+ [:group :string]
+ [:name :string]]]
+
+ [:add-token-set
+ [:map {:title "AddTokenSetChange"}
+ [:type [:= :add-token-set]]
+ [:token-set ::ctot/token-set]]]
+
+ [:mod-token-set
+ [:map {:title "ModTokenSetChange"}
+ [:type [:= :mod-token-set]]
+ [:name :string]
+ [:token-set ::ctot/token-set]]]
+
+ [:move-token-set-before
+ [:map {:title "MoveTokenSetBefore"}
+ [:type [:= :move-token-set-before]]
+ [:set-name :string]
+ [:before-set-name [:maybe :string]]]]
+
+ [:del-token-set
+ [:map {:title "DelTokenSetChange"}
+ [:type [:= :del-token-set]]
+ [:name :string]]]
+
+ [:set-tokens-lib
+ [:map {:title "SetTokensLib"}
+ [:type [:= :set-tokens-lib]]
+ [:tokens-lib :any]]]
+
+ [:add-token
+ [:map {:title "AddTokenChange"}
+ [:type [:= :add-token]]
+ [:set-name :string]
+ [:token ::cto/token]]]
+
+ [:mod-token
+ [:map {:title "ModTokenChange"}
+ [:type [:= :mod-token]]
+ [:set-name :string]
+ [:name :string]
+ [:token ::cto/token]]]
+
+ [:del-token
+ [:map {:title "DelTokenChange"}
+ [:type [:= :del-token]]
+ [:set-name :string]
+ [:name :string]]]]])
(def schema:changes
[:sequential {:gen/max 5 :gen/min 1} schema:change])
@@ -889,6 +972,103 @@
[data {:keys [id]}]
(ctyl/delete-typography data id))
+;; -- Tokens
+
+(defmethod process-change :set-tokens-lib
+ [data {:keys [tokens-lib]}]
+ (assoc data :tokens-lib tokens-lib))
+
+(defmethod process-change :add-token
+ [data {:keys [set-name token]}]
+ (update data :tokens-lib #(-> %
+ (ctob/ensure-tokens-lib)
+ (ctob/add-token-in-set set-name (ctob/make-token token)))))
+
+(defmethod process-change :mod-token
+ [data {:keys [set-name name token]}]
+ (update data :tokens-lib #(-> %
+ (ctob/ensure-tokens-lib)
+ (ctob/update-token-in-set
+ set-name
+ name
+ (fn [old-token]
+ (ctob/make-token (merge old-token token)))))))
+
+(defmethod process-change :del-token
+ [data {:keys [set-name name]}]
+ (update data :tokens-lib #(-> %
+ (ctob/ensure-tokens-lib)
+ (ctob/delete-token-from-set
+ set-name
+ name))))
+
+(defmethod process-change :add-temporary-token-theme
+ [data {:keys [token-theme]}]
+ (update data :tokens-lib #(-> %
+ (ctob/ensure-tokens-lib)
+ (ctob/add-theme (ctob/make-token-theme token-theme)))))
+
+(defmethod process-change :update-active-token-themes
+ [data {:keys [theme-ids]}]
+ (update data :tokens-lib #(-> % (ctob/ensure-tokens-lib)
+ (ctob/set-active-themes theme-ids))))
+
+(defmethod process-change :delete-temporary-token-theme
+ [data {:keys [group name]}]
+ (update data :tokens-lib #(-> %
+ (ctob/ensure-tokens-lib)
+ (ctob/delete-theme group name))))
+
+(defmethod process-change :add-token-theme
+ [data {:keys [token-theme]}]
+ (update data :tokens-lib #(-> %
+ (ctob/ensure-tokens-lib)
+ (ctob/add-theme (-> token-theme
+ (ctob/make-token-theme))))))
+
+(defmethod process-change :mod-token-theme
+ [data {:keys [name group token-theme]}]
+ (update data :tokens-lib #(-> %
+ (ctob/ensure-tokens-lib)
+ (ctob/update-theme group name
+ (fn [prev-theme]
+ (merge prev-theme token-theme))))))
+
+(defmethod process-change :del-token-theme
+ [data {:keys [group name]}]
+ (update data :tokens-lib #(-> %
+ (ctob/ensure-tokens-lib)
+ (ctob/delete-theme group name))))
+
+(defmethod process-change :add-token-set
+ [data {:keys [token-set]}]
+ (update data :tokens-lib #(-> %
+ (ctob/ensure-tokens-lib)
+ (ctob/add-set (ctob/make-token-set token-set)))))
+
+(defmethod process-change :mod-token-set
+ [data {:keys [name token-set]}]
+ (update data :tokens-lib (fn [lib]
+ (let [path-changed? (not= name (:name token-set))
+ lib' (-> lib
+ (ctob/ensure-tokens-lib)
+ (ctob/update-set name (fn [prev-set]
+ (merge prev-set (dissoc token-set :tokens)))))]
+ (cond-> lib'
+ path-changed? (ctob/update-set-name name (:name token-set)))))))
+
+(defmethod process-change :move-token-set-before
+ [data {:keys [set-name before-set-name]}]
+ (update data :tokens-lib #(-> %
+ (ctob/ensure-tokens-lib)
+ (ctob/move-set-before set-name before-set-name))))
+
+(defmethod process-change :del-token-set
+ [data {:keys [name]}]
+ (update data :tokens-lib #(-> %
+ (ctob/ensure-tokens-lib)
+ (ctob/delete-set name))))
+
;; === Operations
(def ^:private decode-shape
diff --git a/common/src/app/common/files/changes_builder.cljc b/common/src/app/common/files/changes_builder.cljc
index ce593e8a4..9da21cfda 100644
--- a/common/src/app/common/files/changes_builder.cljc
+++ b/common/src/app/common/files/changes_builder.cljc
@@ -20,6 +20,7 @@
[app.common.types.component :as ctk]
[app.common.types.file :as ctf]
[app.common.types.shape.layout :as ctl]
+ [app.common.types.tokens-lib :as ctob]
[app.common.uuid :as uuid]))
;; Auxiliary functions to help create a set of changes (undo + redo)
@@ -760,6 +761,116 @@
(update :undo-changes conj {:type :add-typography :typography prev-typography})
(apply-changes-local))))
+(defn add-temporary-token-theme
+ [changes token-theme]
+ (-> changes
+ (update :redo-changes conj {:type :add-temporary-token-theme :token-theme token-theme})
+ (update :undo-changes conj {:type :delete-temporary-token-theme :id (:id token-theme) :name (:name token-theme)})
+ (apply-changes-local)))
+
+(defn update-active-token-themes
+ [changes token-active-theme-ids prev-token-active-theme-ids]
+ (-> changes
+ (update :redo-changes conj {:type :update-active-token-themes :theme-ids token-active-theme-ids})
+ (update :undo-changes conj {:type :update-active-token-themes :theme-ids prev-token-active-theme-ids})
+ (apply-changes-local)))
+
+(defn add-token-theme
+ [changes token-theme]
+ (-> changes
+ (update :redo-changes conj {:type :add-token-theme :token-theme token-theme})
+ (update :undo-changes conj {:type :del-token-theme :group (:group token-theme) :name (:name token-theme)})
+ (apply-changes-local)))
+
+(defn update-token-theme
+ [changes token-theme prev-token-theme]
+ (let [name (or (:name prev-token-theme)
+ (:name token-theme))
+ group (or (:group prev-token-theme)
+ (:group token-theme))]
+ (-> changes
+ (update :redo-changes conj {:type :mod-token-theme :group group :name name :token-theme token-theme})
+ (update :undo-changes conj {:type :mod-token-theme :group group :name name :token-theme (or prev-token-theme token-theme)})
+ (apply-changes-local))))
+
+(defn delete-token-theme
+ [changes group name]
+ (assert-library! changes)
+ (let [library-data (::library-data (meta changes))
+ prev-token-theme (some-> (get library-data :tokens-lib)
+ (ctob/get-theme group name))]
+ (-> changes
+ (update :redo-changes conj {:type :del-token-theme :group group :name name})
+ (update :undo-changes conj {:type :add-token-theme :token-theme prev-token-theme})
+ (apply-changes-local))))
+
+(defn add-token-set
+ [changes token-set]
+ (-> changes
+ (update :redo-changes conj {:type :add-token-set :token-set token-set})
+ (update :undo-changes conj {:type :del-token-set :name (:name token-set)})
+ (apply-changes-local)))
+
+(defn update-token-set
+ [changes token-set prev-token-set]
+ (-> changes
+ (update :redo-changes conj {:type :mod-token-set :name (:name prev-token-set) :token-set token-set})
+ (update :undo-changes conj {:type :mod-token-set :name (:name token-set) :token-set (or prev-token-set token-set)})
+ (apply-changes-local)))
+
+(defn delete-token-set
+ [changes token-set-name]
+ (assert-library! changes)
+ (let [library-data (::library-data (meta changes))
+ prev-token-theme (some-> (get library-data :tokens-lib)
+ (ctob/get-set token-set-name))]
+ (-> changes
+ (update :redo-changes conj {:type :del-token-set :name token-set-name})
+ (update :undo-changes conj {:type :add-token-set :token-set prev-token-theme})
+ (apply-changes-local))))
+
+(defn move-token-set-before
+ [changes set-name before-set-name prev-before-set-name]
+ (-> changes
+ (update :redo-changes conj {:type :move-token-set-before :set-name set-name :before-set-name before-set-name})
+ (update :undo-changes conj {:type :move-token-set-before :set-name set-name :before-set-name prev-before-set-name})
+ (apply-changes-local)))
+
+(defn set-tokens-lib
+ [changes tokens-lib]
+ (let [library-data (::library-data (meta changes))
+ prev-tokens-lib (get library-data :tokens-lib)]
+ (-> changes
+ (update :redo-changes conj {:type :set-tokens-lib :tokens-lib tokens-lib})
+ (update :undo-changes conj {:type :set-tokens-lib :tokens-lib prev-tokens-lib})
+ (apply-changes-local))))
+
+(defn add-token
+ [changes set-name token]
+ (-> changes
+ (update :redo-changes conj {:type :add-token :set-name set-name :token token})
+ (update :undo-changes conj {:type :del-token :set-name set-name :name (:name token)})
+ (apply-changes-local)))
+
+(defn update-token
+ [changes set-name token prev-token]
+ (-> changes
+ (update :redo-changes conj {:type :mod-token :set-name set-name :name (:name prev-token) :token token})
+ (update :undo-changes conj {:type :mod-token :set-name set-name :name (:name token) :token (or prev-token token)})
+ (apply-changes-local)))
+
+(defn delete-token
+ [changes set-name token-name]
+ (assert-library! changes)
+ (let [library-data (::library-data (meta changes))
+ prev-token (some-> (get library-data :tokens-lib)
+ (ctob/get-set set-name)
+ (ctob/get-token token-name))]
+ (-> changes
+ (update :redo-changes conj {:type :del-token :set-name set-name :name token-name})
+ (update :undo-changes conj {:type :add-token :set-name set-name :token prev-token})
+ (apply-changes-local))))
+
(defn add-component
([changes id path name new-shapes updated-shapes main-instance-id main-instance-page]
(add-component changes id path name new-shapes updated-shapes main-instance-id main-instance-page nil))
diff --git a/common/src/app/common/fressian.clj b/common/src/app/common/fressian.clj
index 4a640cd8c..7e35f3116 100644
--- a/common/src/app/common/fressian.clj
+++ b/common/src/app/common/fressian.clj
@@ -16,6 +16,7 @@
java.time.OffsetDateTime
java.util.List
linked.map.LinkedMap
+ linked.set.LinkedSet
org.fressian.Reader
org.fressian.StreamingWriter
org.fressian.Writer
@@ -275,7 +276,12 @@
{:name "clj/seq"
:class clojure.lang.ISeq
:wfn write-list-like
- :rfn (comp sequence read-object!)})
+ :rfn (comp sequence read-object!)}
+
+ {:name "linked/set"
+ :class LinkedSet
+ :wfn write-list-like
+ :rfn (comp #(into (d/ordered-set) %) read-object!)})
;; --- PUBLIC API
diff --git a/common/src/app/common/time.cljc b/common/src/app/common/time.cljc
index 02c41f946..4f27d0531 100644
--- a/common/src/app/common/time.cljc
+++ b/common/src/app/common/time.cljc
@@ -27,6 +27,11 @@
#?(:clj (Instant/now)
:cljs (.local ^js DateTime)))
+#?(:clj
+ (defn is-after?
+ [one other]
+ (.isAfter one other)))
+
(defn instant?
[o]
#?(:clj (instance? Instant o)
@@ -51,6 +56,8 @@
#?(:clj (.format DateTimeFormatter/ISO_INSTANT ^Instant v)
:cljs (.toISO ^js v)))
+;; To check for valid date time we can just use the core inst? function
+
#?(:cljs
(extend-protocol IComparable
DateTime
diff --git a/common/src/app/common/types/file.cljc b/common/src/app/common/types/file.cljc
index 8dab26240..739bca6d1 100644
--- a/common/src/app/common/types/file.cljc
+++ b/common/src/app/common/types/file.cljc
@@ -26,6 +26,7 @@
[app.common.types.pages-list :as ctpl]
[app.common.types.plugins :as ctpg]
[app.common.types.shape-tree :as ctst]
+ [app.common.types.tokens-lib :as ctl]
[app.common.types.typographies-list :as ctyl]
[app.common.types.typography :as cty]
[app.common.uuid :as uuid]
@@ -69,7 +70,8 @@
[:colors {:optional true} schema:colors]
[:components {:optional true} schema:components]
[:typographies {:optional true} schema:typographies]
- [:plugin-data {:optional true} ::ctpg/plugin-data]])
+ [:plugin-data {:optional true} ::ctpg/plugin-data]
+ [:tokens-lib {:optional true} ::ctl/tokens-lib]])
(def schema:file
"A schema for validate a file data structure; data is optional
diff --git a/common/src/app/common/types/shape.cljc b/common/src/app/common/types/shape.cljc
index 0d8cf11ae..ad9817490 100644
--- a/common/src/app/common/types/shape.cljc
+++ b/common/src/app/common/types/shape.cljc
@@ -27,6 +27,7 @@
[app.common.types.shape.path :as ctsp]
[app.common.types.shape.shadow :as ctss]
[app.common.types.shape.text :as ctsx]
+ [app.common.types.token :as cto]
[app.common.uuid :as uuid]
[clojure.set :as set]))
@@ -189,6 +190,7 @@
[:blur {:optional true} ::ctsb/blur]
[:grow-type {:optional true}
[::sm/one-of grow-types]]
+ [:applied-tokens {:optional true} ::cto/applied-tokens]
[:plugin-data {:optional true} ::ctpg/plugin-data]])
(def schema:group-attrs
diff --git a/common/src/app/common/types/shape/attrs.cljc b/common/src/app/common/types/shape/attrs.cljc
index 84fc30f81..75509094e 100644
--- a/common/src/app/common/types/shape/attrs.cljc
+++ b/common/src/app/common/types/shape/attrs.cljc
@@ -23,6 +23,8 @@
:show-content
:hide-in-viewer
+ :applied-tokens
+
:opacity
:blend-mode
:blocked
@@ -95,6 +97,8 @@
:parent-id
:frame-id
+ :applied-tokens
+
:opacity
:blend-mode
:blocked
diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc
new file mode 100644
index 000000000..651cb58ef
--- /dev/null
+++ b/common/src/app/common/types/token.cljc
@@ -0,0 +1,151 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS INC
+
+(ns app.common.types.token
+ (:require
+ [app.common.schema :as sm]
+ [app.common.schema.registry :as sr]
+ [clojure.set :as set]
+ [malli.util :as mu]))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; HELPERS
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(defn merge-schemas
+ "Merge registered schemas."
+ [& schema-keys]
+ (let [schemas (map #(get @sr/registry %) schema-keys)]
+ (reduce sm/merge schemas)))
+
+(defn schema-keys
+ "Converts registed map schema into set of keys."
+ [registered-schema]
+ (->> (get @sr/registry registered-schema)
+ (sm/schema)
+ (mu/keys)
+ (into #{})))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; SCHEMA
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(def token-type->dtcg-token-type
+ {:boolean "boolean"
+ :border-radius "borderRadius"
+ :color "color"
+ :dimensions "dimension"
+ :numeric "numeric"
+ :opacity "opacity"
+ :other "other"
+ :rotation "rotation"
+ :sizing "sizing"
+ :spacing "spacing"
+ :string "string"
+ :stroke-width "strokeWidth"})
+
+(def dtcg-token-type->token-type
+ (set/map-invert token-type->dtcg-token-type))
+
+(def token-types
+ (into #{} (keys token-type->dtcg-token-type)))
+
+(defn valid-token-type?
+ [t]
+ (token-types t))
+
+(def token-name-ref :string)
+
+(defn valid-token-name-ref?
+ [n]
+ (string? n))
+
+;; TODO Move this to tokens-lib
+(sm/register! ::token
+ [:map {:title "Token"}
+ [:name token-name-ref]
+ [:type [::sm/one-of token-types]]
+ [:value :any]
+ [:description {:optional true} [:maybe :string]]
+ [:modified-at {:optional true} ::sm/inst]])
+
+(sm/register! ::color
+ [:map
+ [:color {:optional true} token-name-ref]])
+
+(def color-keys (schema-keys ::color))
+
+(sm/register! ::border-radius
+ [:map
+ [:rx {:optional true} token-name-ref]
+ [:ry {:optional true} token-name-ref]
+ [:r1 {:optional true} token-name-ref]
+ [:r2 {:optional true} token-name-ref]
+ [:r3 {:optional true} token-name-ref]
+ [:r4 {:optional true} token-name-ref]])
+
+(def border-radius-keys (schema-keys ::border-radius))
+
+(sm/register! ::stroke-width
+ [:map
+ [:stroke-width {:optional true} token-name-ref]])
+
+(def stroke-width-keys (schema-keys ::stroke-width))
+
+(sm/register! ::sizing
+ [:map
+ [:width {:optional true} token-name-ref]
+ [:height {:optional true} token-name-ref]
+ [:layout-item-min-w {:optional true} token-name-ref]
+ [:layout-item-max-w {:optional true} token-name-ref]
+ [:layout-item-min-h {:optional true} token-name-ref]
+ [:layout-item-max-h {:optional true} token-name-ref]])
+
+(def sizing-keys (schema-keys ::sizing))
+
+(sm/register! ::opacity
+ [:map
+ [:opacity {:optional true} token-name-ref]])
+
+(def opacity-keys (schema-keys ::opacity))
+
+(sm/register! ::spacing
+ [:map
+ [:row-gap {:optional true} token-name-ref]
+ [:column-gap {:optional true} token-name-ref]
+ [:p1 {:optional true} token-name-ref]
+ [:p2 {:optional true} token-name-ref]
+ [:p3 {:optional true} token-name-ref]
+ [:p4 {:optional true} token-name-ref]
+ [:x {:optional true} token-name-ref]
+ [:y {:optional true} token-name-ref]])
+
+(def spacing-keys (schema-keys ::spacing))
+
+(sm/register! ::dimensions
+ (merge-schemas ::sizing
+ ::spacing
+ ::stroke-width
+ ::border-radius))
+
+(def dimensions-keys (schema-keys ::dimensions))
+
+(sm/register! ::rotation
+ [:map
+ [:rotation {:optional true} token-name-ref]])
+
+(def rotation-keys (schema-keys ::rotation))
+
+(sm/register! ::tokens
+ [:map {:title "Applied Tokens"}])
+
+(sm/register! ::applied-tokens
+ (merge-schemas ::tokens
+ ::border-radius
+ ::sizing
+ ::spacing
+ ::rotation
+ ::dimensions))
diff --git a/common/src/app/common/types/token_theme.cljc b/common/src/app/common/types/token_theme.cljc
new file mode 100644
index 000000000..ed7388995
--- /dev/null
+++ b/common/src/app/common/types/token_theme.cljc
@@ -0,0 +1,25 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS INC
+
+(ns app.common.types.token-theme
+ (:require
+ [app.common.schema :as sm]))
+
+(sm/register! ::token-theme
+ [:map {:title "TokenTheme"}
+ [:name :string]
+ [:group :string]
+ [:description [:maybe :string]]
+ [:is-source :boolean]
+ [:modified-at {:optional true} ::sm/inst]
+ [:sets :any]])
+
+(sm/register! ::token-set
+ [:map {:title "TokenSet"}
+ [:name :string]
+ [:description {:optional true} [:maybe :string]]
+ [:modified-at {:optional true} ::sm/inst]
+ [:tokens :any]])
diff --git a/common/src/app/common/types/tokens_lib.cljc b/common/src/app/common/types/tokens_lib.cljc
new file mode 100644
index 000000000..397072f55
--- /dev/null
+++ b/common/src/app/common/types/tokens_lib.cljc
@@ -0,0 +1,947 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS INC
+
+(ns app.common.types.tokens-lib
+ (:require
+ #?(:clj [app.common.fressian :as fres])
+ [app.common.data :as d]
+ [app.common.data.macros :as dm]
+ [app.common.schema :as sm]
+ [app.common.time :as dt]
+ [app.common.transit :as t]
+ [app.common.types.token :as cto]
+ [clojure.set :as set]
+ [clojure.walk :as walk]
+ [cuerdas.core :as str]))
+
+;; === Groups handling
+
+(def schema:groupable-item
+ [:map {:title "Groupable item"}
+ [:name :string]])
+
+(def valid-groupable-item?
+ (sm/validator schema:groupable-item))
+
+(defn split-path
+ "Decompose a string in the form 'one.two.three' into a vector of strings, removing spaces."
+ [path separator]
+ (let [xf (comp (map str/trim)
+ (remove str/empty?))]
+ (->> (str/split path separator)
+ (into [] xf))))
+
+(defn join-path
+ "Regenerate a path as a string, from a vector."
+ [path separator]
+ (str/join separator path))
+
+(defn group-item
+ "Add a group to the item name, in the form group.name."
+ [item group-name separator]
+ (dm/assert!
+ "expected groupable item"
+ (valid-groupable-item? item))
+ (update item :name #(str group-name separator %)))
+
+(defn ungroup-item
+ "Remove the first group from the item name."
+ [item separator]
+ (dm/assert!
+ "expected groupable item"
+ (valid-groupable-item? item))
+ (update item :name #(-> %
+ (split-path separator)
+ (rest)
+ (join-path separator))))
+
+(defn get-path
+ "Get the groups part of the name as a vector. E.g. group.subgroup.name -> ['group' 'subrgoup']"
+ [item separator]
+ (dm/assert!
+ "expected groupable item"
+ (valid-groupable-item? item))
+ (split-path (:name item) separator))
+
+(defn get-groups-str
+ "Get the groups part of the name. E.g. group.subgroup.name -> group.subrgoup"
+ [item separator]
+ (-> (get-path item separator)
+ (butlast)
+ (join-path separator)))
+
+(defn get-final-name
+ "Get the final part of the name. E.g. group.subgroup.name -> name"
+ [item separator]
+ (dm/assert!
+ "expected groupable item"
+ (valid-groupable-item? item))
+ (-> (:name item)
+ (split-path separator)
+ (last)))
+
+(defn group?
+ "Check if a node of the grouping tree is a group or a final item."
+ [item]
+ (d/ordered-map? item))
+
+(defn get-children
+ "Get all children of a group of a grouping tree. Each child is
+ a tuple [name item], where item "
+ [group]
+ (dm/assert!
+ "expected group node"
+ (group? group))
+ (seq group))
+
+;; === Token
+
+(def token-separator ".")
+
+(defn get-token-path [path]
+ (get-path path token-separator))
+
+(defn split-token-path [path]
+ (split-path path token-separator))
+
+(defrecord Token [name type value description modified-at])
+
+(def schema:token
+ [:and
+ [:map {:title "Token"}
+ [:name cto/token-name-ref]
+ [:type [::sm/one-of cto/token-types]]
+ [:value :any]
+ [:description [:maybe :string]]
+ [:modified-at ::sm/inst]]
+ [:fn (partial instance? Token)]])
+
+(sm/register! ::token schema:token)
+
+(def valid-token?
+ (sm/validator schema:token))
+
+(def check-token!
+ (sm/check-fn ::token))
+
+(defn make-token
+ [& {:keys [] :as params}]
+ (let [params (-> params
+ (dissoc :id) ;; we will remove this when old data structures are removed
+ (update :modified-at #(or % (dt/now))))
+ token (map->Token params)]
+
+ (dm/assert!
+ "expected valid token"
+ (check-token! token))
+
+ token))
+
+(defn find-token-value-references
+ "Returns set of token references found in `token-value`.
+
+ Used for checking if a token has a reference in the value.
+ Token references are strings delimited by curly braces.
+ E.g.: {foo.bar.baz} -> foo.bar.baz"
+ [token-value]
+ (some->> (re-seq #"\{([^}]*)\}" token-value)
+ (map second)
+ (into #{})))
+
+(defn token-value-self-reference?
+ "Check if the token is self referencing with its `token-name` in `token-value`.
+ Simple 1 level check, doesn't account for circular self refernces across multiple tokens."
+ [token-name token-value]
+ (let [token-references (find-token-value-references token-value)
+ self-reference? (get token-references token-name)]
+ self-reference?))
+
+(defn group-by-type [tokens]
+ (let [tokens' (if (or (map? tokens)
+ (d/ordered-map? tokens))
+ (vals tokens)
+ tokens)]
+ (group-by :type tokens')))
+
+(defn filter-by-type [token-type tokens]
+ (let [token-type? #(= token-type (:type %))]
+ (cond
+ (d/ordered-map? tokens) (into (d/ordered-map) (filter (comp token-type? val) tokens))
+ (map? tokens) (into {} (filter (comp token-type? val) tokens))
+ :else (filter token-type? tokens))))
+
+;; === Token Set
+
+(def set-separator "/")
+
+(defn get-token-set-path [path]
+ (get-path path set-separator))
+
+(defn get-token-set-group-str [path]
+ (get-groups-str path set-separator))
+
+(defn split-token-set-path [path]
+ (split-path path set-separator))
+
+(defn tokens-tree
+ "Convert tokens into a nested tree with their `:name` as the path.
+ Optionally use `update-token-fn` option to transform the token."
+ [tokens & {:keys [update-token-fn]
+ :or {update-token-fn identity}}]
+ (reduce
+ (fn [acc [_ token]]
+ (let [path (split-token-path (:name token))]
+ (assoc-in acc path (update-token-fn token))))
+ {} tokens))
+
+(defn backtrace-tokens-tree
+ "Convert tokens into a nested tree with their `:name` as the path.
+ Generates a uuid per token to backtrace a token from an external source (StyleDictionary).
+ The backtrace can't be the name as the name might not exist when the user is creating a token."
+ [tokens]
+ (reduce
+ (fn [acc [_ token]]
+ (let [temp-id (random-uuid)
+ token (assoc token :temp/id temp-id)
+ path (split-token-path (:name token))]
+ (-> acc
+ (assoc-in (concat [:tokens-tree] path) token)
+ (assoc-in [:ids temp-id] token))))
+ {:tokens-tree {} :ids {}} tokens))
+
+(defprotocol ITokenSet
+ (add-token [_ token] "add a token at the end of the list")
+ (update-token [_ token-name f] "update a token in the list")
+ (delete-token [_ token-name] "delete a token from the list")
+ (get-token [_ token-name] "return token by token-name")
+ (get-tokens [_] "return an ordered sequence of all tokens in the set")
+ (get-tokens-tree [_] "returns a tree of tokens split & nested by their name path")
+ (get-dtcg-tokens-tree [_] "returns tokens tree formated to the dtcg spec"))
+
+(defrecord TokenSet [name description modified-at tokens]
+ ITokenSet
+ (add-token [_ token]
+ (dm/assert! "expected valid token" (check-token! token))
+ (TokenSet. name
+ description
+ (dt/now)
+ (assoc tokens (:name token) token)))
+
+ (update-token [this token-name f]
+ (if-let [token (get tokens token-name)]
+ (let [token' (-> (make-token (f token))
+ (assoc :modified-at (dt/now)))]
+ (check-token! token')
+ (TokenSet. name
+ description
+ (dt/now)
+ (if (= (:name token) (:name token'))
+ (assoc tokens (:name token') token')
+ (-> tokens
+ (d/oassoc-before (:name token) (:name token') token')
+ (dissoc (:name token))))))
+ this))
+
+ (delete-token [_ token-name]
+ (TokenSet. name
+ description
+ (dt/now)
+ (dissoc tokens token-name)))
+
+ (get-token [_ token-name]
+ (get tokens token-name))
+
+ (get-tokens [_]
+ (vals tokens))
+
+ (get-tokens-tree [_]
+ (tokens-tree tokens))
+
+ (get-dtcg-tokens-tree [_]
+ (tokens-tree tokens :update-token-fn (fn [token]
+ (cond-> {"$value" (:value token)
+ "$type" (cto/token-type->dtcg-token-type (:type token))}
+ (:description token) (assoc "$description" (:description token)))))))
+
+(def schema:token-set
+ [:and [:map {:title "TokenSet"}
+ [:name :string]
+ [:description [:maybe :string]]
+ [:modified-at ::sm/inst]
+ [:tokens [:and [:map-of {:gen/max 5} :string ::token]
+ [:fn d/ordered-map?]]]]
+ [:fn (partial instance? TokenSet)]])
+
+(sm/register! ::token-set schema:token-set)
+
+(def valid-token-set?
+ (sm/validator schema:token-set))
+
+(def check-token-set!
+ (sm/check-fn ::token-set))
+
+(defn make-token-set
+ [& {:keys [] :as params}]
+ (let [params (-> params
+ (dissoc :id)
+ (update :modified-at #(or % (dt/now)))
+ (update :tokens #(into (d/ordered-map) %)))
+ token-set (map->TokenSet params)]
+
+ (dm/assert!
+ "expected valid token set"
+ (check-token-set! token-set))
+
+ token-set))
+
+;; === TokenSetGroup
+
+(defrecord TokenSetGroup [attr1 attr2])
+
+;; TODO schema, validators, etc.
+
+(defn make-token-set-group
+ []
+ (TokenSetGroup. "one" "two"))
+
+;; === TokenSets (collection)
+
+(defprotocol ITokenSets
+ (add-set [_ token-set] "add a set to the library, at the end")
+ (update-set [_ set-name f] "modify a set in the ilbrary")
+ (delete-set [_ set-name] "delete a set in the library")
+ (move-set-before [_ set-name before-set-name] "move a set with `set-name` before a set with `before-set-name` in the library.
+When `before-set-name` is nil, move set to bottom")
+ (set-count [_] "get the total number if sets in the library")
+ (get-set-tree [_] "get a nested tree of all sets in the library")
+ (get-sets [_] "get an ordered sequence of all sets in the library")
+ (get-ordered-set-names [_] "get an ordered sequence of all sets names in the library")
+ (get-set [_ set-name] "get one set looking for name")
+ (get-neighbor-set-name [_ set-name index-offset] "get neighboring set name offset by `index-offset`")
+ (get-set-group [_ set-group-path] "get the attributes of a set group"))
+
+(def schema:token-set-node
+ [:schema {:registry {::node [:or ::token-set
+ [:and
+ [:map-of {:gen/max 5} :string [:ref ::node]]
+ [:fn d/ordered-map?]]]}}
+ [:ref ::node]])
+
+(sm/register! ::token-set-node schema:token-set-node)
+
+(def schema:token-sets
+ [:and
+ [:map-of {:title "TokenSets"}
+ :string ::token-set-node]
+ [:fn d/ordered-map?]])
+
+(sm/register! ::token-sets schema:token-sets)
+
+(def valid-token-sets?
+ (sm/validator schema:token-sets))
+
+(def check-token-sets!
+ (sm/check-fn ::token-sets))
+
+;; === TokenTheme
+
+(def theme-separator "/")
+
+(defn token-theme-path [group name]
+ (join-path [group name] theme-separator))
+
+(defn split-token-theme-path [path]
+ (split-path path theme-separator))
+
+(def hidden-token-theme-group
+ "")
+
+(def hidden-token-theme-name
+ "__PENPOT__HIDDEN__TOKEN__THEME__")
+
+(def hidden-token-theme-path
+ (token-theme-path hidden-token-theme-group hidden-token-theme-name))
+
+
+(defprotocol ITokenTheme
+ (set-sets [_ set-names] "set the active token sets")
+ (disable-set [_ set-name] "disable set in theme")
+ (toggle-set [_ set-name] "toggle a set enabled / disabled in the theme")
+ (theme-path [_] "get `token-theme-path` from theme")
+ (theme-matches-group-name [_ group name] "if a theme matches the given group & name")
+ (hidden-temporary-theme? [_] "if a theme is the (from the user ui) hidden temporary theme"))
+
+(defrecord TokenTheme [name group description is-source modified-at sets]
+ ITokenTheme
+ (set-sets [_ set-names]
+ (TokenTheme. name
+ group
+ description
+ is-source
+ (dt/now)
+ set-names))
+
+ (disable-set [this set-name]
+ (set-sets this (disj sets set-name)))
+
+ (toggle-set [this set-name]
+ (set-sets this (if (sets set-name)
+ (disj sets set-name)
+ (conj sets set-name))))
+
+ (theme-path [_]
+ (token-theme-path group name))
+
+ (theme-matches-group-name [this group name]
+ (and (= (:group this) group)
+ (= (:name this) name)))
+
+ (hidden-temporary-theme? [this]
+ (theme-matches-group-name this hidden-token-theme-group hidden-token-theme-name)))
+
+(def schema:token-theme
+ [:and [:map {:title "TokenTheme"}
+ [:name :string]
+ [:group :string]
+ [:description [:maybe :string]]
+ [:is-source :boolean]
+ [:modified-at ::sm/inst]
+ [:sets [:set {:gen/max 5} :string]]]
+ [:fn (partial instance? TokenTheme)]])
+
+(sm/register! ::token-theme schema:token-theme)
+
+(def valid-token-theme?
+ (sm/validator schema:token-theme))
+
+(def check-token-theme!
+ (sm/check-fn ::token-theme))
+
+(def top-level-theme-group-name
+ "Top level theme groups have an empty string as the theme group."
+ "")
+
+(defn top-level-theme-group? [group]
+ (= group top-level-theme-group-name))
+
+(defn make-token-theme
+ [& {:keys [] :as params}]
+ (let [params (-> params
+ (dissoc :id)
+ (update :group #(or % top-level-theme-group-name))
+ (update :is-source #(or % false))
+ (update :modified-at #(or % (dt/now)))
+ (update :sets #(into #{} %)))
+ token-theme (map->TokenTheme params)]
+
+ (dm/assert!
+ "expected valid token theme"
+ (check-token-theme! token-theme))
+
+ token-theme))
+
+(defn make-hidden-token-theme
+ [& {:keys [] :as params}]
+ (make-token-theme (assoc params
+ :group hidden-token-theme-group
+ :name hidden-token-theme-name)))
+
+;; === TokenThemes (collection)
+
+(defprotocol ITokenThemes
+ (add-theme [_ token-theme] "add a theme to the library, at the end")
+ (update-theme [_ group name f] "modify a theme in the ilbrary")
+ (delete-theme [_ group name] "delete a theme in the library")
+ (theme-count [_] "get the total number if themes in the library")
+ (get-theme-tree [_] "get a nested tree of all themes in the library")
+ (get-themes [_] "get an ordered sequence of all themes in the library")
+ (get-theme [_ group name] "get one theme looking for name")
+ (get-theme-groups [_] "get a sequence of group names by order")
+ (get-active-theme-paths [_] "get the active theme paths")
+ (get-active-themes [_] "get an ordered sequence of active themes in the library")
+ (set-active-themes [_ active-themes] "set active themes in library")
+ (theme-active? [_ group name] "predicate if token theme is active")
+ (activate-theme [_ group name] "adds theme from the active-themes")
+ (deactivate-theme [_ group name] "removes theme from the active-themes")
+ (toggle-theme-active? [_ group name] "toggles theme in the active-themes"))
+
+(def schema:token-themes
+ [:and
+ [:map-of {:title "TokenThemes"}
+ :string [:and [:map-of :string ::token-theme]
+ [:fn d/ordered-map?]]]
+ [:fn d/ordered-map?]])
+
+(sm/register! ::token-themes schema:token-themes)
+
+(def valid-token-themes?
+ (sm/validator schema:token-themes))
+
+(def check-token-themes!
+ (sm/check-fn ::token-themes))
+
+(def schema:active-token-themes
+ [:set string?])
+
+(def valid-active-token-themes?
+ (sm/validator schema:active-token-themes))
+
+;; === Import / Export from DTCG format
+
+(defn flatten-nested-tokens-json
+ "Recursively flatten the dtcg token structure, joining keys with '.'."
+ [tokens token-path]
+ (reduce-kv
+ (fn [acc k v]
+ (let [child-path (if (empty? token-path)
+ (name k)
+ (str token-path "." k))]
+ (if (and (map? v)
+ (not (contains? v "$type")))
+ (merge acc (flatten-nested-tokens-json v child-path))
+ (let [token-type (cto/dtcg-token-type->token-type (get v "$type"))]
+ (if token-type
+ (assoc acc child-path (make-token
+ :name child-path
+ :type token-type
+ :value (get v "$value")
+ :description (get v "$description")))
+ ;; Discard unknown tokens
+ acc)))))
+ {}
+ tokens))
+
+;; === Tokens Lib
+
+(defprotocol ITokensLib
+ "A library of tokens, sets and themes."
+ (add-token-in-set [_ set-name token] "add token to a set")
+ (update-token-in-set [_ set-name token-name f] "update a token in a set")
+ (delete-token-from-set [_ set-name token-name] "delete a token from a set")
+ (toggle-set-in-theme [_ group-name theme-name set-name] "toggle a set used / not used in a theme")
+ (get-active-themes-set-names [_] "set of set names that are active in the the active themes")
+ (get-active-themes-set-tokens [_] "set of set names that are active in the the active themes")
+ (update-set-name [_ old-set-name new-set-name] "updates set name in themes")
+ (encode-dtcg [_] "Encodes library to a dtcg compatible json string")
+ (decode-dtcg-json [_ parsed-json] "Decodes parsed json containing tokens and converts to library")
+ (get-all-tokens [_] "all tokens in the lib")
+ (validate [_]))
+
+(deftype TokensLib [sets set-groups themes active-themes]
+ ;; NOTE: This is only for debug purposes, pending to properly
+ ;; implement the toString and alternative printing.
+ #?@(:clj [clojure.lang.IDeref
+ (deref [_] {:sets sets
+ :set-groups set-groups
+ :themes themes
+ :active-themes active-themes})]
+ :cljs [cljs.core/IDeref
+ (-deref [_] {:sets sets
+ :set-groups set-groups
+ :themes themes
+ :active-themes active-themes})])
+
+ #?@(:cljs [cljs.core/IEncodeJS
+ (-clj->js [_] (js-obj "sets" (clj->js sets)
+ "set-groups" (clj->js set-groups)
+ "themes" (clj->js themes)
+ "active-themes" (clj->js active-themes)))])
+
+ ITokenSets
+ (add-set [_ token-set]
+ (dm/assert! "expected valid token set" (check-token-set! token-set))
+ (let [path (get-token-set-path token-set)
+ groups-str (get-token-set-group-str token-set)]
+ (TokensLib. (d/oassoc-in sets path token-set)
+ (cond-> set-groups
+ (not (str/empty? groups-str))
+ (assoc groups-str (make-token-set-group)))
+ themes
+ active-themes)))
+
+ (update-set [this set-name f]
+ (let [path (split-token-set-path set-name)
+ set (get-in sets path)]
+ (if set
+ (let [set' (-> (make-token-set (f set))
+ (assoc :modified-at (dt/now)))
+ path' (get-path set' "/")]
+ (check-token-set! set')
+ (TokensLib. (if (= (:name set) (:name set'))
+ (d/oassoc-in sets path set')
+ (-> sets
+ (d/oassoc-in-before path path' set')
+ (d/dissoc-in path)))
+ set-groups ;; TODO update set-groups as needed
+ themes
+ active-themes))
+ this)))
+
+ (delete-set [_ set-name]
+ (let [path (split-token-set-path set-name)]
+ (TokensLib. (d/dissoc-in sets path)
+ set-groups ;; TODO remove set-group if needed
+ (walk/postwalk
+ (fn [form]
+ (if (instance? TokenTheme form)
+ (disable-set form set-name)
+ form))
+ themes)
+ active-themes)))
+
+ ;; TODO Handle groups and nesting
+ (move-set-before [this set-name before-set-name]
+ (let [source-path (split-token-set-path set-name)
+ token-set (-> (get-set this set-name)
+ (assoc :modified-at (dt/now)))
+ target-path (split-token-set-path before-set-name)]
+ (if before-set-name
+ (TokensLib. (d/oassoc-in-before sets target-path source-path token-set)
+ set-groups ;; TODO remove set-group if needed
+ themes
+ active-themes)
+ (TokensLib. (-> sets
+ (d/dissoc-in source-path)
+ (d/oassoc-in source-path token-set))
+ set-groups ;; TODO remove set-group if needed
+ themes
+ active-themes))))
+
+ (get-set-tree [_]
+ sets)
+
+ (get-sets [_]
+ (->> (tree-seq d/ordered-map? vals sets)
+ (filter (partial instance? TokenSet))))
+
+ (get-ordered-set-names [this]
+ (map :name (get-sets this)))
+
+ (set-count [this]
+ (count (get-sets this)))
+
+ (get-set [_ set-name]
+ (let [path (split-path set-name "/")]
+ (get-in sets path)))
+
+ (get-neighbor-set-name [this set-name index-offset]
+ (let [sets (get-ordered-set-names this)
+ index (d/index-of sets set-name)
+ neighbor-set-name (when index
+ (nth sets (+ index-offset index) nil))]
+ neighbor-set-name))
+
+ (get-set-group [_ set-group-path]
+ (get set-groups set-group-path))
+
+ ITokenThemes
+ (add-theme [_ token-theme]
+ (dm/assert! "expected valid token theme" (check-token-theme! token-theme))
+ (TokensLib. sets
+ set-groups
+ (update themes (:group token-theme) d/oassoc (:name token-theme) token-theme)
+ active-themes))
+
+ (update-theme [this group name f]
+ (let [theme (dm/get-in themes [group name])]
+ (if theme
+ (let [theme' (-> (make-token-theme (f theme))
+ (assoc :modified-at (dt/now)))
+ group' (:group theme')
+ name' (:name theme')
+ same-group? (= group group')
+ same-name? (= name name')
+ same-path? (and same-group? same-name?)]
+ (check-token-theme! theme')
+ (TokensLib. sets
+ set-groups
+ (if same-path?
+ (update themes group' assoc name' theme')
+ (-> themes
+ (d/oassoc-in-before [group name] [group' name'] theme')
+ (d/dissoc-in [group name])))
+ (if same-path?
+ active-themes
+ (disj active-themes (token-theme-path group name)))))
+ this)))
+
+ (delete-theme [_ group name]
+ (TokensLib. sets
+ set-groups
+ (d/dissoc-in themes [group name])
+ (disj active-themes (token-theme-path group name))))
+
+ (get-theme-tree [_]
+ themes)
+
+ (get-theme-groups [_]
+ (into [] (comp
+ (map key)
+ (remove top-level-theme-group?))
+ themes))
+
+ (get-themes [_]
+ (->> (tree-seq d/ordered-map? vals themes)
+ (filter (partial instance? TokenTheme))))
+
+ (theme-count [this]
+ (count (get-themes this)))
+
+ (get-theme [_ group name]
+ (dm/get-in themes [group name]))
+
+ (set-active-themes [_ active-themes]
+ (TokensLib. sets
+ set-groups
+ themes
+ active-themes))
+
+ (activate-theme [this group name]
+ (if-let [theme (get-theme this group name)]
+ (let [group-themes (->> (get themes group)
+ (map (comp theme-path val))
+ (into #{}))
+ active-themes' (-> (set/difference active-themes group-themes)
+ (conj (theme-path theme)))]
+ (TokensLib. sets
+ set-groups
+ themes
+ active-themes'))
+ this))
+
+ (deactivate-theme [_ group name]
+ (TokensLib. sets
+ set-groups
+ themes
+ (disj active-themes (token-theme-path group name))))
+
+ (theme-active? [_ group name]
+ (contains? active-themes (token-theme-path group name)))
+
+ (toggle-theme-active? [this group name]
+ (if (theme-active? this group name)
+ (deactivate-theme this group name)
+ (activate-theme this group name)))
+
+ (get-active-theme-paths [_]
+ active-themes)
+
+ (get-active-themes [this]
+ (into
+ (list)
+ (comp
+ (filter (partial instance? TokenTheme))
+ (filter #(theme-active? this (:group %) (:name %))))
+ (tree-seq d/ordered-map? vals themes)))
+
+ ITokensLib
+ (add-token-in-set [this set-name token]
+ (dm/assert! "expected valid token instance" (check-token! token))
+ (if (contains? sets set-name)
+ (TokensLib. (update sets set-name add-token token)
+ set-groups
+ themes
+ active-themes)
+ this))
+
+ (update-token-in-set [this set-name token-name f]
+ (if (contains? sets set-name)
+ (TokensLib. (update sets set-name
+ #(update-token % token-name f))
+ set-groups
+ themes
+ active-themes)
+ this))
+
+ (delete-token-from-set [this set-name token-name]
+ (if (contains? sets set-name)
+ (TokensLib. (update sets set-name
+ #(delete-token % token-name))
+ set-groups
+ themes
+ active-themes)
+ this))
+
+ (toggle-set-in-theme [this theme-group theme-name set-name]
+ (if-let [_theme (get-in themes theme-group theme-name)]
+ (TokensLib. sets
+ set-groups
+ (d/oupdate-in themes [theme-group theme-name]
+ #(toggle-set % set-name))
+ active-themes)
+ this))
+
+ (get-active-themes-set-names [this]
+ (into #{}
+ (mapcat :sets)
+ (get-active-themes this)))
+
+ (get-active-themes-set-tokens [this]
+ (let [sets-order (get-ordered-set-names this)
+ active-themes (get-active-themes this)
+ order-theme-set (fn [theme]
+ (filter #(contains? (set (:sets theme)) %) sets-order))]
+ (reduce
+ (fn [tokens theme]
+ (reduce
+ (fn [tokens' cur]
+ (merge tokens' (:tokens (get-set this cur))))
+ tokens (order-theme-set theme)))
+ (d/ordered-map) active-themes)))
+
+ ;; TODO Move to `update-set`
+ (update-set-name [_ old-set-name new-set-name]
+ (TokensLib. sets
+ set-groups
+ (walk/postwalk
+ (fn [form]
+ (if (instance? TokenTheme form)
+ (-> form
+ (update :sets disj old-set-name)
+ (update :sets conj new-set-name))
+ form))
+ themes)
+ active-themes))
+
+ (encode-dtcg [_]
+ (into {} (map (fn [[k v]]
+ [k (get-dtcg-tokens-tree v)])
+ sets)))
+
+ (decode-dtcg-json [_ parsed-json]
+ (let [token-sets (into (d/ordered-map)
+ (map (fn [[set-name tokens]]
+ [set-name (make-token-set
+ :name set-name
+ :tokens (flatten-nested-tokens-json tokens ""))]))
+ (-> parsed-json
+ ;; tokens-studio/plugin will add these meta properties, remove them for now
+ (dissoc "$themes" "$metadata")))]
+ (TokensLib. token-sets
+ set-groups
+ themes
+ active-themes)))
+
+ (get-all-tokens [this]
+ (reduce
+ (fn [tokens' set]
+ (into tokens' (map (fn [x] [(:name x) x]) (get-tokens set))))
+ {} (get-sets this)))
+
+ (validate [_]
+ (and (valid-token-sets? sets) ;; TODO: validate set-groups
+ (valid-token-themes? themes)
+ (valid-active-token-themes? active-themes))))
+
+(defn valid-tokens-lib?
+ [o]
+ (and (instance? TokensLib o)
+ (validate o)))
+
+(defn check-tokens-lib!
+ [lib]
+ (dm/assert!
+ "expected valid tokens lib"
+ (valid-tokens-lib? lib)))
+
+(defn make-tokens-lib
+ "Create an empty or prepopulated tokens library."
+ ([]
+ ;; NOTE: is possible that ordered map is not the most apropriate
+ ;; data structure and maybe we need a specific that allows us an
+ ;; easy way to reorder it, or just store inside Tokens data
+ ;; structure the data and the order separately as we already do
+ ;; with pages and pages-index.
+ (make-tokens-lib :sets (d/ordered-map)
+ :set-groups {}
+ :themes (d/ordered-map)
+ :active-themes #{}))
+
+ ([& {:keys [sets set-groups themes active-themes]}]
+ (let [tokens-lib (TokensLib. sets set-groups themes (or active-themes #{}))]
+
+ (dm/assert!
+ "expected valid tokens lib"
+ (valid-tokens-lib? tokens-lib))
+
+ tokens-lib)))
+
+(defn ensure-tokens-lib
+ [tokens-lib]
+ (or tokens-lib (make-tokens-lib)))
+
+(def type:tokens-lib
+ {:type ::tokens-lib
+ :pred valid-tokens-lib?})
+
+(sm/register! ::tokens-lib type:tokens-lib)
+
+;; === Serialization handlers for RPC API (transit) and database (fressian)
+
+(t/add-handlers!
+ {:id "penpot/tokens-lib"
+ :class TokensLib
+ :wfn deref
+ :rfn #(make-tokens-lib %)}
+
+ {:id "penpot/token-set"
+ :class TokenSet
+ :wfn #(into {} %)
+ :rfn #(make-token-set %)}
+
+ {:id "penpot/token-theme"
+ :class TokenTheme
+ :wfn #(into {} %)
+ :rfn #(make-token-theme %)}
+
+ {:id "penpot/token"
+ :class Token
+ :wfn #(into {} %)
+ :rfn #(make-token %)})
+
+#?(:clj
+ (fres/add-handlers!
+ {:name "penpot/token/v1"
+ :class Token
+ :wfn (fn [n w o]
+ (fres/write-tag! w n 1)
+ (fres/write-object! w (into {} o)))
+ :rfn (fn [r]
+ (let [obj (fres/read-object! r)]
+ (map->Token obj)))}
+
+ {:name "penpot/token-set/v1"
+ :class TokenSet
+ :wfn (fn [n w o]
+ (fres/write-tag! w n 1)
+ (fres/write-object! w (into {} o)))
+ :rfn (fn [r]
+ (let [obj (fres/read-object! r)]
+ (map->TokenSet obj)))}
+
+ {:name "penpot/token-theme/v1"
+ :class TokenTheme
+ :wfn (fn [n w o]
+ (fres/write-tag! w n 1)
+ (fres/write-object! w (into {} o)))
+ :rfn (fn [r]
+ (let [obj (fres/read-object! r)]
+ (map->TokenTheme obj)))}
+
+ {:name "penpot/tokens-lib/v1"
+ :class TokensLib
+ :wfn (fn [n w o]
+ (fres/write-tag! w n 3)
+ (fres/write-object! w (.-sets o))
+ (fres/write-object! w (.-set-groups o))
+ (fres/write-object! w (.-themes o))
+ (fres/write-object! w (.-active-themes o)))
+ :rfn (fn [r]
+ (let [sets (fres/read-object! r)
+ set-groups (fres/read-object! r)
+ themes (fres/read-object! r)
+ active-themes (fres/read-object! r)]
+ (->TokensLib sets set-groups themes active-themes)))}))
diff --git a/common/src/app/common/types/tokens_list.cljc b/common/src/app/common/types/tokens_list.cljc
new file mode 100644
index 000000000..b31262d4d
--- /dev/null
+++ b/common/src/app/common/types/tokens_list.cljc
@@ -0,0 +1,49 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS INC
+
+(ns app.common.types.tokens-list
+ (:require
+ [app.common.data :as d]
+ [app.common.time :as dt]))
+
+(defn tokens-seq
+ "Returns a sequence of all tokens within the file data."
+ [file-data]
+ (vals (:tokens file-data)))
+
+(defn- touch
+ "Updates the `modified-at` timestamp of a token."
+ [token]
+ (assoc token :modified-at (dt/now)))
+
+(defn add-token
+ "Adds a new token to the file data, setting its `modified-at` timestamp."
+ [file-data token-set-id token]
+ (-> file-data
+ (update :tokens assoc (:id token) (touch token))
+ (d/update-in-when [:token-sets-index token-set-id] #(->
+ (update % :tokens conj (:id token))
+ (touch)))))
+
+(defn get-token
+ "Retrieves a token by its ID from the file data."
+ [file-data token-id]
+ (get-in file-data [:tokens token-id]))
+
+(defn set-token
+ "Sets or updates a token in the file data, updating its `modified-at` timestamp."
+ [file-data token]
+ (d/assoc-in-when file-data [:tokens (:id token)] (touch token)))
+
+(defn update-token
+ "Applies a function to update a token in the file data, then touches it."
+ [file-data token-id f & args]
+ (d/update-in-when file-data [:tokens token-id] #(-> (apply f % args) (touch))))
+
+(defn delete-token
+ "Removes a token from the file data by its ID."
+ [file-data token-id]
+ (update file-data :tokens dissoc token-id))
diff --git a/common/src/app/common/types/tokens_theme_list.cljc b/common/src/app/common/types/tokens_theme_list.cljc
new file mode 100644
index 000000000..971c96946
--- /dev/null
+++ b/common/src/app/common/types/tokens_theme_list.cljc
@@ -0,0 +1,79 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS INC
+
+(ns app.common.types.tokens-theme-list
+ (:require
+ [app.common.data :as d]
+ [app.common.time :as dt]))
+
+(defn- touch
+ "Updates the `modified-at` timestamp of a token set."
+ [token-set]
+ (assoc token-set :modified-at (dt/now)))
+
+(defn assoc-active-token-themes
+ [file-data theme-ids]
+ (assoc file-data :token-active-themes theme-ids))
+
+(defn add-temporary-token-theme
+ [file-data {:keys [id name] :as token-theme}]
+ (-> file-data
+ (d/dissoc-in [:token-themes-index (:token-theme-temporary-id file-data)])
+ (assoc :token-theme-temporary-id id)
+ (assoc :token-theme-temporary-name name)
+ (update :token-themes-index assoc id token-theme)))
+
+(defn delete-temporary-token-theme
+ [file-data token-theme-id]
+ (cond-> file-data
+ (= (:token-theme-temporary-id file-data) token-theme-id) (dissoc :token-theme-temporary-id :token-theme-temporary-name)
+ :always (d/dissoc-in [:token-themes-index (:token-theme-temporary-id file-data)])))
+
+(defn add-token-theme
+ [file-data {:keys [index id] :as token-theme}]
+ (-> file-data
+ (update :token-themes
+ (fn [token-themes]
+ (let [exists? (some (partial = id) token-themes)]
+ (cond
+ exists? token-themes
+ (nil? index) (conj (or token-themes []) id)
+ :else (d/insert-at-index token-themes index [id])))))
+ (update :token-themes-index assoc id token-theme)))
+
+(defn update-token-theme
+ [file-data token-theme-id f & args]
+ (d/update-in-when file-data [:token-themes-index token-theme-id] #(-> (apply f % args) (touch))))
+
+(defn delete-token-theme
+ [file-data theme-id]
+ (-> file-data
+ (update :token-themes (fn [ids] (d/removev #(= % theme-id) ids)))
+ (update :token-themes-index dissoc theme-id)
+ (update :token-active-themes disj theme-id)))
+
+(defn add-token-set
+ [file-data {:keys [index id] :as token-set}]
+ (-> file-data
+ (update :token-set-groups
+ (fn [token-set-groups]
+ (let [exists? (some (partial = id) token-set-groups)]
+ (cond
+ exists? token-set-groups
+ (nil? index) (conj (or token-set-groups []) id)
+ :else (d/insert-at-index token-set-groups index [id])))))
+ (update :token-sets-index assoc id token-set)))
+
+(defn update-token-set
+ [file-data token-set-id f & args]
+ (d/update-in-when file-data [:token-sets-index token-set-id] #(-> (apply f % args) (touch))))
+
+(defn delete-token-set
+ [file-data token-set-id]
+ (-> file-data
+ (update :token-set-groups (fn [xs] (into [] (remove #(= (:id %) token-set-id) xs))))
+ (update :token-sets-index dissoc token-set-id)
+ (update :token-themes-index (fn [xs] (update-vals xs #(update % :sets disj token-set-id))))))
diff --git a/common/test/common_tests/types/data/tokens-multi-set-example.json b/common/test/common_tests/types/data/tokens-multi-set-example.json
new file mode 100644
index 000000000..ca836d961
--- /dev/null
+++ b/common/test/common_tests/types/data/tokens-multi-set-example.json
@@ -0,0 +1,803 @@
+{
+ "core": {
+ "dimension": {
+ "scale": {
+ "$value": "2",
+ "$type": "dimension"
+ },
+ "xs": {
+ "$value": "4",
+ "$type": "dimension"
+ },
+ "sm": {
+ "$value": "{dimension.xs} * {dimension.scale}",
+ "$type": "dimension"
+ },
+ "md": {
+ "$value": "{dimension.sm} * {dimension.scale}",
+ "$type": "dimension"
+ },
+ "lg": {
+ "$value": "{dimension.md} * {dimension.scale}",
+ "$type": "dimension"
+ },
+ "xl": {
+ "$value": "{dimension.lg} * {dimension.scale}",
+ "$type": "dimension"
+ }
+ },
+ "spacing": {
+ "xs": {
+ "$value": "{dimension.xs}",
+ "$type": "spacing"
+ },
+ "sm": {
+ "$value": "{dimension.sm}",
+ "$type": "spacing"
+ },
+ "md": {
+ "$value": "{dimension.md}",
+ "$type": "spacing"
+ },
+ "lg": {
+ "$value": "{dimension.lg}",
+ "$type": "spacing"
+ },
+ "xl": {
+ "$value": "{dimension.xl}",
+ "$type": "spacing"
+ },
+ "multi-value": {
+ "$value": "{dimension.sm} {dimension.xl}",
+ "$type": "spacing",
+ "$description": "You can have multiple values in a single spacing token"
+ }
+ },
+ "borderRadius": {
+ "sm": {
+ "$value": "4",
+ "$type": "borderRadius"
+ },
+ "lg": {
+ "$value": "8",
+ "$type": "borderRadius"
+ },
+ "xl": {
+ "$value": "16",
+ "$type": "borderRadius"
+ },
+ "multi-value": {
+ "$value": "{borderRadius.sm} {borderRadius.lg}",
+ "$type": "borderRadius",
+ "$description": "You can have multiple values in a single radius token. Read more on these: https://docs.tokens.studio/available-tokens/border-radius-tokens#single--multiple-values"
+ }
+ },
+ "colors": {
+ "black": {
+ "$value": "#000000",
+ "$type": "color"
+ },
+ "white": {
+ "$value": "#ffffff",
+ "$type": "color"
+ },
+ "gray": {
+ "100": {
+ "$value": "#f7fafc",
+ "$type": "color"
+ },
+ "200": {
+ "$value": "#edf2f7",
+ "$type": "color"
+ },
+ "300": {
+ "$value": "#e2e8f0",
+ "$type": "color"
+ },
+ "400": {
+ "$value": "#cbd5e0",
+ "$type": "color"
+ },
+ "500": {
+ "$value": "#a0aec0",
+ "$type": "color"
+ },
+ "600": {
+ "$value": "#718096",
+ "$type": "color"
+ },
+ "700": {
+ "$value": "#4a5568",
+ "$type": "color"
+ },
+ "800": {
+ "$value": "#2d3748",
+ "$type": "color"
+ },
+ "900": {
+ "$value": "#1a202c",
+ "$type": "color"
+ }
+ },
+ "red": {
+ "100": {
+ "$value": "#fff5f5",
+ "$type": "color"
+ },
+ "200": {
+ "$value": "#fed7d7",
+ "$type": "color"
+ },
+ "300": {
+ "$value": "#feb2b2",
+ "$type": "color"
+ },
+ "400": {
+ "$value": "#fc8181",
+ "$type": "color"
+ },
+ "500": {
+ "$value": "#f56565",
+ "$type": "color"
+ },
+ "600": {
+ "$value": "#e53e3e",
+ "$type": "color"
+ },
+ "700": {
+ "$value": "#c53030",
+ "$type": "color"
+ },
+ "800": {
+ "$value": "#9b2c2c",
+ "$type": "color"
+ },
+ "900": {
+ "$value": "#742a2a",
+ "$type": "color"
+ }
+ },
+ "orange": {
+ "100": {
+ "$value": "#fffaf0",
+ "$type": "color"
+ },
+ "200": {
+ "$value": "#feebc8",
+ "$type": "color"
+ },
+ "300": {
+ "$value": "#fbd38d",
+ "$type": "color"
+ },
+ "400": {
+ "$value": "#f6ad55",
+ "$type": "color"
+ },
+ "500": {
+ "$value": "#ed8936",
+ "$type": "color"
+ },
+ "600": {
+ "$value": "#dd6b20",
+ "$type": "color"
+ },
+ "700": {
+ "$value": "#c05621",
+ "$type": "color"
+ },
+ "800": {
+ "$value": "#9c4221",
+ "$type": "color"
+ },
+ "900": {
+ "$value": "#7b341e",
+ "$type": "color"
+ }
+ },
+ "yellow": {
+ "100": {
+ "$value": "#fffff0",
+ "$type": "color"
+ },
+ "200": {
+ "$value": "#fefcbf",
+ "$type": "color"
+ },
+ "300": {
+ "$value": "#faf089",
+ "$type": "color"
+ },
+ "400": {
+ "$value": "#f6e05e",
+ "$type": "color"
+ },
+ "500": {
+ "$value": "#ecc94b",
+ "$type": "color"
+ },
+ "600": {
+ "$value": "#d69e2e",
+ "$type": "color"
+ },
+ "700": {
+ "$value": "#b7791f",
+ "$type": "color"
+ },
+ "800": {
+ "$value": "#975a16",
+ "$type": "color"
+ },
+ "900": {
+ "$value": "#744210",
+ "$type": "color"
+ }
+ },
+ "green": {
+ "100": {
+ "$value": "#f0fff4",
+ "$type": "color"
+ },
+ "200": {
+ "$value": "#c6f6d5",
+ "$type": "color"
+ },
+ "300": {
+ "$value": "#9ae6b4",
+ "$type": "color"
+ },
+ "400": {
+ "$value": "#68d391",
+ "$type": "color"
+ },
+ "500": {
+ "$value": "#48bb78",
+ "$type": "color"
+ },
+ "600": {
+ "$value": "#38a169",
+ "$type": "color"
+ },
+ "700": {
+ "$value": "#2f855a",
+ "$type": "color"
+ },
+ "800": {
+ "$value": "#276749",
+ "$type": "color"
+ },
+ "900": {
+ "$value": "#22543d",
+ "$type": "color"
+ }
+ },
+ "teal": {
+ "100": {
+ "$value": "#e6fffa",
+ "$type": "color"
+ },
+ "200": {
+ "$value": "#b2f5ea",
+ "$type": "color"
+ },
+ "300": {
+ "$value": "#81e6d9",
+ "$type": "color"
+ },
+ "400": {
+ "$value": "#4fd1c5",
+ "$type": "color"
+ },
+ "500": {
+ "$value": "#38b2ac",
+ "$type": "color"
+ },
+ "600": {
+ "$value": "#319795",
+ "$type": "color"
+ },
+ "700": {
+ "$value": "#2c7a7b",
+ "$type": "color"
+ },
+ "800": {
+ "$value": "#285e61",
+ "$type": "color"
+ },
+ "900": {
+ "$value": "#234e52",
+ "$type": "color"
+ }
+ },
+ "blue": {
+ "100": {
+ "$value": "#ebf8ff",
+ "$type": "color"
+ },
+ "200": {
+ "$value": "#bee3f8",
+ "$type": "color"
+ },
+ "300": {
+ "$value": "#90cdf4",
+ "$type": "color"
+ },
+ "400": {
+ "$value": "#63b3ed",
+ "$type": "color"
+ },
+ "500": {
+ "$value": "#4299e1",
+ "$type": "color"
+ },
+ "600": {
+ "$value": "#3182ce",
+ "$type": "color"
+ },
+ "700": {
+ "$value": "#2b6cb0",
+ "$type": "color"
+ },
+ "800": {
+ "$value": "#2c5282",
+ "$type": "color"
+ },
+ "900": {
+ "$value": "#2a4365",
+ "$type": "color"
+ }
+ },
+ "indigo": {
+ "100": {
+ "$value": "#ebf4ff",
+ "$type": "color"
+ },
+ "200": {
+ "$value": "#c3dafe",
+ "$type": "color"
+ },
+ "300": {
+ "$value": "#a3bffa",
+ "$type": "color"
+ },
+ "400": {
+ "$value": "#7f9cf5",
+ "$type": "color"
+ },
+ "500": {
+ "$value": "#667eea",
+ "$type": "color"
+ },
+ "600": {
+ "$value": "#5a67d8",
+ "$type": "color"
+ },
+ "700": {
+ "$value": "#4c51bf",
+ "$type": "color"
+ },
+ "800": {
+ "$value": "#434190",
+ "$type": "color"
+ },
+ "900": {
+ "$value": "#3c366b",
+ "$type": "color"
+ }
+ },
+ "purple": {
+ "100": {
+ "$value": "#faf5ff",
+ "$type": "color"
+ },
+ "200": {
+ "$value": "#e9d8fd",
+ "$type": "color"
+ },
+ "300": {
+ "$value": "#d6bcfa",
+ "$type": "color"
+ },
+ "400": {
+ "$value": "#b794f4",
+ "$type": "color"
+ },
+ "500": {
+ "$value": "#9f7aea",
+ "$type": "color"
+ },
+ "600": {
+ "$value": "#805ad5",
+ "$type": "color"
+ },
+ "700": {
+ "$value": "#6b46c1",
+ "$type": "color"
+ },
+ "800": {
+ "$value": "#553c9a",
+ "$type": "color"
+ },
+ "900": {
+ "$value": "#44337a",
+ "$type": "color"
+ }
+ },
+ "pink": {
+ "100": {
+ "$value": "#fff5f7",
+ "$type": "color"
+ },
+ "200": {
+ "$value": "#fed7e2",
+ "$type": "color"
+ },
+ "300": {
+ "$value": "#fbb6ce",
+ "$type": "color"
+ },
+ "400": {
+ "$value": "#f687b3",
+ "$type": "color"
+ },
+ "500": {
+ "$value": "#ed64a6",
+ "$type": "color"
+ },
+ "600": {
+ "$value": "#d53f8c",
+ "$type": "color"
+ },
+ "700": {
+ "$value": "#b83280",
+ "$type": "color"
+ },
+ "800": {
+ "$value": "#97266d",
+ "$type": "color"
+ },
+ "900": {
+ "$value": "#702459",
+ "$type": "color"
+ }
+ }
+ },
+ "opacity": {
+ "low": {
+ "$value": "10%",
+ "$type": "opacity"
+ },
+ "md": {
+ "$value": "50%",
+ "$type": "opacity"
+ },
+ "high": {
+ "$value": "90%",
+ "$type": "opacity"
+ }
+ },
+ "fontFamilies": {
+ "heading": {
+ "$value": "Inter",
+ "$type": "fontFamilies"
+ },
+ "body": {
+ "$value": "Roboto",
+ "$type": "fontFamilies"
+ }
+ },
+ "lineHeights": {
+ "heading": {
+ "$value": "110%",
+ "$type": "lineHeights"
+ },
+ "body": {
+ "$value": "140%",
+ "$type": "lineHeights"
+ }
+ },
+ "letterSpacing": {
+ "default": {
+ "$value": "0",
+ "$type": "letterSpacing"
+ },
+ "increased": {
+ "$value": "150%",
+ "$type": "letterSpacing"
+ },
+ "decreased": {
+ "$value": "-5%",
+ "$type": "letterSpacing"
+ }
+ },
+ "paragraphSpacing": {
+ "h1": {
+ "$value": "32",
+ "$type": "paragraphSpacing"
+ },
+ "h2": {
+ "$value": "26",
+ "$type": "paragraphSpacing"
+ }
+ },
+ "fontWeights": {
+ "headingRegular": {
+ "$value": "Regular",
+ "$type": "fontWeights"
+ },
+ "headingBold": {
+ "$value": "Bold",
+ "$type": "fontWeights"
+ },
+ "bodyRegular": {
+ "$value": "Regular",
+ "$type": "fontWeights"
+ },
+ "bodyBold": {
+ "$value": "Bold",
+ "$type": "fontWeights"
+ }
+ },
+ "fontSizes": {
+ "h1": {
+ "$value": "{fontSizes.h2} * 1.25",
+ "$type": "fontSizes"
+ },
+ "h2": {
+ "$value": "{fontSizes.h3} * 1.25",
+ "$type": "fontSizes"
+ },
+ "h3": {
+ "$value": "{fontSizes.h4} * 1.25",
+ "$type": "fontSizes"
+ },
+ "h4": {
+ "$value": "{fontSizes.h5} * 1.25",
+ "$type": "fontSizes"
+ },
+ "h5": {
+ "$value": "{fontSizes.h6} * 1.25",
+ "$type": "fontSizes"
+ },
+ "h6": {
+ "$value": "{fontSizes.body} * 1",
+ "$type": "fontSizes"
+ },
+ "body": {
+ "$value": "16",
+ "$type": "fontSizes"
+ },
+ "sm": {
+ "$value": "{fontSizes.body} * 0.85",
+ "$type": "fontSizes"
+ },
+ "xs": {
+ "$value": "{fontSizes.body} * 0.65",
+ "$type": "fontSizes"
+ }
+ }
+ },
+ "light": {
+ "fg": {
+ "default": {
+ "$value": "{colors.black}",
+ "$type": "color"
+ },
+ "muted": {
+ "$value": "{colors.gray.700}",
+ "$type": "color"
+ },
+ "subtle": {
+ "$value": "{colors.gray.500}",
+ "$type": "color"
+ }
+ },
+ "bg": {
+ "default": {
+ "$value": "{colors.white}",
+ "$type": "color"
+ },
+ "muted": {
+ "$value": "{colors.gray.100}",
+ "$type": "color"
+ },
+ "subtle": {
+ "$value": "{colors.gray.200}",
+ "$type": "color"
+ }
+ },
+ "accent": {
+ "default": {
+ "$value": "{colors.indigo.400}",
+ "$type": "color"
+ },
+ "onAccent": {
+ "$value": "{colors.white}",
+ "$type": "color"
+ },
+ "bg": {
+ "$value": "{colors.indigo.200}",
+ "$type": "color"
+ }
+ },
+ "shadows": {
+ "default": {
+ "$value": "{colors.gray.900}",
+ "$type": "color"
+ }
+ }
+ },
+ "dark": {
+ "fg": {
+ "default": {
+ "$value": "{colors.white}",
+ "$type": "color"
+ },
+ "muted": {
+ "$value": "{colors.gray.300}",
+ "$type": "color"
+ },
+ "subtle": {
+ "$value": "{colors.gray.500}",
+ "$type": "color"
+ }
+ },
+ "bg": {
+ "default": {
+ "$value": "{colors.gray.900}",
+ "$type": "color"
+ },
+ "muted": {
+ "$value": "{colors.gray.700}",
+ "$type": "color"
+ },
+ "subtle": {
+ "$value": "{colors.gray.600}",
+ "$type": "color"
+ }
+ },
+ "accent": {
+ "default": {
+ "$value": "{colors.indigo.600}",
+ "$type": "color"
+ },
+ "onAccent": {
+ "$value": "{colors.white}",
+ "$type": "color"
+ },
+ "bg": {
+ "$value": "{colors.indigo.800}",
+ "$type": "color"
+ }
+ },
+ "shadows": {
+ "default": {
+ "$value": "rgba({colors.black}, 0.3)",
+ "$type": "color"
+ }
+ }
+ },
+ "theme": {
+ "button": {
+ "primary": {
+ "background": {
+ "$value": "{accent.default}",
+ "$type": "color"
+ },
+ "text": {
+ "$value": "{accent.onAccent}",
+ "$type": "color"
+ }
+ },
+ "borderRadius": {
+ "$value": "{borderRadius.lg}",
+ "$type": "borderRadius"
+ },
+ "borderWidth": {
+ "$value": "{dimension.sm}",
+ "$type": "borderWidth"
+ }
+ },
+ "card": {
+ "borderRadius": {
+ "$value": "{borderRadius.lg}",
+ "$type": "borderRadius"
+ },
+ "background": {
+ "$value": "{bg.default}",
+ "$type": "color"
+ },
+ "padding": {
+ "$value": "{dimension.md}",
+ "$type": "dimension"
+ }
+ },
+ "boxShadow": {
+ "default": {
+ "$value": [
+ {
+ "x": 5,
+ "y": 5,
+ "spread": 3,
+ "color": "rgba({shadows.default}, 0.15)",
+ "blur": 5,
+ "$type": "dropShadow"
+ },
+ {
+ "x": 4,
+ "y": 4,
+ "spread": 6,
+ "color": "#00000033",
+ "blur": 5,
+ "$type": "innerShadow"
+ }
+ ],
+ "$type": "boxShadow"
+ }
+ },
+ "typography": {
+ "H1": {
+ "Bold": {
+ "$value": {
+ "fontFamily": "{fontFamilies.heading}",
+ "fontWeight": "{fontWeights.headingBold}",
+ "lineHeight": "{lineHeights.heading}",
+ "fontSize": "{fontSizes.h1}",
+ "paragraphSpacing": "{paragraphSpacing.h1}",
+ "letterSpacing": "{letterSpacing.decreased}"
+ },
+ "$type": "typography"
+ },
+ "Regular": {
+ "$value": {
+ "fontFamily": "{fontFamilies.heading}",
+ "fontWeight": "{fontWeights.headingRegular}",
+ "lineHeight": "{lineHeights.heading}",
+ "fontSize": "{fontSizes.h1}",
+ "paragraphSpacing": "{paragraphSpacing.h1}",
+ "letterSpacing": "{letterSpacing.decreased}"
+ },
+ "$type": "typography"
+ }
+ },
+ "H2": {
+ "Bold": {
+ "$value": {
+ "fontFamily": "{fontFamilies.heading}",
+ "fontWeight": "{fontWeights.headingBold}",
+ "lineHeight": "{lineHeights.heading}",
+ "fontSize": "{fontSizes.h2}",
+ "paragraphSpacing": "{paragraphSpacing.h2}",
+ "letterSpacing": "{letterSpacing.decreased}"
+ },
+ "$type": "typography"
+ },
+ "Regular": {
+ "$value": {
+ "fontFamily": "{fontFamilies.heading}",
+ "fontWeight": "{fontWeights.headingRegular}",
+ "lineHeight": "{lineHeights.heading}",
+ "fontSize": "{fontSizes.h2}",
+ "paragraphSpacing": "{paragraphSpacing.h2}",
+ "letterSpacing": "{letterSpacing.decreased}"
+ },
+ "$type": "typography"
+ }
+ },
+ "Body": {
+ "$value": {
+ "fontFamily": "{fontFamilies.body}",
+ "fontWeight": "{fontWeights.bodyRegular}",
+ "lineHeight": "{lineHeights.heading}",
+ "fontSize": "{fontSizes.body}",
+ "paragraphSpacing": "{paragraphSpacing.h2}"
+ },
+ "$type": "typography"
+ }
+ }
+ },
+ "$themes": [],
+ "$metadata": {
+ "tokenSetOrder": ["core", "light", "dark", "theme"]
+ }
+}
diff --git a/common/test/common_tests/types/tokens_lib_test.cljc b/common/test/common_tests/types/tokens_lib_test.cljc
new file mode 100644
index 000000000..2c47b3b96
--- /dev/null
+++ b/common/test/common_tests/types/tokens_lib_test.cljc
@@ -0,0 +1,1142 @@
+;; 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 common-tests.types.tokens-lib-test
+ (:require
+ #?(:clj [app.common.fressian :as fres])
+ #?(:clj [clojure.data.json :as json])
+ [app.common.data :as d]
+ [app.common.time :as dt]
+ [app.common.transit :as tr]
+ [app.common.types.tokens-lib :as ctob]
+ [clojure.test :as t]))
+
+(t/testing "token"
+ (t/deftest make-token
+ (let [now (dt/now)
+ token1 (ctob/make-token :name "test-token-1"
+ :type :boolean
+ :value true)
+ token2 (ctob/make-token :name "test-token-2"
+ :type :numeric
+ :value 66
+ :description "test description"
+ :modified-at now)]
+
+ (t/is (= (:name token1) "test-token-1"))
+ (t/is (= (:type token1) :boolean))
+ (t/is (= (:value token1) true))
+ (t/is (nil? (:description token1)))
+ (t/is (some? (:modified-at token1)))
+ (t/is (ctob/valid-token? token1))
+
+ (t/is (= (:name token2) "test-token-2"))
+ (t/is (= (:type token2) :numeric))
+ (t/is (= (:value token2) 66))
+ (t/is (= (:description token2) "test description"))
+ (t/is (= (:modified-at token2) now))
+ (t/is (ctob/valid-token? token2))))
+
+ (t/deftest invalid-tokens
+ (let [args {:name 777
+ :type :invalid}]
+ (t/is (thrown-with-msg? Exception #"expected valid token"
+ (apply ctob/make-token args)))
+ (t/is (false? (ctob/valid-token? {})))))
+
+ (t/deftest find-token-value-references
+ (t/testing "finds references inside curly braces in a string"
+ (t/is (= #{"foo" "bar"} (ctob/find-token-value-references "{foo} + {bar}")))
+ (t/testing "ignores extra text"
+ (t/is (= #{"foo.bar.baz"} (ctob/find-token-value-references "{foo.bar.baz} + something")))))
+ (t/testing "ignores string without references"
+ (t/is (nil? (ctob/find-token-value-references "1 + 2"))))
+ (t/testing "handles edge-case for extra curly braces"
+ (t/is (= #{"foo" "bar"} (ctob/find-token-value-references "{foo}} + {bar}"))))))
+
+(t/testing "token-set"
+ (t/deftest make-token-set
+ (let [now (dt/now)
+ token-set1 (ctob/make-token-set :name "test-token-set-1")
+ token-set2 (ctob/make-token-set :name "test-token-set-2"
+ :description "test description"
+ :modified-at now
+ :tokens [])]
+
+ (t/is (= (:name token-set1) "test-token-set-1"))
+ (t/is (nil? (:description token-set1)))
+ (t/is (some? (:modified-at token-set1)))
+ (t/is (empty? (:tokens token-set1)))
+
+ (t/is (= (:name token-set2) "test-token-set-2"))
+ (t/is (= (:description token-set2) "test description"))
+ (t/is (= (:modified-at token-set2) now))
+ (t/is (empty? (:tokens token-set2)))))
+
+ (t/deftest invalid-token-set
+ (let [args {:name 777
+ :description 999}]
+ (t/is (thrown-with-msg? Exception #"expected valid token set"
+ (apply ctob/make-token-set args)))))
+
+ (t/deftest move-token-set
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :name "A"))
+ (ctob/add-set (ctob/make-token-set :name "B"))
+ (ctob/add-set (ctob/make-token-set :name "Move")))
+ original-order (into [] (ctob/get-ordered-set-names tokens-lib))
+ move (fn [set-name before-set-name]
+ (->> (ctob/move-set-before tokens-lib set-name before-set-name)
+ (ctob/get-ordered-set-names)
+ (into [])))]
+ ;; TODO Nested moving doesn't work as expected
+ (t/testing "regular moving"
+ (t/is (= ["A" "Move" "B"] (move "Move" "B")))
+ (t/is (= ["B" "A" "Move"] (move "A" "Move"))))
+
+ (t/testing "move to bottom"
+ (t/is (= ["B" "Move" "A"] (move "A" nil))))
+
+ (t/testing "no move expected"
+ (t/is (= original-order (move "Move" "Move"))))
+
+ (t/testing "ignore invalid moves"
+ (t/is (= original-order (move "A" "foo/bar/baz")))
+ (t/is (= original-order (move "Missing" "Move"))))))
+
+ (t/deftest tokens-tree
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :name "A"
+ :tokens {"foo.bar.baz" (ctob/make-token :name "foo.bar.baz"
+ :type :boolean
+ :value true)
+ "foo.bar.bam" (ctob/make-token :name "foo.bar.bam"
+ :type :boolean
+ :value true)
+ "baz.boo" (ctob/make-token :name "baz.boo"
+ :type :boolean
+ :value true)})))
+ expected (-> (ctob/get-set tokens-lib "A")
+ (ctob/get-tokens-tree))]
+ (t/is (= (get-in expected ["foo" "bar" "baz" :name]) "foo.bar.baz"))
+ (t/is (= (get-in expected ["foo" "bar" "bam" :name]) "foo.bar.bam"))
+ (t/is (= (get-in expected ["baz" "boo" :name]) "baz.boo")))))
+
+(t/testing "token-theme"
+ (t/deftest make-token-theme
+ (let [now (dt/now)
+ token-theme1 (ctob/make-token-theme :name "test-token-theme-1")
+ token-theme2 (ctob/make-token-theme :name "test-token-theme-2"
+ :group "group-1"
+ :description "test description"
+ :is-source true
+ :modified-at now
+ :sets #{})]
+
+ (t/is (= (:name token-theme1) "test-token-theme-1"))
+ (t/is (= (:group token-theme1) ""))
+ (t/is (nil? (:description token-theme1)))
+ (t/is (false? (:is-source token-theme1)))
+ (t/is (some? (:modified-at token-theme1)))
+ (t/is (empty? (:sets token-theme1)))
+
+ (t/is (= (:name token-theme2) "test-token-theme-2"))
+ (t/is (= (:group token-theme2) "group-1"))
+ (t/is (= (:description token-theme2) "test description"))
+ (t/is (true? (:is-source token-theme2)))
+ (t/is (= (:modified-at token-theme2) now))
+ (t/is (empty? (:sets token-theme2)))))
+
+ (t/deftest invalid-token-theme
+ (let [args {:name 777
+ :group nil
+ :description 999
+ :is-source 42}]
+ (t/is (thrown-with-msg? Exception #"expected valid token theme"
+ (apply ctob/make-token-theme args))))))
+
+
+(t/testing "tokens-lib"
+ (t/deftest make-tokens-lib
+ (let [tokens-lib (ctob/make-tokens-lib)]
+ (t/is (= (ctob/set-count tokens-lib) 0))))
+
+ (t/deftest invalid-tokens-lib
+ (let [args {:sets nil
+ :themes nil}]
+ (t/is (thrown-with-msg? Exception #"expected valid tokens lib"
+ (apply ctob/make-tokens-lib args))))))
+
+
+(t/testing "token-set in a lib"
+ (t/deftest add-token-set
+ (let [tokens-lib (ctob/make-tokens-lib)
+ token-set (ctob/make-token-set :name "test-token-set")
+ tokens-lib' (ctob/add-set tokens-lib token-set)
+
+ token-sets' (ctob/get-sets tokens-lib')
+ token-set' (ctob/get-set tokens-lib' "test-token-set")]
+
+ (t/is (= (ctob/set-count tokens-lib') 1))
+ (t/is (= (first token-sets') token-set))
+ (t/is (= token-set' token-set))))
+
+ (t/deftest add-token-set-with-group
+ (let [tokens-lib (ctob/make-tokens-lib)
+ token-set (ctob/make-token-set :name "test-group/test-token-set")
+ tokens-lib' (ctob/add-set tokens-lib token-set)
+
+ set-group (ctob/get-set-group tokens-lib' "test-group")]
+
+ (t/is (= (:attr1 set-group) "one"))
+ (t/is (= (:attr2 set-group) "two"))))
+
+ (t/deftest update-token-set
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :name "test-token-set")))
+
+ tokens-lib' (-> tokens-lib
+ (ctob/update-set "test-token-set"
+ (fn [token-set]
+ (assoc token-set
+ :description "some description")))
+ (ctob/update-set "not-existing-set"
+ (fn [token-set]
+ (assoc token-set
+ :description "no-effect"))))
+
+ token-set (ctob/get-set tokens-lib "test-token-set")
+ token-set' (ctob/get-set tokens-lib' "test-token-set")]
+
+ (t/is (= (ctob/set-count tokens-lib') 1))
+ (t/is (= (:name token-set') "test-token-set"))
+ (t/is (= (:description token-set') "some description"))
+ (t/is (dt/is-after? (:modified-at token-set') (:modified-at token-set)))))
+
+ (t/deftest rename-token-set
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :name "test-token-set")))
+
+ tokens-lib' (-> tokens-lib
+ (ctob/update-set "test-token-set"
+ (fn [token-set]
+ (assoc token-set
+ :name "updated-name"))))
+
+ token-set (ctob/get-set tokens-lib "test-token-set")
+ token-set' (ctob/get-set tokens-lib' "updated-name")]
+
+ (t/is (= (ctob/set-count tokens-lib') 1))
+ (t/is (= (:name token-set') "updated-name"))
+ (t/is (dt/is-after? (:modified-at token-set') (:modified-at token-set)))))
+
+ (t/deftest delete-token-set
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :name "test-token-set"))
+ (ctob/add-theme (ctob/make-token-theme :name "test-token-theme" :sets #{"test-token-set"})))
+
+ tokens-lib' (-> tokens-lib
+ (ctob/delete-set "test-token-set")
+ (ctob/delete-set "not-existing-set"))
+
+ token-set' (ctob/get-set tokens-lib' "updated-name")
+ token-theme' (ctob/get-theme tokens-lib' "" "test-token-theme")]
+
+ (t/is (= (ctob/set-count tokens-lib') 0))
+ (t/is (= (:sets token-theme') #{}))
+ (t/is (nil? token-set'))))
+
+ (t/deftest active-themes-set-names
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :name "test-token-set")))
+
+ tokens-lib' (-> tokens-lib
+ (ctob/delete-set "test-token-set")
+ (ctob/delete-set "not-existing-set"))
+
+ token-set' (ctob/get-set tokens-lib' "updated-name")]
+
+ (t/is (= (ctob/set-count tokens-lib') 0))
+ (t/is (nil? token-set')))))
+
+
+(t/testing "token in a lib"
+ (t/deftest add-token
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :name "test-token-set")))
+ token (ctob/make-token :name "test-token"
+ :type :boolean
+ :value true)
+ tokens-lib' (-> tokens-lib
+ (ctob/add-token-in-set "test-token-set" token)
+ (ctob/add-token-in-set "not-existing-set" token))
+
+ token-set (ctob/get-set tokens-lib "test-token-set")
+ token-set' (ctob/get-set tokens-lib' "test-token-set")
+ token' (get-in token-set' [:tokens "test-token"])]
+
+ (t/is (= (ctob/set-count tokens-lib') 1))
+ (t/is (= (count (:tokens token-set')) 1))
+ (t/is (= (:name token') "test-token"))
+ (t/is (dt/is-after? (:modified-at token-set') (:modified-at token-set)))))
+
+ (t/deftest update-token
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :name "test-token-set"))
+ (ctob/add-token-in-set "test-token-set"
+ (ctob/make-token :name "test-token-1"
+ :type :boolean
+ :value true))
+ (ctob/add-token-in-set "test-token-set"
+ (ctob/make-token :name "test-token-2"
+ :type :boolean
+ :value true)))
+
+ tokens-lib' (-> tokens-lib
+ (ctob/update-token-in-set "test-token-set" "test-token-1"
+ (fn [token]
+ (assoc token
+ :description "some description"
+ :value false)))
+ (ctob/update-token-in-set "not-existing-set" "test-token-1"
+ (fn [token]
+ (assoc token
+ :name "no-effect")))
+ (ctob/update-token-in-set "test-token-set" "not-existing-token"
+ (fn [token]
+ (assoc token
+ :name "no-effect"))))
+
+ token-set (ctob/get-set tokens-lib "test-token-set")
+ token-set' (ctob/get-set tokens-lib' "test-token-set")
+ token (get-in token-set [:tokens "test-token-1"])
+ token' (get-in token-set' [:tokens "test-token-1"])]
+
+ (t/is (= (ctob/set-count tokens-lib') 1))
+ (t/is (= (count (:tokens token-set')) 2))
+ (t/is (= (d/index-of (keys (:tokens token-set')) "test-token-1") 0))
+ (t/is (= (:name token') "test-token-1"))
+ (t/is (= (:description token') "some description"))
+ (t/is (= (:value token') false))
+ (t/is (dt/is-after? (:modified-at token-set') (:modified-at token-set)))
+ (t/is (dt/is-after? (:modified-at token') (:modified-at token)))))
+
+ (t/deftest rename-token
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :name "test-token-set"))
+ (ctob/add-token-in-set "test-token-set"
+ (ctob/make-token :name "test-token-1"
+ :type :boolean
+ :value true))
+ (ctob/add-token-in-set "test-token-set"
+ (ctob/make-token :name "test-token-2"
+ :type :boolean
+ :value true)))
+
+ tokens-lib' (-> tokens-lib
+ (ctob/update-token-in-set "test-token-set" "test-token-1"
+ (fn [token]
+ (assoc token
+ :name "updated-name"))))
+
+ token-set (ctob/get-set tokens-lib "test-token-set")
+ token-set' (ctob/get-set tokens-lib' "test-token-set")
+ token (get-in token-set [:tokens "test-token-1"])
+ token' (get-in token-set' [:tokens "updated-name"])]
+
+ (t/is (= (ctob/set-count tokens-lib') 1))
+ (t/is (= (count (:tokens token-set')) 2))
+ (t/is (= (d/index-of (keys (:tokens token-set')) "updated-name") 0))
+ (t/is (= (:name token') "updated-name"))
+ (t/is (= (:description token') nil))
+ (t/is (= (:value token') true))
+ (t/is (dt/is-after? (:modified-at token-set') (:modified-at token-set)))
+ (t/is (dt/is-after? (:modified-at token') (:modified-at token)))))
+
+ (t/deftest delete-token
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :name "test-token-set"))
+ (ctob/add-token-in-set "test-token-set"
+ (ctob/make-token :name "test-token"
+ :type :boolean
+ :value true)))
+ tokens-lib' (-> tokens-lib
+ (ctob/delete-token-from-set "test-token-set" "test-token")
+ (ctob/delete-token-from-set "not-existing-set" "test-token")
+ (ctob/delete-token-from-set "test-set" "not-existing-token"))
+
+ token-set (ctob/get-set tokens-lib "test-token-set")
+ token-set' (ctob/get-set tokens-lib' "test-token-set")
+ token' (get-in token-set' [:tokens "test-token"])]
+
+ (t/is (= (ctob/set-count tokens-lib') 1))
+ (t/is (= (count (:tokens token-set')) 0))
+ (t/is (nil? token'))
+ (t/is (dt/is-after? (:modified-at token-set') (:modified-at token-set)))))
+
+ (t/deftest list-active-themes-tokens-in-order
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-theme (ctob/make-token-theme :name "out-of-order-theme"
+ ;; Out of order sets in theme
+ :sets ["unknown-set" "set-b" "set-a"]))
+ (ctob/set-active-themes #{"/out-of-order-theme"})
+
+ (ctob/add-set (ctob/make-token-set :name "set-a"))
+ (ctob/add-token-in-set "set-a" (ctob/make-token :name "set-a-token"
+ :type :boolean
+ :value true))
+ (ctob/add-set (ctob/make-token-set :name "set-b"))
+ (ctob/add-token-in-set "set-b" (ctob/make-token :name "set-b-token"
+ :type :boolean
+ :value true))
+ ;; Ignore this set
+ (ctob/add-set (ctob/make-token-set :name "inactive-set"))
+ (ctob/add-token-in-set "inactive-set" (ctob/make-token :name "inactive-set-token"
+ :type :boolean
+ :value true)))
+
+
+ expected-order (ctob/get-ordered-set-names tokens-lib)
+ expected-tokens (ctob/get-active-themes-set-tokens tokens-lib)
+ expected-token-names (mapv key expected-tokens)]
+ (t/is (= '("set-a" "set-b" "inactive-set") expected-order))
+ (t/is (= ["set-a-token" "set-b-token"] expected-token-names)))))
+
+
+(t/testing "token-theme in a lib"
+ (t/deftest add-token-theme
+ (let [tokens-lib (ctob/make-tokens-lib)
+ token-theme (ctob/make-token-theme :name "test-token-theme")
+ tokens-lib' (ctob/add-theme tokens-lib token-theme)
+
+ token-themes' (ctob/get-themes tokens-lib')
+ token-theme' (ctob/get-theme tokens-lib' "" "test-token-theme")]
+
+ (t/is (= (ctob/theme-count tokens-lib') 1))
+ (t/is (= (first token-themes') token-theme))
+ (t/is (= token-theme' token-theme))))
+
+ (t/deftest update-token-theme
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-theme (ctob/make-token-theme :name "test-token-theme")))
+
+ tokens-lib' (-> tokens-lib
+ (ctob/update-theme "" "test-token-theme"
+ (fn [token-theme]
+ (assoc token-theme
+ :description "some description")))
+ (ctob/update-theme "" "not-existing-theme"
+ (fn [token-theme]
+ (assoc token-theme
+ :description "no-effect"))))
+
+ token-theme (ctob/get-theme tokens-lib "" "test-token-theme")
+ token-theme' (ctob/get-theme tokens-lib' "" "test-token-theme")]
+
+ (t/is (= (ctob/theme-count tokens-lib') 1))
+ (t/is (= (:name token-theme') "test-token-theme"))
+ (t/is (= (:description token-theme') "some description"))
+ (t/is (dt/is-after? (:modified-at token-theme') (:modified-at token-theme)))))
+
+ (t/deftest rename-token-theme
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-theme (ctob/make-token-theme :name "test-token-theme")))
+
+ tokens-lib' (-> tokens-lib
+ (ctob/update-theme "" "test-token-theme"
+ (fn [token-theme]
+ (assoc token-theme
+ :name "updated-name"))))
+
+ token-theme (ctob/get-theme tokens-lib "" "test-token-theme")
+ token-theme' (ctob/get-theme tokens-lib' "" "updated-name")]
+
+ (t/is (= (ctob/theme-count tokens-lib') 1))
+ (t/is (= (:name token-theme') "updated-name"))
+ (t/is (dt/is-after? (:modified-at token-theme') (:modified-at token-theme)))))
+
+ (t/deftest delete-token-theme
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-theme (ctob/make-token-theme :name "test-token-theme")))
+
+ tokens-lib' (-> tokens-lib
+ (ctob/delete-theme "" "test-token-theme")
+ (ctob/delete-theme "" "not-existing-theme"))
+
+ token-theme' (ctob/get-theme tokens-lib' "" "updated-name")]
+
+ (t/is (= (ctob/theme-count tokens-lib') 0))
+ (t/is (nil? token-theme'))))
+
+ (t/deftest toggle-set-in-theme
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :name "token-set-1"))
+ (ctob/add-set (ctob/make-token-set :name "token-set-2"))
+ (ctob/add-set (ctob/make-token-set :name "token-set-3"))
+ (ctob/add-theme (ctob/make-token-theme :name "test-token-theme")))
+ tokens-lib' (-> tokens-lib
+ (ctob/toggle-set-in-theme "" "test-token-theme" "token-set-1")
+ (ctob/toggle-set-in-theme "" "test-token-theme" "token-set-2")
+ (ctob/toggle-set-in-theme "" "test-token-theme" "token-set-2"))
+
+ token-theme (ctob/get-theme tokens-lib "" "test-token-theme")
+ token-theme' (ctob/get-theme tokens-lib' "" "test-token-theme")]
+
+ (t/is (dt/is-after? (:modified-at token-theme') (:modified-at token-theme))))))
+
+
+(t/testing "serialization"
+ (t/deftest transit-serialization
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :name "test-token-set"))
+ (ctob/add-token-in-set "test-token-set" (ctob/make-token :name "test-token"
+ :type :boolean
+ :value true))
+ (ctob/add-theme (ctob/make-token-theme :name "test-token-theme"))
+ (ctob/toggle-set-in-theme "" "test-token-theme" "test-token-set"))
+ encoded-str (tr/encode-str tokens-lib)
+ tokens-lib' (tr/decode-str encoded-str)]
+
+ (t/is (ctob/valid-tokens-lib? tokens-lib'))
+ (t/is (= (ctob/set-count tokens-lib') 1))
+ (t/is (= (ctob/theme-count tokens-lib') 1))))
+
+ (t/deftest fressian-serialization
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :name "test-token-set"))
+ (ctob/add-token-in-set "test-token-set" (ctob/make-token :name "test-token"
+ :type :boolean
+ :value true))
+ (ctob/add-theme (ctob/make-token-theme :name "test-token-theme"))
+ (ctob/toggle-set-in-theme "" "test-token-theme" "test-token-set"))
+ encoded-blob (fres/encode tokens-lib)
+ tokens-lib' (fres/decode encoded-blob)]
+
+ (t/is (ctob/valid-tokens-lib? tokens-lib'))
+ (t/is (= (ctob/set-count tokens-lib') 1))
+ (t/is (= (ctob/theme-count tokens-lib') 1)))))
+
+(t/testing "grouping"
+ (t/deftest split-and-join
+ (let [name "group/subgroup/name"
+ path (ctob/split-path name "/")
+ name' (ctob/join-path path "/")]
+ (t/is (= (first path) "group"))
+ (t/is (= (second path) "subgroup"))
+ (t/is (= (nth path 2) "name"))
+ (t/is (= name' name))))
+
+ (t/deftest remove-spaces
+ (let [name "group / subgroup / name"
+ path (ctob/split-path name "/")]
+ (t/is (= (first path) "group"))
+ (t/is (= (second path) "subgroup"))
+ (t/is (= (nth path 2) "name"))))
+
+ (t/deftest group-and-ungroup
+ (let [token-set1 (ctob/make-token-set :name "token-set1")
+ token-set2 (ctob/make-token-set :name "some group/token-set2")
+
+ token-set1' (ctob/group-item token-set1 "big group" "/")
+ token-set2' (ctob/group-item token-set2 "big group" "/")
+ token-set1'' (ctob/ungroup-item token-set1' "/")
+ token-set2'' (ctob/ungroup-item token-set2' "/")]
+ (t/is (= (:name token-set1') "big group/token-set1"))
+ (t/is (= (:name token-set2') "big group/some group/token-set2"))
+ (t/is (= (:name token-set1'') "token-set1"))
+ (t/is (= (:name token-set2'') "some group/token-set2"))))
+
+ (t/deftest get-groups-str
+ (let [token-set1 (ctob/make-token-set :name "token-set1")
+ token-set2 (ctob/make-token-set :name "some-group/token-set2")
+ token-set3 (ctob/make-token-set :name "some-group/some-subgroup/token-set3")]
+ (t/is (= (ctob/get-groups-str token-set1 "/") ""))
+ (t/is (= (ctob/get-groups-str token-set2 "/") "some-group"))
+ (t/is (= (ctob/get-groups-str token-set3 "/") "some-group/some-subgroup"))))
+
+ (t/deftest get-final-name
+ (let [token-set1 (ctob/make-token-set :name "token-set1")
+ token-set2 (ctob/make-token-set :name "some-group/token-set2")
+ token-set3 (ctob/make-token-set :name "some-group/some-subgroup/token-set3")]
+ (t/is (= (ctob/get-final-name token-set1 "/") "token-set1"))
+ (t/is (= (ctob/get-final-name token-set2 "/") "token-set2"))
+ (t/is (= (ctob/get-final-name token-set3 "/") "token-set3"))))
+
+ (t/testing "grouped tokens"
+ (t/deftest grouped-tokens
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :name "test-token-set"))
+ (ctob/add-token-in-set "test-token-set"
+ (ctob/make-token :name "token1"
+ :type :boolean
+ :value true))
+ (ctob/add-token-in-set "test-token-set"
+ (ctob/make-token :name "group1.token2"
+ :type :boolean
+ :value true))
+ (ctob/add-token-in-set "test-token-set"
+ (ctob/make-token :name "group1.token3"
+ :type :boolean
+ :value true))
+ (ctob/add-token-in-set "test-token-set"
+ (ctob/make-token :name "group1.subgroup11.token4"
+ :type :boolean
+ :value true))
+ (ctob/add-token-in-set "test-token-set"
+ (ctob/make-token :name "group2.token5"
+ :type :boolean
+ :value true)))
+
+ set (ctob/get-set tokens-lib "test-token-set")
+ tokens-list (vals (:tokens set))]
+
+ (t/is (= (count tokens-list) 5))
+ (t/is (= (:name (nth tokens-list 0)) "token1"))
+ (t/is (= (:name (nth tokens-list 1)) "group1.token2"))
+ (t/is (= (:name (nth tokens-list 2)) "group1.token3"))
+ (t/is (= (:name (nth tokens-list 3)) "group1.subgroup11.token4"))
+ (t/is (= (:name (nth tokens-list 4)) "group2.token5"))))
+
+ (t/deftest update-token-in-groups
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :name "test-token-set"))
+ (ctob/add-token-in-set "test-token-set"
+ (ctob/make-token :name "test-token-1"
+ :type :boolean
+ :value true))
+ (ctob/add-token-in-set "test-token-set"
+ (ctob/make-token :name "group1.test-token-2"
+ :type :boolean
+ :value true))
+ (ctob/add-token-in-set "test-token-set"
+ (ctob/make-token :name "group1.test-token-3"
+ :type :boolean
+ :value true)))
+
+ tokens-lib' (-> tokens-lib
+ (ctob/update-token-in-set "test-token-set" "group1.test-token-2"
+ (fn [token]
+ (assoc token
+ :description "some description"
+ :value false))))
+
+ token-set (ctob/get-set tokens-lib "test-token-set")
+ token-set' (ctob/get-set tokens-lib' "test-token-set")
+ token (get-in token-set [:tokens "group1.test-token-2"])
+ token' (get-in token-set' [:tokens "group1.test-token-2"])]
+
+ (t/is (= (ctob/set-count tokens-lib') 1))
+ (t/is (= (:name token') "group1.test-token-2"))
+ (t/is (= (:description token') "some description"))
+ (t/is (= (:value token') false))
+ (t/is (dt/is-after? (:modified-at token-set') (:modified-at token-set)))
+ (t/is (dt/is-after? (:modified-at token') (:modified-at token)))))
+
+ (t/deftest rename-token-in-groups
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :name "test-token-set"))
+ (ctob/add-token-in-set "test-token-set"
+ (ctob/make-token :name "test-token-1"
+ :type :boolean
+ :value true))
+ (ctob/add-token-in-set "test-token-set"
+ (ctob/make-token :name "group1.test-token-2"
+ :type :boolean
+ :value true))
+ (ctob/add-token-in-set "test-token-set"
+ (ctob/make-token :name "group1.test-token-3"
+ :type :boolean
+ :value true)))
+
+ tokens-lib' (-> tokens-lib
+ (ctob/update-token-in-set "test-token-set" "group1.test-token-2"
+ (fn [token]
+ (assoc token
+ :name "group1.updated-name"))))
+
+ token-set (ctob/get-set tokens-lib "test-token-set")
+ token-set' (ctob/get-set tokens-lib' "test-token-set")
+ token (get-in token-set [:tokens "group1.test-token-2"])
+ token' (get-in token-set' [:tokens "group1.updated-name"])]
+
+ (t/is (= (ctob/set-count tokens-lib') 1))
+ (t/is (= (:name token') "group1.updated-name"))
+ (t/is (= (:description token') nil))
+ (t/is (= (:value token') true))
+ (t/is (dt/is-after? (:modified-at token-set') (:modified-at token-set)))
+ (t/is (dt/is-after? (:modified-at token') (:modified-at token)))))
+
+ (t/deftest move-token-of-group
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :name "test-token-set"))
+ (ctob/add-token-in-set "test-token-set"
+ (ctob/make-token :name "test-token-1"
+ :type :boolean
+ :value true))
+ (ctob/add-token-in-set "test-token-set"
+ (ctob/make-token :name "group1.test-token-2"
+ :type :boolean
+ :value true))
+ (ctob/add-token-in-set "test-token-set"
+ (ctob/make-token :name "group1.test-token-3"
+ :type :boolean
+ :value true)))
+
+ tokens-lib' (-> tokens-lib
+ (ctob/update-token-in-set "test-token-set" "group1.test-token-2"
+ (fn [token]
+ (assoc token
+ :name "group2.updated-name"))))
+
+ token-set (ctob/get-set tokens-lib "test-token-set")
+ token-set' (ctob/get-set tokens-lib' "test-token-set")
+ token (get-in token-set [:tokens "group1.test-token-2"])
+ token' (get-in token-set' [:tokens "group2.updated-name"])]
+
+ (t/is (= (ctob/set-count tokens-lib') 1))
+ (t/is (= (d/index-of (keys (:tokens token-set')) "group2.updated-name") 1))
+ (t/is (= (:name token') "group2.updated-name"))
+ (t/is (= (:description token') nil))
+ (t/is (= (:value token') true))
+ (t/is (dt/is-after? (:modified-at token-set') (:modified-at token-set)))
+ (t/is (dt/is-after? (:modified-at token') (:modified-at token)))))
+
+ (t/deftest delete-token-in-group
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :name "test-token-set"))
+ (ctob/add-token-in-set "test-token-set"
+ (ctob/make-token :name "test-token-1"
+ :type :boolean
+ :value true))
+ (ctob/add-token-in-set "test-token-set"
+ (ctob/make-token :name "group1.test-token-2"
+ :type :boolean
+ :value true)))
+ tokens-lib' (-> tokens-lib
+ (ctob/delete-token-from-set "test-token-set" "group1.test-token-2"))
+
+ token-set (ctob/get-set tokens-lib "test-token-set")
+ token-set' (ctob/get-set tokens-lib' "test-token-set")
+ token' (get-in token-set' [:tokens "group1.test-token-2"])]
+
+ (t/is (= (ctob/set-count tokens-lib') 1))
+ (t/is (= (count (:tokens token-set')) 1))
+ (t/is (nil? token'))
+ (t/is (dt/is-after? (:modified-at token-set') (:modified-at token-set))))))
+
+ (t/testing "grouped sets"
+ (t/deftest grouped-sets
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :name "token-set-1"))
+ (ctob/add-set (ctob/make-token-set :name "group1/token-set-2"))
+ (ctob/add-set (ctob/make-token-set :name "group1/token-set-3"))
+ (ctob/add-set (ctob/make-token-set :name "group1/subgroup11/token-set-4"))
+ (ctob/add-set (ctob/make-token-set :name "group2/token-set-5")))
+
+ sets-list (ctob/get-sets tokens-lib)
+
+ sets-tree (ctob/get-set-tree tokens-lib)
+
+ [node-set1 node-group1 node-group2]
+ (ctob/get-children sets-tree)
+
+ [node-set2 node-set3 node-subgroup11]
+ (ctob/get-children (second node-group1))
+
+ [node-set4]
+ (ctob/get-children (second node-subgroup11))
+
+ [node-set5]
+ (ctob/get-children (second node-group2))]
+
+ (t/is (= (count sets-list) 5))
+ (t/is (= (:name (nth sets-list 0)) "token-set-1"))
+ (t/is (= (:name (nth sets-list 1)) "group1/token-set-2"))
+ (t/is (= (:name (nth sets-list 2)) "group1/token-set-3"))
+ (t/is (= (:name (nth sets-list 3)) "group1/subgroup11/token-set-4"))
+ (t/is (= (:name (nth sets-list 4)) "group2/token-set-5"))
+
+ (t/is (= (first node-set1) "token-set-1"))
+ (t/is (= (ctob/group? (second node-set1)) false))
+ (t/is (= (:name (second node-set1)) "token-set-1"))
+
+ (t/is (= (first node-group1) "group1"))
+ (t/is (= (ctob/group? (second node-group1)) true))
+ (t/is (= (count (second node-group1)) 3))
+
+ (t/is (= (first node-set2) "token-set-2"))
+ (t/is (= (ctob/group? (second node-set2)) false))
+ (t/is (= (:name (second node-set2)) "group1/token-set-2"))
+
+ (t/is (= (first node-set3) "token-set-3"))
+ (t/is (= (ctob/group? (second node-set3)) false))
+ (t/is (= (:name (second node-set3)) "group1/token-set-3"))
+
+ (t/is (= (first node-subgroup11) "subgroup11"))
+ (t/is (= (ctob/group? (second node-subgroup11)) true))
+ (t/is (= (count (second node-subgroup11)) 1))
+
+ (t/is (= (first node-set4) "token-set-4"))
+ (t/is (= (ctob/group? (second node-set4)) false))
+ (t/is (= (:name (second node-set4)) "group1/subgroup11/token-set-4"))
+
+ (t/is (= (first node-set5) "token-set-5"))
+ (t/is (= (ctob/group? (second node-set5)) false))
+ (t/is (= (:name (second node-set5)) "group2/token-set-5"))))
+
+ (t/deftest update-set-in-groups
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :name "token-set-1"))
+ (ctob/add-set (ctob/make-token-set :name "group1/token-set-2"))
+ (ctob/add-set (ctob/make-token-set :name "group1/token-set-3"))
+ (ctob/add-set (ctob/make-token-set :name "group1/subgroup11/token-set-4"))
+ (ctob/add-set (ctob/make-token-set :name "group2/token-set-5")))
+
+ tokens-lib' (-> tokens-lib
+ (ctob/update-set "group1/token-set-2"
+ (fn [token-set]
+ (assoc token-set :description "some description"))))
+
+ sets-tree (ctob/get-set-tree tokens-lib)
+ sets-tree' (ctob/get-set-tree tokens-lib')
+ group1' (get sets-tree' "group1")
+ token-set (get-in sets-tree ["group1" "token-set-2"])
+ token-set' (get-in sets-tree' ["group1" "token-set-2"])]
+
+ (t/is (= (ctob/set-count tokens-lib') 5))
+ (t/is (= (count group1') 3))
+ (t/is (= (d/index-of (keys group1') "token-set-2") 0))
+ (t/is (= (:name token-set') "group1/token-set-2"))
+ (t/is (= (:description token-set') "some description"))
+ (t/is (dt/is-after? (:modified-at token-set') (:modified-at token-set)))))
+
+ (t/deftest rename-set-in-groups
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :name "token-set-1"))
+ (ctob/add-set (ctob/make-token-set :name "group1/token-set-2"))
+ (ctob/add-set (ctob/make-token-set :name "group1/token-set-3"))
+ (ctob/add-set (ctob/make-token-set :name "group1/subgroup11/token-set-4"))
+ (ctob/add-set (ctob/make-token-set :name "group2/token-set-5")))
+
+ tokens-lib' (-> tokens-lib
+ (ctob/update-set "group1/token-set-2"
+ (fn [token-set]
+ (assoc token-set
+ :name "group1/updated-name"))))
+
+ sets-tree (ctob/get-set-tree tokens-lib)
+ sets-tree' (ctob/get-set-tree tokens-lib')
+ group1' (get sets-tree' "group1")
+ token-set (get-in sets-tree ["group1" "token-set-2"])
+ token-set' (get-in sets-tree' ["group1" "updated-name"])]
+
+ (t/is (= (ctob/set-count tokens-lib') 5))
+ (t/is (= (count group1') 3))
+ (t/is (= (d/index-of (keys group1') "updated-name") 0))
+ (t/is (= (:name token-set') "group1/updated-name"))
+ (t/is (= (:description token-set') nil))
+ (t/is (dt/is-after? (:modified-at token-set') (:modified-at token-set)))))
+
+ (t/deftest move-set-of-group
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :name "token-set-1"))
+ (ctob/add-set (ctob/make-token-set :name "group1/token-set-2"))
+ (ctob/add-set (ctob/make-token-set :name "group1/token-set-3"))
+ (ctob/add-set (ctob/make-token-set :name "group1/subgroup11/token-set-4"))
+ #_(ctob/add-set (ctob/make-token-set :name "group2/token-set-5")))
+
+ tokens-lib' (-> tokens-lib
+ (ctob/update-set "group1/token-set-2"
+ (fn [token-set]
+ (assoc token-set
+ :name "group2/updated-name"))))
+
+ sets-tree (ctob/get-set-tree tokens-lib)
+ sets-tree' (ctob/get-set-tree tokens-lib')
+ group1' (get sets-tree' "group1")
+ group2' (get sets-tree' "group2")
+ token-set (get-in sets-tree ["group1" "token-set-2"])
+ token-set' (get-in sets-tree' ["group2" "updated-name"])]
+
+ (t/is (= (ctob/set-count tokens-lib') 4))
+ (t/is (= (count group1') 2))
+ (t/is (= (count group2') 1))
+ (t/is (= (d/index-of (keys group2') "updated-name") 0))
+ (t/is (= (:name token-set') "group2/updated-name"))
+ (t/is (= (:description token-set') nil))
+ (t/is (dt/is-after? (:modified-at token-set') (:modified-at token-set)))))
+
+ (t/deftest delete-set-in-group
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :name "token-set-1"))
+ (ctob/add-set (ctob/make-token-set :name "group1/token-set-2")))
+
+ tokens-lib' (-> tokens-lib
+ (ctob/delete-set "group1/token-set-2"))
+
+ sets-tree' (ctob/get-set-tree tokens-lib')
+ token-set' (get-in sets-tree' ["group1" "token-set-2"])]
+
+ (t/is (= (ctob/set-count tokens-lib') 1))
+ (t/is (= (count sets-tree') 1))
+ (t/is (nil? token-set')))))
+
+ (t/testing "grouped themes"
+ (t/deftest grouped-themes
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-theme (ctob/make-token-theme :group "" :name "token-theme-1"))
+ (ctob/add-theme (ctob/make-token-theme :group "group1" :name "token-theme-2"))
+ (ctob/add-theme (ctob/make-token-theme :group "group1" :name "token-theme-3"))
+ (ctob/add-theme (ctob/make-token-theme :group "group2" :name "token-theme-4")))
+
+ themes-list (ctob/get-themes tokens-lib)
+
+ themes-tree (ctob/get-theme-tree tokens-lib)
+
+ [node-group0 node-group1 node-group2]
+ (ctob/get-children themes-tree)
+
+ [node-theme1]
+ (ctob/get-children (second node-group0))
+
+ [node-theme2 node-theme3]
+ (ctob/get-children (second node-group1))
+
+ [node-theme4]
+ (ctob/get-children (second node-group2))]
+
+ (t/is (= (count themes-list) 4))
+ (t/is (= (:name (nth themes-list 0)) "token-theme-1"))
+ (t/is (= (:name (nth themes-list 1)) "token-theme-2"))
+ (t/is (= (:name (nth themes-list 2)) "token-theme-3"))
+ (t/is (= (:name (nth themes-list 3)) "token-theme-4"))
+ (t/is (= (:group (nth themes-list 0)) ""))
+ (t/is (= (:group (nth themes-list 1)) "group1"))
+ (t/is (= (:group (nth themes-list 2)) "group1"))
+ (t/is (= (:group (nth themes-list 3)) "group2"))
+
+ (t/is (= (first node-group0) ""))
+ (t/is (= (ctob/group? (second node-group0)) true))
+ (t/is (= (count (second node-group0)) 1))
+
+ (t/is (= (first node-theme1) "token-theme-1"))
+ (t/is (= (ctob/group? (second node-theme1)) false))
+ (t/is (= (:name (second node-theme1)) "token-theme-1"))
+
+ (t/is (= (first node-group1) "group1"))
+ (t/is (= (ctob/group? (second node-group1)) true))
+ (t/is (= (count (second node-group1)) 2))
+
+ (t/is (= (first node-theme2) "token-theme-2"))
+ (t/is (= (ctob/group? (second node-theme2)) false))
+ (t/is (= (:name (second node-theme2)) "token-theme-2"))
+
+ (t/is (= (first node-theme3) "token-theme-3"))
+ (t/is (= (ctob/group? (second node-theme3)) false))
+ (t/is (= (:name (second node-theme3)) "token-theme-3"))
+
+ (t/is (= (first node-theme4) "token-theme-4"))
+ (t/is (= (ctob/group? (second node-theme4)) false))
+ (t/is (= (:name (second node-theme4)) "token-theme-4"))))
+
+ (t/deftest update-theme-in-groups
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-theme (ctob/make-token-theme :group "" :name "token-theme-1"))
+ (ctob/add-theme (ctob/make-token-theme :group "group1" :name "token-theme-2"))
+ (ctob/add-theme (ctob/make-token-theme :group "group1" :name "token-theme-3"))
+ (ctob/add-theme (ctob/make-token-theme :group "group2" :name "token-theme-4")))
+
+ tokens-lib' (-> tokens-lib
+ (ctob/update-theme "group1" "token-theme-2"
+ (fn [token-theme]
+ (assoc token-theme :description "some description"))))
+
+ themes-tree (ctob/get-theme-tree tokens-lib)
+ themes-tree' (ctob/get-theme-tree tokens-lib')
+ group1' (get themes-tree' "group1")
+ token-theme (get-in themes-tree ["group1" "token-theme-2"])
+ token-theme' (get-in themes-tree' ["group1" "token-theme-2"])]
+
+ (t/is (= (ctob/theme-count tokens-lib') 4))
+ (t/is (= (count group1') 2))
+ (t/is (= (d/index-of (keys group1') "token-theme-2") 0))
+ (t/is (= (:name token-theme') "token-theme-2"))
+ (t/is (= (:group token-theme') "group1"))
+ (t/is (= (:description token-theme') "some description"))
+ (t/is (dt/is-after? (:modified-at token-theme') (:modified-at token-theme)))))
+
+ (t/deftest get-theme-groups
+ (let [token-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-theme (ctob/make-token-theme :group "" :name "token-theme-1"))
+ (ctob/add-theme (ctob/make-token-theme :group "group1" :name "token-theme-2"))
+ (ctob/add-theme (ctob/make-token-theme :group "group1" :name "token-theme-3"))
+ (ctob/add-theme (ctob/make-token-theme :group "group2" :name "token-theme-4")))
+ token-groups (ctob/get-theme-groups token-lib)]
+ (t/is (= token-groups ["group1" "group2"]))))
+
+ (t/deftest rename-theme-in-groups
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-theme (ctob/make-token-theme :group "" :name "token-theme-1"))
+ (ctob/add-theme (ctob/make-token-theme :group "group1" :name "token-theme-2"))
+ (ctob/add-theme (ctob/make-token-theme :group "group1" :name "token-theme-3"))
+ (ctob/add-theme (ctob/make-token-theme :group "group2" :name "token-theme-4")))
+
+ tokens-lib' (-> tokens-lib
+ (ctob/update-theme "group1" "token-theme-2"
+ (fn [token-theme]
+ (assoc token-theme
+ :name "updated-name"))))
+
+ themes-tree (ctob/get-theme-tree tokens-lib)
+ themes-tree' (ctob/get-theme-tree tokens-lib')
+ group1' (get themes-tree' "group1")
+ token-theme (get-in themes-tree ["group1" "token-theme-2"])
+ token-theme' (get-in themes-tree' ["group1" "updated-name"])]
+
+ (t/is (= (ctob/theme-count tokens-lib') 4))
+ (t/is (= (count group1') 2))
+ (t/is (= (d/index-of (keys group1') "updated-name") 0))
+ (t/is (= (:name token-theme') "updated-name"))
+ (t/is (= (:group token-theme') "group1"))
+ (t/is (= (:description token-theme') nil))
+ (t/is (dt/is-after? (:modified-at token-theme') (:modified-at token-theme)))))
+
+ (t/deftest move-theme-of-group
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-theme (ctob/make-token-theme :group "" :name "token-theme-1"))
+ (ctob/add-theme (ctob/make-token-theme :group "group1" :name "token-theme-2"))
+ (ctob/add-theme (ctob/make-token-theme :group "group1" :name "token-theme-3"))
+ #_(ctob/add-theme (ctob/make-token-theme :group "group2" :name "token-theme-4")))
+
+ tokens-lib' (-> tokens-lib
+ (ctob/update-theme "group1" "token-theme-2"
+ (fn [token-theme]
+ (assoc token-theme
+ :name "updated-name"
+ :group "group2"))))
+
+ themes-tree (ctob/get-theme-tree tokens-lib)
+ themes-tree' (ctob/get-theme-tree tokens-lib')
+ group1' (get themes-tree' "group1")
+ group2' (get themes-tree' "group2")
+ token-theme (get-in themes-tree ["group1" "token-theme-2"])
+ token-theme' (get-in themes-tree' ["group2" "updated-name"])]
+
+ (t/is (= (ctob/theme-count tokens-lib') 3))
+ (t/is (= (count group1') 1))
+ (t/is (= (count group2') 1))
+ (t/is (= (d/index-of (keys group2') "updated-name") 0))
+ (t/is (= (:name token-theme') "updated-name"))
+ (t/is (= (:group token-theme') "group2"))
+ (t/is (= (:description token-theme') nil))
+ (t/is (dt/is-after? (:modified-at token-theme') (:modified-at token-theme)))))
+
+ (t/deftest delete-theme-in-group
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-theme (ctob/make-token-theme :group "" :name "token-theme-1"))
+ (ctob/add-theme (ctob/make-token-theme :group "group1" :name "token-theme-2")))
+
+ tokens-lib' (-> tokens-lib
+ (ctob/delete-theme "group1" "token-theme-2"))
+
+ themes-tree' (ctob/get-theme-tree tokens-lib')
+ token-theme' (get-in themes-tree' ["group1" "token-theme-2"])]
+
+ (t/is (= (ctob/theme-count tokens-lib') 1))
+ (t/is (= (count themes-tree') 1))
+ (t/is (nil? token-theme'))))))
+
+#?(:clj
+ (t/testing "dtcg encoding/decoding"
+ (t/deftest decode-dtcg-json
+ (let [json (-> (slurp "test/common_tests/types/data/tokens-multi-set-example.json")
+ (tr/decode-str))
+ lib (ctob/decode-dtcg-json (ctob/ensure-tokens-lib nil) json)
+ get-set-token (fn [set-name token-name]
+ (some-> (ctob/get-set lib set-name)
+ (ctob/get-token token-name)
+ (dissoc :modified-at)))]
+ (t/is (= '("core" "light" "dark" "theme") (ctob/get-ordered-set-names lib)))
+ (t/testing "tokens exist in core set"
+ (t/is (= (get-set-token "core" "colors.red.600")
+ {:name "colors.red.600"
+ :type :color
+ :value "#e53e3e"
+ :description nil}))
+ (t/is (= (get-set-token "core" "spacing.multi-value")
+ {:name "spacing.multi-value"
+ :type :spacing
+ :value "{dimension.sm} {dimension.xl}"
+ :description "You can have multiple values in a single spacing token"}))
+ (t/is (= (get-set-token "theme" "button.primary.background")
+ {:name "button.primary.background"
+ :type :color
+ :value "{accent.default}"
+ :description nil})))
+ (t/testing "invalid tokens got discarded"
+ (t/is (nil? (get-set-token "typography" "H1.Bold"))))))
+
+ (t/deftest encode-dtcg-json
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :name "core"
+ :tokens {"colors.red.600"
+ (ctob/make-token
+ {:name "colors.red.600"
+ :type :color
+ :value "#e53e3e"})
+ "spacing.multi-value"
+ (ctob/make-token
+ {:name "spacing.multi-value"
+ :type :spacing
+ :value "{dimension.sm} {dimension.xl}"
+ :description "You can have multiple values in a single spacing token"})
+ "button.primary.background"
+ (ctob/make-token
+ {:name "button.primary.background"
+ :type :color
+ :value "{accent.default}"})})))
+ expected (ctob/encode-dtcg tokens-lib)]
+ (t/is (= {"core"
+ {"colors" {"red" {"600" {"$value" "#e53e3e"
+ "$type" "color"}}}
+ "spacing"
+ {"multi-value"
+ {"$value" "{dimension.sm} {dimension.xl}"
+ "$type" "spacing"
+ "$description" "You can have multiple values in a single spacing token"}}
+ "button"
+ {"primary" {"background" {"$value" "{accent.default}"
+ "$type" "color"}}}}}
+ expected))))
+
+ (t/deftest encode-decode-dtcg-json
+ (with-redefs [dt/now (constantly #inst "2024-10-16T12:01:20.257840055-00:00")]
+ (let [tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :name "core"
+ :tokens {"colors.red.600"
+ (ctob/make-token
+ {:name "colors.red.600"
+ :type :color
+ :value "#e53e3e"})
+ "spacing.multi-value"
+ (ctob/make-token
+ {:name "spacing.multi-value"
+ :type :spacing
+ :value "{dimension.sm} {dimension.xl}"
+ :description "You can have multiple values in a single spacing token"})
+ "button.primary.background"
+ (ctob/make-token
+ {:name "button.primary.background"
+ :type :color
+ :value "{accent.default}"})})))
+ encoded (ctob/encode-dtcg tokens-lib)
+ with-prev-tokens-lib (ctob/decode-dtcg-json tokens-lib encoded)
+ with-empty-tokens-lib (ctob/decode-dtcg-json (ctob/ensure-tokens-lib nil) encoded)]
+ (t/testing "library got updated but data is equal"
+ (t/is (not= with-prev-tokens-lib tokens-lib))
+ (t/is (= @with-prev-tokens-lib @tokens-lib)))
+ (t/testing "fresh tokens library is also equal"
+ (= @with-empty-tokens-lib @tokens-lib)))))))
diff --git a/frontend/package.json b/frontend/package.json
index 4c1267d3e..edc46abed 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -32,9 +32,9 @@
"lint:clj": "clj-kondo --parallel --lint src/",
"lint:scss": "yarn run prettier -c resources/styles -c src/**/*.scss",
"lint:scss:fix": "yarn run prettier -c resources/styles -c src/**/*.scss -w",
- "build:test": "clojure -M:dev:shadow-cljs compile test-esm",
+ "build:test": "clojure -M:dev:shadow-cljs compile test",
"test": "yarn run build:test && node target/tests/test.js",
- "watch:test": "concurrently \"clojure -M:dev:shadow-cljs watch test-esm\" \"nodemon -w target/tests/test.js --exec 'sleep 2 && node target/tests/test.js'\"",
+ "watch:test": "concurrently \"clojure -M:dev:shadow-cljs watch test\" \"nodemon -w target/tests/test.js --exec 'sleep 2 && node target/tests/test.js'\"",
"test:e2e": "playwright test --project default",
"translations": "node ./scripts/translations.js",
"watch:app:assets": "node ./scripts/watch.js",
@@ -54,6 +54,7 @@
"@storybook/react-vite": "^8.3.6",
"@types/node": "^22.7.7",
"autoprefixer": "^10.4.20",
+ "bun": "^1.1.25",
"concurrently": "^9.0.1",
"esbuild": "^0.24.0",
"express": "^4.21.1",
@@ -100,12 +101,14 @@
"@penpot/mousetrap": "file:./vendor/mousetrap",
"@penpot/svgo": "penpot/svgo#c6fba7a4dcfbc27b643e7fc0c94fc98cf680b77b",
"@penpot/text-editor": "penpot/penpot-text-editor#449e3322f3fa40b1318c9154afbbc7932a3cb766",
+ "@tokens-studio/sd-transforms": "^0.16.1",
"compression": "^1.7.4",
"date-fns": "^4.1.0",
"eventsource-parser": "^3.0.0",
"js-beautify": "^1.15.1",
"jszip": "^3.10.1",
"lodash": "^4.17.21",
+ "lodash.debounce": "^4.0.8",
"luxon": "^3.5.0",
"opentype.js": "^1.3.4",
"postcss-modules": "^6.0.0",
@@ -117,7 +120,9 @@
"rxjs": "8.0.0-alpha.14",
"sax": "^1.4.1",
"source-map-support": "^0.5.21",
+ "style-dictionary": "^4.1.4",
"tdigest": "^0.1.2",
+ "tinycolor2": "npm:^1.6.0",
"ua-parser-js": "2.0.0-rc.1",
"xregexp": "^5.1.1"
}
diff --git a/frontend/resources/images/icons/arrow-down.svg b/frontend/resources/images/icons/arrow-down.svg
new file mode 100644
index 000000000..c947ef5b7
--- /dev/null
+++ b/frontend/resources/images/icons/arrow-down.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/frontend/resources/images/icons/arrow-left.svg b/frontend/resources/images/icons/arrow-left.svg
new file mode 100644
index 000000000..5fd7250b7
--- /dev/null
+++ b/frontend/resources/images/icons/arrow-left.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/frontend/resources/images/icons/arrow-right.svg b/frontend/resources/images/icons/arrow-right.svg
new file mode 100644
index 000000000..d95bda1ad
--- /dev/null
+++ b/frontend/resources/images/icons/arrow-right.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/frontend/resources/images/icons/arrow-up.svg b/frontend/resources/images/icons/arrow-up.svg
new file mode 100644
index 000000000..505e91dcf
--- /dev/null
+++ b/frontend/resources/images/icons/arrow-up.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/frontend/shadow-cljs.edn b/frontend/shadow-cljs.edn
index dad9d34f0..56e1678e4 100644
--- a/frontend/shadow-cljs.edn
+++ b/frontend/shadow-cljs.edn
@@ -8,8 +8,7 @@
{:target :browser
:output-dir "resources/public/js/"
:asset-path "/js"
- :devtools {:browser-inject :main
- :watch-dir "resources/public"
+ :devtools {:watch-dir "resources/public"
:reload-strategy :full}
:build-options {:manifest-name "manifest.json"}
:module-loader true
@@ -136,38 +135,37 @@
:output-wrapper false
:warnings {:fn-deprecated false}}}
- :test-esm
- {:target :esm
- :output-dir "target/tests"
- :runtime :custom
+ :test
+ {:target :esm
+ :output-dir "target/tests"
+ :runtime :custom
+ :js-options {:js-provider :import}
- :modules
- {:test {:init-fn frontend-tests.runner/init}}}
- ;; :compiler-options
- ;; {:output-feature-set :es2020
- ;; :warnings {:fn-deprecated false}}}
+ :modules
+ {:test {:init-fn frontend-tests.runner/init
+ :prepend-js "globalThis.navigator = {userAgent: \"\"}"}}}
:lib-penpot
- {:target :esm
- :output-dir "resources/public/libs"
+ {:target :esm
+ :output-dir "resources/public/libs"
- :modules
- {:penpot {:exports {:renderPage app.libs.render/render-page-export
- :createFile app.libs.file-builder/create-file-export}}}
+ :modules
+ {:penpot {:exports {:renderPage app.libs.render/render-page-export
+ :createFile app.libs.file-builder/create-file-export}}}
- :compiler-options
- {:output-feature-set :es2020
- :output-wrapper false
- :warnings {:fn-deprecated false}}
+ :compiler-options
+ {:output-feature-set :es2020
+ :output-wrapper false
+ :warnings {:fn-deprecated false}}
- :release
- {:compiler-options
- {:fn-invoke-direct true
- :source-map true
- :elide-asserts true
- :anon-fn-naming-policy :off
- :source-map-detail-level :all}}}
+ :release
+ {:compiler-options
+ {:fn-invoke-direct true
+ :source-map true
+ :elide-asserts true
+ :anon-fn-naming-policy :off
+ :source-map-detail-level :all}}}
:bench
{:target :node-script
@@ -184,24 +182,5 @@
{:compiler-options
{:fn-invoke-direct true
:elide-asserts true
- :anon-fn-naming-policy :off}}}
+ :anon-fn-naming-policy :off}}}}}
- :test
- {:target :node-test
- :output-to "target/tests.cjs"
- :output-dir "target/test/"
- :ns-regexp "^frontend-tests.*-test$"
- :autorun true
-
- :js-options
- {:entry-keys ["module" "browser" "main"]}
-
- :compiler-options
- {:output-feature-set :es2020
- :output-wrapper false
- :source-map true
- :source-map-include-sources-content true
- :source-map-detail-level :all
- :warnings {:fn-deprecated false}}}
-
- }}
diff --git a/frontend/src/app/main/data/tokens.cljs b/frontend/src/app/main/data/tokens.cljs
new file mode 100644
index 000000000..eda14e846
--- /dev/null
+++ b/frontend/src/app/main/data/tokens.cljs
@@ -0,0 +1,352 @@
+;; 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.data.tokens
+ (:require
+ [app.common.data :as d]
+ [app.common.data.macros :as dm]
+ [app.common.files.changes-builder :as pcb]
+ [app.common.geom.point :as gpt]
+ [app.common.types.shape :as cts]
+ [app.common.types.tokens-lib :as ctob]
+ [app.main.data.changes :as dch]
+ [app.main.data.workspace.shapes :as dwsh]
+ [app.main.refs :as refs]
+ [app.main.ui.workspace.tokens.token :as wtt]
+ [app.main.ui.workspace.tokens.token-set :as wtts]
+ [app.main.ui.workspace.tokens.update :as wtu]
+ [beicon.v2.core :as rx]
+ [clojure.data :as data]
+ [cuerdas.core :as str]
+ [potok.v2.core :as ptk]))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; Helpers
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+;; TODO HYMA: Copied over from workspace.cljs
+(defn update-shape
+ [id attrs]
+ (dm/assert!
+ "expected valid parameters"
+ (and (cts/check-shape-attrs! attrs)
+ (uuid? id)))
+
+ (ptk/reify ::update-shape
+ ptk/WatchEvent
+ (watch [_ _ _]
+ (rx/of (dwsh/update-shapes [id] #(merge % attrs))))))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; TOKENS Getters
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(defn get-tokens-lib [state]
+ (get-in state [:workspace-data :tokens-lib]))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; TOKENS Actions
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(defn toggle-or-apply-token
+ "Remove any shape attributes from token if they exists.
+ Othewise apply token attributes."
+ [shape token]
+ (let [[shape-leftover token-leftover _matching] (data/diff (:applied-tokens shape) token)]
+ (merge {} shape-leftover token-leftover)))
+
+(defn token-from-attributes [token attributes]
+ (->> (map (fn [attr] [attr (wtt/token-identifier token)]) attributes)
+ (into {})))
+
+(defn unapply-token-id [shape attributes]
+ (update shape :applied-tokens d/without-keys attributes))
+
+(defn apply-token-to-attributes [{:keys [shape token attributes]}]
+ (let [token (token-from-attributes token attributes)]
+ (toggle-or-apply-token shape token)))
+
+(defn apply-token-to-shape
+ [{:keys [shape token attributes] :as _props}]
+ (let [applied-tokens (apply-token-to-attributes {:shape shape
+ :token token
+ :attributes attributes})]
+ (update shape :applied-tokens #(merge % applied-tokens))))
+
+(defn maybe-apply-token-to-shape
+ "When the passed `:token` is non-nil apply it to the `:applied-tokens` on a shape."
+ [{:keys [shape token _attributes] :as props}]
+ (if token
+ (apply-token-to-shape props)
+ shape))
+
+(defn get-token-data-from-token-id
+ [id]
+ (let [workspace-data (deref refs/workspace-data)]
+ (get (:tokens workspace-data) id)))
+
+(defn set-selected-token-set-id
+ [id]
+ (ptk/reify ::set-selected-token-set-id
+ ptk/UpdateEvent
+ (update [_ state]
+ (wtts/assoc-selected-token-set-id state id))))
+
+(defn create-token-theme [token-theme]
+ (let [new-token-theme token-theme]
+ (ptk/reify ::create-token-theme
+ ptk/WatchEvent
+ (watch [it _ _]
+ (let [changes (-> (pcb/empty-changes it)
+ (pcb/add-token-theme new-token-theme))]
+ (rx/of
+ (dch/commit-changes changes)))))))
+
+(defn update-token-theme [[group name] token-theme]
+ (ptk/reify ::update-token-theme
+ ptk/WatchEvent
+ (watch [it state _]
+ (let [tokens-lib (get-tokens-lib state)
+ prev-token-theme (some-> tokens-lib (ctob/get-theme group name))
+ changes (pcb/update-token-theme (pcb/empty-changes it) token-theme prev-token-theme)]
+ (rx/of
+ (dch/commit-changes changes))))))
+
+(defn toggle-token-theme-active? [group name]
+ (ptk/reify ::toggle-token-theme-active?
+ ptk/WatchEvent
+ (watch [it state _]
+ (let [tokens-lib (get-tokens-lib state)
+ prev-active-token-themes (some-> tokens-lib
+ (ctob/get-active-theme-paths))
+ active-token-themes (some-> tokens-lib
+ (ctob/toggle-theme-active? group name)
+ (ctob/get-active-theme-paths))
+ active-token-themes' (if (= active-token-themes #{ctob/hidden-token-theme-path})
+ active-token-themes
+ (disj active-token-themes ctob/hidden-token-theme-path))
+ changes (-> (pcb/empty-changes it)
+ (pcb/update-active-token-themes active-token-themes' prev-active-token-themes))]
+ (rx/of
+ (dch/commit-changes changes)
+ (wtu/update-workspace-tokens))))))
+
+(defn delete-token-theme [group name]
+ (ptk/reify ::delete-token-theme
+ ptk/WatchEvent
+ (watch [it state _]
+ (let [data (get state :workspace-data)
+ changes (-> (pcb/empty-changes it)
+ (pcb/with-library-data data)
+ (pcb/delete-token-theme group name))]
+ (rx/of
+ (dch/commit-changes changes)
+ (wtu/update-workspace-tokens))))))
+
+(defn create-token-set [token-set]
+ (let [new-token-set (merge
+ {:name "Token Set"
+ :tokens []}
+ token-set)]
+ (ptk/reify ::create-token-set
+ ptk/WatchEvent
+ (watch [it _ _]
+ (let [changes (-> (pcb/empty-changes it)
+ (pcb/add-token-set new-token-set))]
+ (rx/of
+ (set-selected-token-set-id (:name new-token-set))
+ (dch/commit-changes changes)))))))
+
+(defn update-token-set [set-name token-set]
+ (ptk/reify ::update-token-set
+ ptk/WatchEvent
+ (watch [it state _]
+ (let [prev-token-set (some-> (get-tokens-lib state)
+ (ctob/get-set set-name))
+ changes (-> (pcb/empty-changes it)
+ (pcb/update-token-set token-set prev-token-set))]
+ (rx/of
+ (set-selected-token-set-id (:name token-set))
+ (dch/commit-changes changes))))))
+
+(defn toggle-token-set [{:keys [token-set-name]}]
+ (ptk/reify ::toggle-token-set
+ ptk/WatchEvent
+ (watch [it state _]
+ (let [tokens-lib (get-tokens-lib state)
+ prev-theme (ctob/get-theme tokens-lib ctob/hidden-token-theme-group ctob/hidden-token-theme-name)
+ active-token-set-names (ctob/get-active-themes-set-names tokens-lib)
+ theme (-> (or (some-> prev-theme
+ (ctob/set-sets active-token-set-names))
+ (ctob/make-hidden-token-theme :sets active-token-set-names))
+ (ctob/toggle-set token-set-name))
+ prev-active-token-themes (ctob/get-active-theme-paths tokens-lib)
+ changes (-> (pcb/empty-changes it)
+ (pcb/update-active-token-themes #{(ctob/token-theme-path ctob/hidden-token-theme-group ctob/hidden-token-theme-name)} prev-active-token-themes))
+ changes' (if prev-theme
+ (pcb/update-token-theme changes theme prev-theme)
+ (pcb/add-token-theme changes theme))]
+ (rx/of
+ (dch/commit-changes changes')
+ (wtu/update-workspace-tokens))))))
+
+(defn import-tokens-lib [lib]
+ (ptk/reify ::import-tokens-lib
+ ptk/WatchEvent
+ (watch [it state _]
+ (let [data (get state :workspace-data)
+ update-token-set-change (some-> lib
+ (ctob/get-sets)
+ (first)
+ (:name)
+ (set-selected-token-set-id))
+ changes (-> (pcb/empty-changes it)
+ (pcb/with-library-data data)
+ (pcb/set-tokens-lib lib))]
+ (rx/of
+ (dch/commit-changes changes)
+ update-token-set-change
+ (wtu/update-workspace-tokens))))))
+
+(defn delete-token-set [token-set-name]
+ (ptk/reify ::delete-token-set
+ ptk/WatchEvent
+ (watch [it state _]
+ (let [data (get state :workspace-data)
+ changes (-> (pcb/empty-changes it)
+ (pcb/with-library-data data)
+ (pcb/delete-token-set token-set-name))]
+ (rx/of
+ (dch/commit-changes changes)
+ (wtu/update-workspace-tokens))))))
+
+(defn move-token-set [source-set-name dest-set-name position]
+ (ptk/reify ::move-token-set
+ ptk/WatchEvent
+ (watch [it state _]
+ (let [tokens-lib (get-tokens-lib state)
+ prev-before-set-name (ctob/get-neighbor-set-name tokens-lib source-set-name 1)
+ [source-set-name' dest-set-name'] (if (= :top position)
+ [source-set-name dest-set-name]
+ [source-set-name (ctob/get-neighbor-set-name tokens-lib dest-set-name 1)])
+ changes (-> (pcb/empty-changes it)
+ (pcb/move-token-set-before source-set-name' dest-set-name' prev-before-set-name))]
+ (rx/of
+ (dch/commit-changes changes)
+ (wtu/update-workspace-tokens))))))
+
+(defn update-create-token
+ [{:keys [token prev-token-name]}]
+ (ptk/reify ::update-create-token
+ ptk/WatchEvent
+ (watch [_ state _]
+ (let [token-set (wtts/get-selected-token-set state)
+ token-set-name (or (:name token-set) "Global")
+ changes (if (not token-set)
+ ;; No set created add a global set
+ (let [tokens-lib (get-tokens-lib state)
+ token-set (ctob/make-token-set :name token-set-name :tokens {(:name token) token})
+ hidden-theme (ctob/make-hidden-token-theme :sets [token-set-name])
+ active-theme-paths (some-> tokens-lib ctob/get-active-theme-paths)
+ add-to-hidden-theme? (= active-theme-paths #{ctob/hidden-token-theme-path})
+ base-changes (pcb/add-token-set (pcb/empty-changes) token-set)]
+ (cond
+ (not tokens-lib) (-> base-changes
+ (pcb/add-token-theme hidden-theme)
+ (pcb/update-active-token-themes #{ctob/hidden-token-theme-path} #{}))
+
+ add-to-hidden-theme? (let [prev-hidden-theme (ctob/get-theme tokens-lib ctob/hidden-token-theme-group ctob/hidden-token-theme-name)]
+ (-> base-changes
+ (pcb/update-token-theme (ctob/toggle-set prev-hidden-theme ctob/hidden-token-theme-path) prev-hidden-theme)))
+
+ :else base-changes))
+ ;; Either update or add token to existing set
+ (if-let [prev-token (ctob/get-token token-set (or prev-token-name (:name token)))]
+ (pcb/update-token (pcb/empty-changes) (:name token-set) token prev-token)
+ (pcb/add-token (pcb/empty-changes) (:name token-set) token)))]
+ (rx/of
+ (set-selected-token-set-id token-set-name)
+ (dch/commit-changes changes))))))
+
+(defn delete-token
+ [set-name token-name]
+ (dm/assert! (string? set-name))
+ (dm/assert! (string? token-name))
+ (ptk/reify ::delete-token
+ ptk/WatchEvent
+ (watch [it state _]
+ (let [data (get state :workspace-data)
+ changes (-> (pcb/empty-changes it)
+ (pcb/with-library-data data)
+ (pcb/delete-token set-name token-name))]
+ (rx/of (dch/commit-changes changes))))))
+
+(defn duplicate-token
+ [token-name]
+ (dm/assert! (string? token-name))
+ (ptk/reify ::duplicate-token
+ ptk/WatchEvent
+ (watch [_ state _]
+ (when-let [token (some-> (wtts/get-selected-token-set state)
+ (ctob/get-token token-name)
+ (update :name #(str/concat % "-copy")))]
+ (rx/of
+ (update-create-token {:token token}))))))
+
+(defn set-token-type-section-open
+ [token-type open?]
+ (ptk/reify ::set-token-type-section-open
+ ptk/UpdateEvent
+ (update [_ state]
+ (assoc-in state [:workspace-tokens :open-status token-type] open?))))
+
+;; === Token Context Menu
+
+(defn show-token-context-menu
+ [{:keys [position _token-name] :as params}]
+ (dm/assert! (gpt/point? position))
+ (ptk/reify ::show-token-context-menu
+ ptk/UpdateEvent
+ (update [_ state]
+ (assoc-in state [:workspace-local :token-context-menu] params))))
+
+(def hide-token-context-menu
+ (ptk/reify ::hide-token-context-menu
+ ptk/UpdateEvent
+ (update [_ state]
+ (assoc-in state [:workspace-local :token-context-menu] nil))))
+
+;; === Token Set Context Menu
+
+(defn show-token-set-context-menu
+ [{:keys [position _token-set-name] :as params}]
+ (dm/assert! (gpt/point? position))
+ (ptk/reify ::show-token-set-context-menu
+ ptk/UpdateEvent
+ (update [_ state]
+ (assoc-in state [:workspace-local :token-set-context-menu] params))))
+
+(def hide-token-set-context-menu
+ (ptk/reify ::hide-token-set-context-menu
+ ptk/UpdateEvent
+ (update [_ state]
+ (assoc-in state [:workspace-local :token-set-context-menu] nil))))
+
+;; === Import Export Context Menu
+
+(defn show-import-export-context-menu
+ [{:keys [position] :as params}]
+ (dm/assert! (gpt/point? position))
+ (ptk/reify ::show-import-export-context-menu
+ ptk/UpdateEvent
+ (update [_ state]
+ (assoc-in state [:workspace-local :import-export-context-menu] params))))
+
+(def hide-import-export-set-context-menu
+ (ptk/reify ::hide-import-export-set-context-menu
+ ptk/UpdateEvent
+ (update [_ state]
+ (assoc-in state [:workspace-local :import-export-set-context-menu] nil))))
diff --git a/frontend/src/app/main/data/workspace/layout.cljs b/frontend/src/app/main/data/workspace/layout.cljs
index 51fddd8a1..1fb219863 100644
--- a/frontend/src/app/main/data/workspace/layout.cljs
+++ b/frontend/src/app/main/data/workspace/layout.cljs
@@ -44,7 +44,11 @@
:layers
{:del #{:document-history :assets}
- :add #{:sitemap :layers}}})
+ :add #{:sitemap :layers}}
+
+ :tokens
+ {:del #{:sitemap :layers :document-history :assets}
+ :add #{:tokens}}})
(def valid-options-mode
#{:design :prototype :inspect})
diff --git a/frontend/src/app/main/features.cljs b/frontend/src/app/main/features.cljs
index b4684d58d..991e17d7e 100644
--- a/frontend/src/app/main/features.cljs
+++ b/frontend/src/app/main/features.cljs
@@ -110,7 +110,9 @@
(when *assert*
(->> (rx/from cfeat/no-migration-features)
;; text editor v2 isn't enabled by default even in devenv
- (rx/filter #(not (or (contains? cfeat/backend-only-features %) (= "text-editor/v2" %))))
+ (rx/filter #(not (or (contains? cfeat/backend-only-features %)
+ (= "text-editor/v2" %)
+ (= "design-tokens/v1" %))))
(rx/observe-on :async)
(rx/map enable-feature))))
diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs
index f0a8db187..6f73be8dc 100644
--- a/frontend/src/app/main/refs.cljs
+++ b/frontend/src/app/main/refs.cljs
@@ -12,8 +12,10 @@
[app.common.files.helpers :as cph]
[app.common.types.shape-tree :as ctt]
[app.common.types.shape.layout :as ctl]
+ [app.common.types.tokens-lib :as ctob]
[app.main.data.workspace.state-helpers :as wsh]
[app.main.store :as st]
+ [app.main.ui.workspace.tokens.token-set :as wtts]
[okulary.core :as l]))
;; ---- Global refs
@@ -205,6 +207,9 @@
(def context-menu
(l/derived :context-menu workspace-local))
+(def token-context-menu
+ (l/derived :token-context-menu workspace-local))
+
;; page item that it is being edited
(def editing-page-item
(l/derived :page-item workspace-local))
@@ -448,6 +453,65 @@
ids)))
st/state =))
+;; ---- Token refs
+
+(def tokens-lib
+ (l/derived :tokens-lib workspace-data))
+
+(def workspace-token-theme-groups
+ (l/derived (d/nilf ctob/get-theme-groups) tokens-lib))
+
+(defn workspace-token-theme
+ [group name]
+ (l/derived
+ (fn [lib]
+ (when lib
+ (ctob/get-theme lib group name)))
+ tokens-lib))
+
+(def workspace-token-theme-tree-no-hidden
+ (l/derived (fn [lib]
+ (or
+ (some-> lib
+ (ctob/delete-theme ctob/hidden-token-theme-group ctob/hidden-token-theme-name)
+ (ctob/get-theme-tree))
+ []))
+ tokens-lib))
+
+(def workspace-token-themes
+ (l/derived #(or (some-> % ctob/get-themes) []) tokens-lib))
+
+(def workspace-token-themes-no-hidden
+ (l/derived #(remove ctob/hidden-temporary-theme? %) workspace-token-themes))
+
+(def workspace-selected-token-set-id
+ (l/derived wtts/get-selected-token-set-id st/state))
+
+(def workspace-ordered-token-sets
+ (l/derived #(or (some-> % ctob/get-sets) []) tokens-lib))
+
+(def workspace-active-theme-paths
+ (l/derived (d/nilf ctob/get-active-theme-paths) tokens-lib))
+
+(def workspace-active-theme-paths-no-hidden
+ (l/derived #(disj % ctob/hidden-token-theme-path) workspace-active-theme-paths))
+
+(def workspace-active-set-names
+ (l/derived (d/nilf ctob/get-active-themes-set-names) tokens-lib))
+
+(def workspace-active-theme-sets-tokens
+ (l/derived #(or (some-> % ctob/get-active-themes-set-tokens) {}) tokens-lib))
+
+(def workspace-selected-token-set-token
+ (fn [token-name]
+ (l/derived
+ #(some-> (wtts/get-selected-token-set %)
+ (ctob/get-token token-name))
+ st/state)))
+
+(def workspace-selected-token-set-tokens
+ (l/derived #(or (wtts/get-selected-token-set-tokens %) {}) st/state))
+
;; ---- Viewer refs
(defn lookup-viewer-objects-by-id
diff --git a/frontend/src/app/main/ui/comments.cljs b/frontend/src/app/main/ui/comments.cljs
index db9e37018..61a348058 100644
--- a/frontend/src/app/main/ui/comments.cljs
+++ b/frontend/src/app/main/ui/comments.cljs
@@ -99,7 +99,7 @@
content (mf/use-state "")
disabled? (or (str/blank? @content)
- (str/empty-or-nil? @content))
+ (str/empty? @content))
on-focus
(mf/use-fn
@@ -159,7 +159,7 @@
pos-y (* (:y position) zoom)
disabled? (or (str/blank? content)
- (str/empty-or-nil? content))
+ (str/empty? content))
on-esc
(mf/use-fn
@@ -230,7 +230,7 @@
(fn [] (on-submit @content)))
disabled? (or (str/blank? @content)
- (str/empty-or-nil? @content))]
+ (str/empty? @content))]
[:div {:class (stl/css :edit-form)}
[:& resizing-textarea {:value @content
diff --git a/frontend/src/app/main/ui/components/tab_container.cljs b/frontend/src/app/main/ui/components/tab_container.cljs
index 1e3b99079..0d39e93d8 100644
--- a/frontend/src/app/main/ui/components/tab_container.cljs
+++ b/frontend/src/app/main/ui/components/tab_container.cljs
@@ -16,6 +16,8 @@
[cuerdas.core :as str]
[rumext.v2 :as mf]))
+(set! *warn-on-infer* false)
+
(mf/defc tab-element
{::mf/wrap-props false}
[{:keys [children]}]
diff --git a/frontend/src/app/main/ui/context.cljs b/frontend/src/app/main/ui/context.cljs
index 5afe7987f..d192558a3 100644
--- a/frontend/src/app/main/ui/context.cljs
+++ b/frontend/src/app/main/ui/context.cljs
@@ -24,6 +24,7 @@
(def libraries (mf/create-context nil))
(def components-v2 (mf/create-context nil))
+(def design-tokens (mf/create-context nil))
(def current-scroll (mf/create-context nil))
(def current-zoom (mf/create-context nil))
diff --git a/frontend/src/app/main/ui/ds/buttons/_buttons.scss b/frontend/src/app/main/ui/ds/buttons/_buttons.scss
index b489b4df9..07038c823 100644
--- a/frontend/src/app/main/ui/ds/buttons/_buttons.scss
+++ b/frontend/src/app/main/ui/ds/buttons/_buttons.scss
@@ -130,3 +130,21 @@
box-shadow: inset 0 0 #{px2rem(10)} #{px2rem(2)} rgba(0, 0, 0, 0.2);
}
}
+
+%base-button-action {
+ --button-bg-color: transparent;
+ --button-fg-color: var(--color-foreground-secondary);
+
+ --button-hover-bg-color: transparent;
+ --button-hover-fg-color: var(--color-accent-primary);
+
+ --button-active-bg-color: var(--color-background-quaternary);
+
+ --button-disabled-bg-color: transparent;
+ --button-disabled-fg-color: var(--color-accent-primary-muted);
+
+ --button-focus-bg-color: transparent;
+ --button-focus-fg-color: var(--color-accent-primary);
+ --button-focus-inner-ring-color: transparent;
+ --button-focus-outer-ring-color: var(--color-accent-primary);
+}
diff --git a/frontend/src/app/main/ui/ds/buttons/icon_button.cljs b/frontend/src/app/main/ui/ds/buttons/icon_button.cljs
index 0987937b9..68895cc74 100644
--- a/frontend/src/app/main/ui/ds/buttons/icon_button.cljs
+++ b/frontend/src/app/main/ui/ds/buttons/icon_button.cljs
@@ -12,9 +12,6 @@
[app.main.ui.ds.foundations.assets.icon :refer [icon* icon-list]]
[rumext.v2 :as mf]))
-(def button-variants (set '("primary" "secondary" "ghost" "destructive")))
-
-
(def ^:private schema:icon-button
[:map
[:class {:optional true} :string]
@@ -22,7 +19,7 @@
[:and :string [:fn #(contains? icon-list %)]]]
[:aria-label :string]
[:variant {:optional true}
- [:maybe [:enum "primary" "secondary" "ghost" "destructive"]]]])
+ [:maybe [:enum "primary" "secondary" "ghost" "destructive" "action"]]]])
(mf/defc icon-button*
{::mf/props :obj
@@ -33,6 +30,7 @@
:icon-button-primary (= variant "primary")
:icon-button-secondary (= variant "secondary")
:icon-button-ghost (= variant "ghost")
+ :icon-button-action (= variant "action")
:icon-button-destructive (= variant "destructive")))
props (mf/spread-props props {:class class :title aria-label})]
[:> "button" props [:> icon* {:id icon :aria-label aria-label}] children]))
diff --git a/frontend/src/app/main/ui/ds/buttons/icon_button.scss b/frontend/src/app/main/ui/ds/buttons/icon_button.scss
index 1a10c3775..eed4d8f5b 100644
--- a/frontend/src/app/main/ui/ds/buttons/icon_button.scss
+++ b/frontend/src/app/main/ui/ds/buttons/icon_button.scss
@@ -31,3 +31,7 @@
.icon-button-destructive {
@extend %base-button-destructive;
}
+
+.icon-button-action {
+ @extend %base-button-action;
+}
diff --git a/frontend/src/app/main/ui/ds/buttons/icon_button.stories.jsx b/frontend/src/app/main/ui/ds/buttons/icon_button.stories.jsx
index 17cb4b2fb..321aa7b7a 100644
--- a/frontend/src/app/main/ui/ds/buttons/icon_button.stories.jsx
+++ b/frontend/src/app/main/ui/ds/buttons/icon_button.stories.jsx
@@ -26,7 +26,7 @@ export default {
},
disabled: { control: "boolean" },
variant: {
- options: ["primary", "secondary", "ghost", "destructive"],
+ options: ["primary", "secondary", "ghost", "destructive", "action"],
control: { type: "select" },
},
},
@@ -59,6 +59,12 @@ export const Ghost = {
},
};
+export const Action = {
+ args: {
+ variant: "action",
+ },
+};
+
export const Destructive = {
args: {
variant: "destructive",
diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
index fa1485da4..585282db5 100644
--- a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
+++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
@@ -50,6 +50,10 @@
(def ^:icon-id align-top "align-top")
(def ^:icon-id align-vertical-center "align-vertical-center")
(def ^:icon-id arrow "arrow")
+(def ^:icon-id arrow-up "arrow-up")
+(def ^:icon-id arrow-down "arrow-down")
+(def ^:icon-id arrow-left "arrow-left")
+(def ^:icon-id arrow-right "arrow-right")
(def ^:icon-id asc-sort "asc-sort")
(def ^:icon-id board "board")
(def ^:icon-id boards-thumbnail "boards-thumbnail")
diff --git a/frontend/src/app/main/ui/ds/foundations/typography/heading.cljs b/frontend/src/app/main/ui/ds/foundations/typography/heading.cljs
index 515b529e6..9b3898b6c 100644
--- a/frontend/src/app/main/ui/ds/foundations/typography/heading.cljs
+++ b/frontend/src/app/main/ui/ds/foundations/typography/heading.cljs
@@ -32,17 +32,17 @@
(let [level (or level "1")
tag (dm/str "h" level)
- class (dm/str (or class "") " " (stl/css-case :display-typography (= typography t/display)
- :title-large-typography (= typography t/title-large)
- :title-medium-typography (= typography t/title-medium)
- :title-small-typography (= typography t/title-small)
- :headline-large-typography (= typography t/headline-large)
- :headline-medium-typography (= typography t/headline-medium)
- :headline-small-typography (= typography t/headline-small)
- :body-large-typography (= typography t/body-large)
- :body-medium-typography (= typography t/body-medium)
- :body-small-typography (= typography t/body-small)
- :code-font-typography (= typography t/code-font)))
+ class (dm/str class " " (stl/css-case :display-typography (= typography t/display)
+ :title-large-typography (= typography t/title-large)
+ :title-medium-typography (= typography t/title-medium)
+ :title-small-typography (= typography t/title-small)
+ :headline-large-typography (= typography t/headline-large)
+ :headline-medium-typography (= typography t/headline-medium)
+ :headline-small-typography (= typography t/headline-small)
+ :body-large-typography (= typography t/body-large)
+ :body-medium-typography (= typography t/body-medium)
+ :body-small-typography (= typography t/body-small)
+ :code-font-typography (= typography t/code-font)))
props (mf/spread-props props {:class class})]
[:> tag props
children]))
diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs
index 9fef0d2df..4f6360733 100644
--- a/frontend/src/app/main/ui/workspace.cljs
+++ b/frontend/src/app/main/ui/workspace.cljs
@@ -30,6 +30,7 @@
[app.main.ui.workspace.sidebar :refer [left-sidebar right-sidebar]]
[app.main.ui.workspace.sidebar.collapsable-button :refer [collapsed-button]]
[app.main.ui.workspace.sidebar.history :refer [history-toolbox]]
+ [app.main.ui.workspace.tokens.modals]
[app.main.ui.workspace.viewport :refer [viewport]]
[app.util.debug :as dbg]
[app.util.dom :as dom]
@@ -179,6 +180,7 @@
file-ready? (mf/deref file-ready*)
components-v2? (features/use-feature "components/v2")
+ design-tokens? (features/use-feature "design-tokens/v1")
background-color (:background-color wglobal)]
@@ -207,15 +209,16 @@
[:& (mf/provider ctx/current-team-id) {:value team-id}
[:& (mf/provider ctx/current-page-id) {:value page-id}
[:& (mf/provider ctx/components-v2) {:value components-v2?}
- [:& (mf/provider ctx/workspace-read-only?) {:value read-only?}
- [:& (mf/provider ctx/team-permissions) {:value permissions}
- [:section {:class (stl/css :workspace)
- :style {:background-color background-color
- :touch-action "none"}}
- [:& context-menu]
- (if ^boolean file-ready?
- [:& workspace-page {:page-id page-id
- :file file
- :wglobal wglobal
- :layout layout}]
- [:& workspace-loader])]]]]]]]]))
+ [:& (mf/provider ctx/design-tokens) {:value design-tokens?}
+ [:& (mf/provider ctx/workspace-read-only?) {:value read-only?}
+ [:& (mf/provider ctx/team-permissions) {:value permissions}
+ [:section {:class (stl/css :workspace)
+ :style {:background-color background-color
+ :touch-action "none"}}
+ [:& context-menu]
+ (if ^boolean file-ready?
+ [:& workspace-page {:page-id page-id
+ :file file
+ :wglobal wglobal
+ :layout layout}]
+ [:& workspace-loader])]]]]]]]]]))
diff --git a/frontend/src/app/main/ui/workspace/colorpicker.cljs b/frontend/src/app/main/ui/workspace/colorpicker.cljs
index 5eec340c2..fe6ea243e 100644
--- a/frontend/src/app/main/ui/workspace/colorpicker.cljs
+++ b/frontend/src/app/main/ui/workspace/colorpicker.cljs
@@ -53,6 +53,25 @@
;; --- Color Picker Modal
+(defn use-color-picker-css-variables! [node-ref current-color]
+ (mf/with-effect [current-color]
+ (let [node (mf/ref-val node-ref)
+ {:keys [r g b h v]} current-color
+ rgb [r g b]
+ hue-rgb (cc/hsv->rgb [h 1.0 255])
+ hsl-from (cc/hsv->hsl [h 0.0 v])
+ hsl-to (cc/hsv->hsl [h 1.0 v])
+
+ format-hsl (fn [[h s l]]
+ (str/fmt "hsl(%s, %s, %s)"
+ h
+ (str (* s 100) "%")
+ (str (* l 100) "%")))]
+ (dom/set-css-property! node "--color" (str/join ", " rgb))
+ (dom/set-css-property! node "--hue-rgb" (str/join ", " hue-rgb))
+ (dom/set-css-property! node "--saturation-grad-from" (format-hsl hsl-from))
+ (dom/set-css-property! node "--saturation-grad-to" (format-hsl hsl-to)))))
+
(mf/defc colorpicker
{::mf/props :obj}
[{:keys [data disable-gradient disable-opacity disable-image on-change on-accept]}]
@@ -264,23 +283,7 @@
(st/emit! (dc/update-colorpicker data)))
;; Updates the CSS color variable when there is a change in the color
- (mf/with-effect [current-color]
- (let [node (mf/ref-val node-ref)
- {:keys [r g b h v]} current-color
- rgb [r g b]
- hue-rgb (cc/hsv->rgb [h 1.0 255])
- hsl-from (cc/hsv->hsl [h 0.0 v])
- hsl-to (cc/hsv->hsl [h 1.0 v])
-
- format-hsl (fn [[h s l]]
- (str/fmt "hsl(%s, %s, %s)"
- h
- (str (* s 100) "%")
- (str (* l 100) "%")))]
- (dom/set-css-property! node "--color" (str/join ", " rgb))
- (dom/set-css-property! node "--hue-rgb" (str/join ", " hue-rgb))
- (dom/set-css-property! node "--saturation-grad-from" (format-hsl hsl-from))
- (dom/set-css-property! node "--saturation-grad-to" (format-hsl hsl-to))))
+ (use-color-picker-css-variables! node-ref current-color)
;; Updates color when pixel picker is used
(mf/with-effect [picking-color? picked-color picked-color-select]
diff --git a/frontend/src/app/main/ui/workspace/sidebar.cljs b/frontend/src/app/main/ui/workspace/sidebar.cljs
index 74fa94f8c..6886108a0 100644
--- a/frontend/src/app/main/ui/workspace/sidebar.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar.cljs
@@ -27,6 +27,7 @@
[app.main.ui.workspace.sidebar.shortcuts :refer [shortcuts-container]]
[app.main.ui.workspace.sidebar.sitemap :refer [sitemap]]
[app.main.ui.workspace.sidebar.versions :refer [versions-toolbox]]
+ [app.main.ui.workspace.tokens.sidebar :refer [tokens-sidebar-tab]]
[app.util.debug :as dbg]
[app.util.i18n :refer [tr]]
[rumext.v2 :as mf]))
@@ -52,8 +53,11 @@
mode-inspect? (= options-mode :inspect)
project (mf/deref refs/workspace-project)
+ design-tokens? (mf/use-ctx muc/design-tokens)
+
section (cond (or mode-inspect? (contains? layout :layers)) :layers
- (contains? layout :assets) :assets)
+ (contains? layout :assets) :assets
+ (contains? layout :tokens) :tokens)
shortcuts? (contains? layout :shortcuts)
show-debug? (contains? layout :debug-panel)
@@ -97,17 +101,31 @@
assets-tab
(mf/html [:& assets-toolbox {:size (- size 58)}])
+ tokens-tab
+ (when design-tokens?
+ (mf/html [:& tokens-sidebar-tab]))
+
tabs
(if ^boolean mode-inspect?
#js [#js {:label (tr "workspace.sidebar.layers")
:id "layers"
:content layers-tab}]
- #js [#js {:label (tr "workspace.sidebar.layers")
- :id "layers"
- :content layers-tab}
- #js {:label (tr "workspace.toolbar.assets")
- :id "assets"
- :content assets-tab}])]
+ (if ^boolean design-tokens?
+ #js [#js {:label (tr "workspace.sidebar.layers")
+ :id "layers"
+ :content layers-tab}
+ #js {:label (tr "workspace.toolbar.assets")
+ :id "assets"
+ :content assets-tab}
+ #js {:label "Tokens"
+ :id "tokens"
+ :content tokens-tab}]
+ #js [#js {:label (tr "workspace.sidebar.layers")
+ :id "layers"
+ :content layers-tab}
+ #js {:label (tr "workspace.toolbar.assets")
+ :id "assets"
+ :content assets-tab}]))]
[:& (mf/provider muc/sidebar) {:value :left}
[:aside {:ref parent-ref
diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs
index 19f1d746f..578d08af2 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs
@@ -130,7 +130,7 @@
(mf/defc asset-section
{::mf/wrap-props false}
- [{:keys [children file-id title section assets-count open?]}]
+ [{:keys [children file-id title section assets-count icon open?]}]
(let [children (-> (array/normalize-to-array children)
(array/without-nils))
@@ -151,7 +151,7 @@
(mf/html
[:span {:class (stl/css :title-name)}
[:span {:class (stl/css :section-icon)}
- [:& section-icon {:section section}]]
+ [:& (or icon section-icon) {:section section}]]
[:span {:class (stl/css :section-name)}
title]
diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs
index 3bc12106a..ced7f3523 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs
@@ -69,11 +69,11 @@
(defn group-assets
"Convert a list of assets in a nested structure like this:
- {'': [{assetA} {assetB}]
- 'group1': {'': [{asset1A} {asset1B}]
- 'subgroup11': {'': [{asset11A} {asset11B} {asset11C}]}
- 'subgroup12': {'': [{asset12A}]}}
- 'group2': {'subgroup21': {'': [{asset21A}}}}
+ {'': [assetA assetB]
+ 'group1': {'': [asset1A asset1B]
+ 'subgroup11': {'': [asset11A asset11B asset11C]}
+ 'subgroup12': {'': [asset12A]}}
+ 'group2': {'subgroup21': {'': [asset21A]}}}
"
[assets reverse-sort?]
(when-not (empty? assets)
diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
index ffec58e41..df5516f88 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
@@ -12,7 +12,9 @@
[app.common.logic.shapes :as cls]
[app.common.types.shape.layout :as ctl]
[app.common.types.shape.radius :as ctsr]
+ [app.common.types.tokens-lib :as ctob]
[app.main.constants :refer [size-presets]]
+ [app.main.data.tokens :as dt]
[app.main.data.workspace :as udw]
[app.main.data.workspace.interactions :as dwi]
[app.main.data.workspace.shapes :as dwsh]
@@ -22,8 +24,13 @@
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.components.numeric-input :refer [numeric-input*]]
[app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]]
+ [app.main.ui.context :as muc]
[app.main.ui.hooks :as hooks]
[app.main.ui.icons :as i]
+ [app.main.ui.workspace.tokens.core :as wtc]
+ [app.main.ui.workspace.tokens.editable-select :refer [editable-select]]
+ [app.main.ui.workspace.tokens.style-dictionary :as sd]
+ [app.main.ui.workspace.tokens.token-types :as wtty]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[clojure.set :refer [rename-keys union]]
@@ -84,6 +91,8 @@
(reduce #(union %1 %2) (map #(get type->options %) all-types))
(get type->options type))
+ design-tokens? (mf/use-ctx muc/design-tokens)
+
ids-with-children (or ids-with-children ids)
old-shapes (if (= type :multiple)
@@ -96,6 +105,34 @@
selection-parents-ref (mf/use-memo (mf/deps ids) #(refs/parents-by-ids ids))
selection-parents (mf/deref selection-parents-ref)
+ tokens (sd/use-active-theme-sets-tokens)
+ tokens-by-type (mf/use-memo
+ (mf/deps tokens)
+ #(ctob/group-by-type tokens))
+
+ border-radius-tokens (:border-radius tokens-by-type)
+ border-radius-options (mf/use-memo
+ (mf/deps shape border-radius-tokens)
+ #(wtc/tokens->select-options
+ {:shape shape
+ :tokens border-radius-tokens
+ :attributes (wtty/token-attributes :border-radius)}))
+ sizing-tokens (:sizing tokens-by-type)
+ width-options (mf/use-memo
+ (mf/deps shape sizing-tokens)
+ #(wtc/tokens->select-options
+ {:shape shape
+ :tokens sizing-tokens
+ :attributes (wtty/token-attributes :sizing)
+ :selected-attributes #{:width}}))
+ height-options (mf/use-memo
+ (mf/deps shape sizing-tokens)
+ #(wtc/tokens->select-options
+ {:shape shape
+ :tokens sizing-tokens
+ :attributes (wtty/token-attributes :sizing)
+ :selected-attributes #{:height}}))
+
flex-child? (->> selection-parents (some ctl/flex-layout?))
absolute? (ctl/item-absolute? shape)
flex-container? (ctl/flex-layout? shape)
@@ -209,8 +246,21 @@
(mf/use-fn
(mf/deps ids)
(fn [value attr]
- (st/emit! (udw/trigger-bounding-box-cloaking ids)
- (udw/update-dimensions ids attr value))))
+ (let [token-value (wtc/maybe-resolve-token-value value)
+ undo-id (js/Symbol)]
+ (if-not design-tokens?
+ (st/emit! (udw/trigger-bounding-box-cloaking ids)
+ (udw/update-dimensions ids attr (or token-value value)))
+ (st/emit! (udw/trigger-bounding-box-cloaking ids)
+ (dwu/start-undo-transaction undo-id)
+ (dwsh/update-shapes ids
+ (if token-value
+ #(assoc-in % [:applied-tokens attr] (:id value))
+ #(d/dissoc-in % [:applied-tokens attr]))
+ {:reg-objects? true
+ :attrs [:applied-tokens]})
+ (udw/update-dimensions ids attr (or token-value value))
+ (dwu/commit-undo-transaction undo-id))))))
on-proportion-lock-change
(mf/use-fn
@@ -256,7 +306,7 @@
(update-fn shape)
shape))
{:reg-objects? true
- :attrs [:rx :ry :r1 :r2 :r3 :r4]})))
+ :attrs [:rx :ry :r1 :r2 :r3 :r4 :applied-tokens]})))
on-switch-to-radius-1
(mf/use-fn
@@ -281,11 +331,27 @@
(on-switch-to-radius-4)
(on-switch-to-radius-1))))
+ on-border-radius-token-unapply
+ (mf/use-fn
+ (mf/deps ids change-radius)
+ (fn [token]
+ (let [token-value (wtc/maybe-resolve-token-value token)]
+ (st/emit!
+ (change-radius (fn [shape]
+ (-> (dt/unapply-token-id shape (wtty/token-attributes :border-radius))
+ (ctsr/set-radius-1 token-value))))))))
+
on-radius-1-change
(mf/use-fn
(mf/deps ids change-radius)
(fn [value]
- (st/emit! (change-radius #(ctsr/set-radius-1 % value)))))
+ (let [token-value (wtc/maybe-resolve-token-value value)]
+ (st/emit!
+ (change-radius (fn [shape]
+ (-> (dt/maybe-apply-token-to-shape {:token (when token-value value)
+ :shape shape
+ :attributes (wtty/token-attributes :border-radius)})
+ (ctsr/set-radius-1 (or token-value value)))))))))
on-radius-multi-change
(mf/use-fn
@@ -394,24 +460,50 @@
:disabled disabled-width-sizing?)
:title (tr "workspace.options.width")}
[:span {:class (stl/css :icon-text)} "W"]
- [:> numeric-input* {:min 0.01
- :no-validate true
- :placeholder (if (= :multiple (:width values)) (tr "settings.multiple") "--")
- :on-change on-width-change
- :disabled disabled-width-sizing?
- :class (stl/css :numeric-input)
- :value (:width values)}]]
+ (if-not design-tokens?
+ [:> numeric-input* {:min 0.01
+ :no-validate true
+ :placeholder (if (= :multiple (:width values)) (tr "settings.multiple") "--")
+ :on-change on-width-change
+ :disabled disabled-width-sizing?
+ :class (stl/css :numeric-input)
+ :value (:width values)}]
+ [:& editable-select
+ {:placeholder (if (= :multiple (:rx values)) (tr "settings.multiple") "--")
+ :class (stl/css :token-select)
+ :disabled disabled-width-sizing?
+ :on-change on-width-change
+ :on-token-remove #(on-width-change (wtc/maybe-resolve-token-value %))
+ :options width-options
+ :position :left
+ :value (:width values)
+ :input-props {:type "number"
+ :no-validate true
+ :min 0.01}}])]
[:div {:class (stl/css-case :height true
:disabled disabled-height-sizing?)
:title (tr "workspace.options.height")}
[:span {:class (stl/css :icon-text)} "H"]
- [:> numeric-input* {:min 0.01
- :no-validate true
- :placeholder (if (= :multiple (:height values)) (tr "settings.multiple") "--")
- :on-change on-height-change
- :disabled disabled-height-sizing?
- :class (stl/css :numeric-input)
- :value (:height values)}]]
+ (if-not design-tokens?
+ [:> numeric-input* {:min 0.01
+ :no-validate true
+ :placeholder (if (= :multiple (:height values)) (tr "settings.multiple") "--")
+ :on-change on-height-change
+ :disabled disabled-height-sizing?
+ :class (stl/css :numeric-input)
+ :value (:height values)}]
+ [:& editable-select
+ {:placeholder (if (= :multiple (:rx values)) (tr "settings.multiple") "--")
+ :class (stl/css :token-select)
+ :disabled disabled-height-sizing?
+ :on-change on-height-change
+ :on-token-remove #(on-height-change (wtc/maybe-resolve-token-value %))
+ :options height-options
+ :position :right
+ :value (:height values)
+ :input-props {:type "number"
+ :no-validate true
+ :min 0.01}}])]
[:button {:class (stl/css-case
:lock-size-btn true
:selected (true? proportion-lock)
@@ -468,13 +560,24 @@
[:div {:class (stl/css :radius-1)
:title (tr "workspace.options.radius")}
[:span {:class (stl/css :icon)} i/corner-radius]
- [:> numeric-input*
- {:placeholder (if (= :multiple (:rx values)) (tr "settings.multiple") "--")
- :ref radius-input-ref
- :min 0
- :on-change on-radius-1-change
- :class (stl/css :numeric-input)
- :value (:rx values)}]]
+ (if-not design-tokens?
+ [:> numeric-input*
+ {:placeholder (if (= :multiple (:rx values)) (tr "settings.multiple") "--")
+ :ref radius-input-ref
+ :min 0
+ :on-change on-radius-1-change
+ :class (stl/css :numeric-input)
+ :value (:rx values)}]
+ [:& editable-select
+ {:placeholder (if (= :multiple (:rx values)) (tr "settings.multiple") "--")
+ :class (stl/css :token-select)
+ :on-change on-radius-1-change
+ :on-token-remove on-border-radius-token-unapply
+ :options border-radius-options
+ :position :right
+ :value (:rx values)
+ :input-props {:type "number"
+ :min 0}}])]
@radius-multi?
[:div {:class (stl/css :radius-1)
diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.scss
index 7f265e3ef..3294781c0 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.scss
+++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.scss
@@ -109,6 +109,7 @@
.size {
@include flexRow;
+ position: relative;
}
.height,
@@ -186,6 +187,7 @@
@extend .input-element;
@include bodySmallTypography;
width: $s-108;
+ position: relative;
}
.radius-4 {
diff --git a/frontend/src/app/main/ui/workspace/tokens/changes.cljs b/frontend/src/app/main/ui/workspace/tokens/changes.cljs
new file mode 100644
index 000000000..320dca948
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/changes.cljs
@@ -0,0 +1,183 @@
+;; 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.workspace.tokens.changes
+ (:require
+ [app.common.types.shape.radius :as ctsr]
+ [app.common.types.token :as ctt]
+ [app.common.types.tokens-lib :as ctob]
+ [app.main.data.workspace :as udw]
+ [app.main.data.workspace.colors :as wdc]
+ [app.main.data.workspace.shape-layout :as dwsl]
+ [app.main.data.workspace.shapes :as dwsh]
+ [app.main.data.workspace.state-helpers :as wsh]
+ [app.main.data.workspace.transforms :as dwt]
+ [app.main.data.workspace.undo :as dwu]
+ [app.main.ui.workspace.tokens.style-dictionary :as sd]
+ [app.main.ui.workspace.tokens.tinycolor :as tinycolor]
+ [app.main.ui.workspace.tokens.token :as wtt]
+ [beicon.v2.core :as rx]
+ [clojure.set :as set]
+ [potok.v2.core :as ptk]))
+
+;; Token Updates ---------------------------------------------------------------
+
+(defn apply-token
+ "Apply `attributes` that match `token` for `shape-ids`.
+
+ Optionally remove attributes from `attributes-to-remove`,
+ this is useful for applying a single attribute from an attributes set
+ while removing other applied tokens from this set."
+ [{:keys [attributes attributes-to-remove token shape-ids on-update-shape] :as _props}]
+ (ptk/reify ::apply-token
+ ptk/WatchEvent
+ (watch [_ state _]
+ (when-let [tokens (some-> (get-in state [:workspace-data :tokens-lib])
+ (ctob/get-active-themes-set-tokens))]
+ (->> (rx/from (sd/resolve-tokens+ tokens))
+ (rx/mapcat
+ (fn [resolved-tokens]
+ (let [undo-id (js/Symbol)
+ resolved-value (get-in resolved-tokens [(wtt/token-identifier token) :resolved-value])
+ tokenized-attributes (wtt/attributes-map attributes token)]
+ (rx/of
+ (dwu/start-undo-transaction undo-id)
+ (dwsh/update-shapes shape-ids (fn [shape]
+ (cond-> shape
+ attributes-to-remove (update :applied-tokens #(apply (partial dissoc %) attributes-to-remove))
+ :always (update :applied-tokens merge tokenized-attributes))))
+ (when on-update-shape
+ (on-update-shape resolved-value shape-ids attributes))
+ (dwu/commit-undo-transaction undo-id))))))))))
+
+(defn unapply-token
+ "Removes `attributes` that match `token` for `shape-ids`.
+
+ Doesn't update shape attributes."
+ [{:keys [attributes token shape-ids] :as _props}]
+ (ptk/reify ::unapply-token
+ ptk/WatchEvent
+ (watch [_ _ _]
+ (rx/of
+ (let [remove-token #(when % (wtt/remove-attributes-for-token attributes token %))]
+ (dwsh/update-shapes
+ shape-ids
+ (fn [shape]
+ (update shape :applied-tokens remove-token))))))))
+
+(defn toggle-token
+ [{:keys [token-type-props token shapes] :as _props}]
+ (ptk/reify ::on-toggle-token
+ ptk/WatchEvent
+ (watch [_ _ _]
+ (let [{:keys [attributes all-attributes on-update-shape]} token-type-props
+ unapply-tokens? (wtt/shapes-token-applied? token shapes (or all-attributes attributes))
+ shape-ids (map :id shapes)]
+ (if unapply-tokens?
+ (rx/of
+ (unapply-token {:attributes (or all-attributes attributes)
+ :token token
+ :shape-ids shape-ids}))
+ (rx/of
+ (apply-token {:attributes attributes
+ :token token
+ :shape-ids shape-ids
+ :on-update-shape on-update-shape})))))))
+
+;; Shape Updates ---------------------------------------------------------------
+
+(defn update-shape-radius-all [value shape-ids]
+ (dwsh/update-shapes shape-ids
+ (fn [shape]
+ (when (ctsr/has-radius? shape)
+ (ctsr/set-radius-1 shape value)))
+ {:reg-objects? true
+ :attrs ctt/border-radius-keys}))
+
+(defn update-opacity [value shape-ids]
+ (when (<= 0 value 1)
+ (dwsh/update-shapes shape-ids #(assoc % :opacity value))))
+
+(defn update-rotation [value shape-ids]
+ (ptk/reify ::update-shape-rotation
+ ptk/WatchEvent
+ (watch [_ _ _]
+ (rx/of
+ (udw/trigger-bounding-box-cloaking shape-ids)
+ (udw/increase-rotation shape-ids value)))))
+
+(defn update-shape-radius-single-corner [value shape-ids attributes]
+ (dwsh/update-shapes shape-ids
+ (fn [shape]
+ (when (ctsr/has-radius? shape)
+ (cond-> shape
+ (:rx shape) (ctsr/switch-to-radius-4)
+ :always (ctsr/set-radius-4 (first attributes) value))))
+ {:reg-objects? true
+ :attrs [:rx :ry :r1 :r2 :r3 :r4]}))
+
+(defn update-stroke-width
+ [value shape-ids]
+ (dwsh/update-shapes shape-ids
+ (fn [shape]
+ (when (seq (:strokes shape))
+ (assoc-in shape [:strokes 0 :stroke-width] value)))
+ {:reg-objects? true
+ :attrs [:strokes]}))
+
+(defn update-color
+ [value shape-ids]
+ (let [color (some->> value
+ (tinycolor/valid-color)
+ (tinycolor/->hex)
+ (str "#"))]
+ (wdc/change-fill shape-ids {:color color} 0)))
+
+(defn update-shape-dimensions [value shape-ids attributes]
+ (ptk/reify ::update-shape-dimensions
+ ptk/WatchEvent
+ (watch [_ _ _]
+ (rx/of
+ (when (:width attributes) (dwt/update-dimensions shape-ids :width value))
+ (when (:height attributes) (dwt/update-dimensions shape-ids :height value))))))
+
+(defn- attributes->layout-gap [attributes value]
+ (let [layout-gap (-> (set/intersection attributes #{:column-gap :row-gap})
+ (zipmap (repeat value)))]
+ {:layout-gap layout-gap}))
+
+(defn update-layout-padding [value shape-ids attrs]
+ (dwsl/update-layout shape-ids {:layout-padding (zipmap attrs (repeat value))}))
+
+(defn update-layout-spacing [value shape-ids attributes]
+ (ptk/reify ::update-layout-spacing
+ ptk/WatchEvent
+ (watch [_ state _]
+ (let [layout-shape-ids (->> (wsh/lookup-shapes state shape-ids)
+ (eduction
+ (filter :layout)
+ (map :id)))
+ layout-attributes (attributes->layout-gap attributes value)]
+ (rx/of
+ (dwsl/update-layout layout-shape-ids layout-attributes))))))
+
+(defn update-shape-position [value shape-ids attributes]
+ (ptk/reify ::update-shape-position
+ ptk/WatchEvent
+ (watch [_ _ _]
+ (rx/concat
+ (map #(dwt/update-position % (zipmap attributes (repeat value))) shape-ids)))))
+
+(defn update-layout-sizing-limits [value shape-ids attributes]
+ (ptk/reify ::update-layout-sizing-limits
+ ptk/WatchEvent
+ (watch [_ _ _]
+ (let [props (-> {:layout-item-min-w value
+ :layout-item-min-h value
+ :layout-item-max-w value
+ :layout-item-max-h value}
+ (select-keys attributes))]
+ (dwsl/update-layout-child shape-ids props)))))
diff --git a/frontend/src/app/main/ui/workspace/tokens/common.cljs b/frontend/src/app/main/ui/workspace/tokens/common.cljs
new file mode 100644
index 000000000..e81c93999
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/common.cljs
@@ -0,0 +1,131 @@
+;; 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.workspace.tokens.common
+ (:require-macros [app.main.style :as stl])
+ (:require
+ [app.common.data :as d]
+ [app.common.data.macros :as dm]
+ [app.main.data.shortcuts :as dsc]
+ [app.main.store :as st]
+ [app.main.ui.components.dropdown :refer [dropdown]]
+ [app.main.ui.icons :as i]
+ [app.util.dom :as dom]
+ [app.util.globals :as globals]
+ [app.util.keyboard :as kbd]
+ [cuerdas.core :as str]
+ [goog.events :as events]
+ [rumext.v2 :as mf])
+ (:import goog.events.EventType))
+
+;; Helpers ---------------------------------------------------------------------
+
+(defn camel-keys [m]
+ (->> m
+ (d/deep-mapm
+ (fn [[k v]]
+ (if (or (keyword? k) (string? k))
+ [(keyword (str/camel (name k))) v]
+ [k v])))))
+
+(defn direction-select
+ "Returns next `n` in `direction` while wrapping around at the last item at the count of `coll`.
+
+ `direction` accepts `:up` or `:down`."
+ [direction n coll]
+ (let [last-n (dec (count coll))
+ next-n (case direction
+ :up (dec n)
+ :down (inc n))
+ wrap-around-n (cond
+ (neg? next-n) last-n
+ (> next-n last-n) 0
+ :else next-n)]
+ wrap-around-n))
+
+(defn use-arrow-highlight [{:keys [shortcuts-key options on-select]}]
+ (let [highlighted* (mf/use-state nil)
+ highlighted (deref highlighted*)
+ on-dehighlight #(reset! highlighted* nil)
+ on-keyup (fn [event]
+ (cond
+ (and (kbd/enter? event) highlighted) (on-select (nth options highlighted))
+ (kbd/up-arrow? event) (do
+ (dom/prevent-default event)
+ (->> (direction-select :up (or highlighted 0) options)
+ (reset! highlighted*)))
+ (kbd/down-arrow? event) (do
+ (dom/prevent-default event)
+ (->> (direction-select :down (or highlighted -1) options)
+ (reset! highlighted*)))))]
+ (mf/with-effect [highlighted]
+ (let [shortcuts-key shortcuts-key
+ keys [(events/listen globals/document EventType.KEYUP on-keyup)
+ (events/listen globals/document EventType.KEYDOWN dom/prevent-default)]]
+ (st/emit! (dsc/push-shortcuts shortcuts-key {}))
+ (fn []
+ (doseq [key keys]
+ (events/unlistenByKey key))
+ (st/emit! (dsc/pop-shortcuts shortcuts-key)))))
+ {:highlighted highlighted
+ :on-dehighlight on-dehighlight}))
+
+(defn use-dropdown-open-state []
+ (let [open? (mf/use-state false)
+ on-open (mf/use-fn #(reset! open? true))
+ on-close (mf/use-fn #(reset! open? false))
+ on-toggle (mf/use-fn #(swap! open? not))]
+ {:dropdown-open? @open?
+ :on-open-dropdown on-open
+ :on-close-dropdown on-close
+ :on-toggle-dropdown on-toggle}))
+
+;; Components ------------------------------------------------------------------
+
+(mf/defc dropdown-select
+ [{:keys [id _shortcuts-key options on-close element-ref on-select] :as props}]
+ (let [{:keys [highlighted on-dehighlight]} (use-arrow-highlight props)]
+ [:& dropdown {:show true
+ :on-close on-close}
+ [:> :div {:class (stl/css :dropdown)
+ :on-mouse-enter on-dehighlight
+ :ref element-ref}
+ [:ul {:class (stl/css :dropdown-list)}
+ (for [[index item] (d/enumerate options)]
+ (cond
+ (= :separator item)
+ [:li {:class (stl/css :separator)
+ :key (dm/str id "-" index)}]
+ :else
+ (let [{:keys [label selected? disabled?]} item
+ highlighted? (= highlighted index)]
+ [:li
+ {:key (str id "-" index)
+ :class (stl/css-case :dropdown-element true
+ :is-selected selected?
+ :is-highlighted highlighted?)
+ :data-label label
+ :disabled disabled?
+ :on-click #(on-select item)}
+ [:span {:class (stl/css :label)} label]
+ [:span {:class (stl/css :check-icon)} i/tick]])))]]]))
+
+(mf/defc labeled-input
+ {::mf/wrap-props false}
+ [{:keys [label input-props auto-complete? error? render-right]}]
+ (let [input-props (cond-> input-props
+ :always camel-keys
+ ;; Disable auto-complete on form fields for proprietary password managers
+ ;; https://github.com/orgs/tokens-studio/projects/69/views/11?pane=issue&itemId=63724204
+ (not auto-complete?) (assoc "data-1p-ignore" true
+ "data-lpignore" true
+ :auto-complete "off"))]
+ [:label {:class (stl/css-case :labeled-input true
+ :labeled-input-error error?)}
+ [:span {:class (stl/css :label)} label]
+ [:& :input input-props]
+ (when render-right
+ [:& render-right])]))
diff --git a/frontend/src/app/main/ui/workspace/tokens/common.scss b/frontend/src/app/main/ui/workspace/tokens/common.scss
new file mode 100644
index 000000000..bb067e683
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/common.scss
@@ -0,0 +1,115 @@
+// 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
+
+@import "refactor/common-refactor.scss";
+
+.input {
+ @extend .input-element;
+}
+
+.labeled-input {
+ @extend .input-element;
+ .label {
+ width: auto;
+ text-wrap: nowrap;
+ }
+}
+
+.labeled-input-error {
+ border: 1px solid var(--status-color-error-500) !important;
+}
+
+.button {
+ @extend .button-primary;
+}
+
+.action-button {
+ @extend .button-tertiary;
+ height: $s-32;
+ width: $s-28;
+ svg {
+ @extend .button-icon;
+ }
+}
+
+.dropdown {
+ @extend .dropdown-wrapper;
+ max-height: $s-320;
+ width: 100%;
+ margin-top: $s-4;
+
+ ul {
+ margin: 0;
+ }
+
+ .separator {
+ margin: 0;
+ height: $s-12;
+ }
+
+ .dropdown-element {
+ @extend .dropdown-element-base;
+ color: var(--menu-foreground-color-rest);
+ display: flex;
+
+ & > span {
+ display: flex;
+ justify-content: flex-start;
+ align-content: center;
+ }
+
+ .label,
+ .value {
+ width: fit-content;
+ }
+
+ .label {
+ text-transform: unset;
+ flex: 1;
+ }
+
+ .value {
+ text-align: right;
+ justify-content: flex-end;
+ flex: 0.6;
+ }
+
+ .check-icon {
+ @include flexCenter;
+ translate: -$s-4 0;
+ svg {
+ @extend .button-icon-small;
+ visibility: hidden;
+ stroke: var(--icon-foreground);
+ }
+ }
+
+ &.is-selected {
+ color: var(--menu-foreground-color);
+ .check-icon svg {
+ stroke: var(--menu-foreground-color);
+ visibility: visible;
+ }
+ }
+
+ &:hover {
+ background-color: var(--menu-background-color-hover);
+ color: var(--menu-foreground-color-hover);
+ .check-icon svg {
+ stroke: var(--menu-foreground-color-hover);
+ }
+ }
+ &.is-highlighted {
+ background-color: var(--button-primary-background-color-rest);
+ span {
+ color: var(--button-primary-foreground-color-rest);
+ }
+ .check-icon svg {
+ stroke: var(--button-primary-foreground-color-rest);
+ }
+ }
+ }
+}
diff --git a/frontend/src/app/main/ui/workspace/tokens/context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/context_menu.cljs
new file mode 100644
index 000000000..274ad90a5
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/context_menu.cljs
@@ -0,0 +1,336 @@
+;; 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.workspace.tokens.context-menu
+ (:require-macros [app.main.style :as stl])
+ (:require
+ [app.common.data :as d]
+ [app.main.data.modal :as modal]
+ [app.main.data.tokens :as dt]
+ [app.main.refs :as refs]
+ [app.main.store :as st]
+ [app.main.ui.components.dropdown :refer [dropdown]]
+ [app.main.ui.icons :as i]
+ [app.main.ui.workspace.tokens.changes :as wtch]
+ [app.main.ui.workspace.tokens.token :as wtt]
+ [app.main.ui.workspace.tokens.token-types :as wtty]
+ [app.util.dom :as dom]
+ [app.util.timers :as timers]
+ [okulary.core :as l]
+ [rumext.v2 :as mf]))
+
+;; Actions ---------------------------------------------------------------------
+
+(defn attribute-actions [token selected-shapes attributes]
+ (let [ids-by-attributes (wtt/shapes-ids-by-applied-attributes token selected-shapes attributes)
+ shape-ids (into #{} (map :id selected-shapes))]
+ {:all-selected? (wtt/shapes-applied-all? ids-by-attributes shape-ids attributes)
+ :shape-ids shape-ids
+ :selected-pred #(seq (% ids-by-attributes))}))
+
+(defn generic-attribute-actions [attributes title {:keys [token selected-shapes on-update-shape]}]
+ (let [on-update-shape-fn (or on-update-shape
+ (-> (wtty/get-token-properties token)
+ (:on-update-shape)))
+ {:keys [selected-pred shape-ids]} (attribute-actions token selected-shapes attributes)]
+ (map (fn [attribute]
+ (let [selected? (selected-pred attribute)
+ props {:attributes #{attribute}
+ :token token
+ :shape-ids shape-ids}]
+
+ {:title title
+ :selected? selected?
+ :action (fn []
+ (if selected?
+ (st/emit! (wtch/unapply-token props))
+ (st/emit! (wtch/apply-token (assoc props :on-update-shape on-update-shape-fn)))))}))
+ attributes)))
+
+(defn all-or-sepearate-actions [{:keys [attribute-labels on-update-shape-all on-update-shape]}
+ {:keys [token selected-shapes]}]
+ (let [attributes (set (keys attribute-labels))
+ {:keys [all-selected? selected-pred shape-ids]} (attribute-actions token selected-shapes attributes)
+ all-action (let [props {:attributes attributes
+ :token token
+ :shape-ids shape-ids}]
+ {:title "All"
+ :selected? all-selected?
+ :action #(if all-selected?
+ (st/emit! (wtch/unapply-token props))
+ (st/emit! (wtch/apply-token (assoc props :on-update-shape (or on-update-shape-all on-update-shape)))))})
+ single-actions (map (fn [[attr title]]
+ (let [selected? (selected-pred attr)]
+ {:title title
+ :selected? (and (not all-selected?) selected?)
+ :action #(let [props {:attributes #{attr}
+ :token token
+ :shape-ids shape-ids}
+ event (cond
+ all-selected? (-> (assoc props :attributes-to-remove attributes)
+ (wtch/apply-token))
+ selected? (wtch/unapply-token props)
+ :else (-> (assoc props :on-update-shape on-update-shape)
+ (wtch/apply-token)))]
+ (st/emit! event))}))
+ attribute-labels)]
+ (concat [all-action] single-actions)))
+
+(defn spacing-attribute-actions [{:keys [token selected-shapes] :as context-data}]
+ (let [on-update-shape-padding wtch/update-layout-padding
+ padding-attrs {:p1 "Top"
+ :p2 "Right"
+ :p3 "Bottom"
+ :p4 "Left"}
+ all-padding-attrs (into #{} (keys padding-attrs))
+ {:keys [all-selected? selected-pred shape-ids]} (attribute-actions token selected-shapes all-padding-attrs)
+ horizontal-attributes #{:p1 :p3}
+ horizontal-padding-selected? (and
+ (not all-selected?)
+ (every? selected-pred horizontal-attributes))
+ vertical-attributes #{:p2 :p4}
+ vertical-padding-selected? (and
+ (not all-selected?)
+ (every? selected-pred vertical-attributes))
+ padding-items [{:title "All"
+ :selected? all-selected?
+ :action (fn []
+ (let [props {:attributes all-padding-attrs
+ :token token
+ :shape-ids shape-ids}]
+ (if all-selected?
+ (st/emit! (wtch/unapply-token props))
+ (st/emit! (wtch/apply-token (assoc props :on-update-shape on-update-shape-padding))))))}
+ {:title "Horizontal"
+ :selected? horizontal-padding-selected?
+ :action (fn []
+ (let [props {:token token
+ :shape-ids shape-ids}
+ event (cond
+ all-selected? (wtch/apply-token (assoc props :attributes-to-remove vertical-attributes))
+ horizontal-padding-selected? (wtch/apply-token (assoc props :attributes-to-remove horizontal-attributes))
+ :else (wtch/apply-token (assoc props
+ :attributes horizontal-attributes
+ :on-update-shape on-update-shape-padding)))]
+ (st/emit! event)))}
+ {:title "Vertical"
+ :selected? vertical-padding-selected?
+ :action (fn []
+ (let [props {:token token
+ :shape-ids shape-ids}
+ event (cond
+ all-selected? (wtch/apply-token (assoc props :attributes-to-remove vertical-attributes))
+ vertical-padding-selected? (wtch/apply-token (assoc props :attributes-to-remove vertical-attributes))
+ :else (wtch/apply-token (assoc props
+ :attributes vertical-attributes
+ :on-update-shape on-update-shape-padding)))]
+ (st/emit! event)))}]
+ single-padding-items (->> padding-attrs
+ (map (fn [[attr title]]
+ (let [same-axis-selected? (cond
+ (get horizontal-attributes attr) horizontal-padding-selected?
+ (get vertical-attributes attr) vertical-padding-selected?
+ :else true)
+ selected? (and
+ (not all-selected?)
+ (not same-axis-selected?)
+ (selected-pred attr))]
+ {:title title
+ :selected? selected?
+ :action #(let [props {:attributes #{attr}
+ :token token
+ :shape-ids shape-ids}
+ event (cond
+ all-selected? (-> (assoc props :attributes-to-remove all-padding-attrs)
+ (wtch/apply-token))
+ selected? (wtch/unapply-token props)
+ :else (-> (assoc props :on-update-shape on-update-shape-padding)
+ (wtch/apply-token)))]
+ (st/emit! event))}))))
+ gap-items (all-or-sepearate-actions {:attribute-labels {:column-gap "Column Gap"
+ :row-gap "Row Gap"}
+ :on-update-shape wtch/update-layout-spacing}
+ context-data)]
+ (concat padding-items
+ single-padding-items
+ [:separator]
+ gap-items)))
+
+(defn sizing-attribute-actions [context-data]
+ (concat
+ (all-or-sepearate-actions {:attribute-labels {:width "Width"
+ :height "Height"}
+ :on-update-shape wtch/update-shape-dimensions}
+ context-data)
+ [:separator]
+ (all-or-sepearate-actions {:attribute-labels {:layout-item-min-w "Min Width"
+ :layout-item-min-h "Min Height"}
+ :on-update-shape wtch/update-layout-sizing-limits}
+ context-data)
+ [:separator]
+ (all-or-sepearate-actions {:attribute-labels {:layout-item-max-w "Max Width"
+ :layout-item-max-h "Max Height"}
+ :on-update-shape wtch/update-layout-sizing-limits}
+ context-data)))
+
+(def shape-attribute-actions-map
+ (let [stroke-width (partial generic-attribute-actions #{:stroke-width} "Stroke Width")]
+ {:border-radius (partial all-or-sepearate-actions {:attribute-labels {:r1 "Top Left"
+ :r2 "Top Right"
+ :r4 "Bottom Left"
+ :r3 "Bottom Right"}
+ :on-update-shape-all wtch/update-shape-radius-all
+ :on-update-shape wtch/update-shape-radius-single-corner})
+ :spacing spacing-attribute-actions
+ :sizing sizing-attribute-actions
+ :rotation (partial generic-attribute-actions #{:rotation} "Rotation")
+ :opacity (partial generic-attribute-actions #{:opacity} "Opacity")
+ :stroke-width stroke-width
+ :dimensions (fn [context-data]
+ (concat
+ [{:title "Spacing" :submenu :spacing}
+ {:title "Sizing" :submenu :sizing}
+ :separator
+ {:title "Border Radius" :submenu :border-radius}]
+ (stroke-width context-data)
+ [:separator]
+ (generic-attribute-actions #{:x} "X" (assoc context-data :on-update-shape wtch/update-shape-position))
+ (generic-attribute-actions #{:y} "Y" (assoc context-data :on-update-shape wtch/update-shape-position))))}))
+
+(defn default-actions [{:keys [token selected-token-set-id]}]
+ (let [{:keys [modal]} (wtty/get-token-properties token)]
+ [{:title "Delete Token"
+ :action #(st/emit! (dt/delete-token selected-token-set-id (:name token)))}
+ {:title "Duplicate Token"
+ :action #(st/emit! (dt/duplicate-token (:name token)))}
+ {:title "Edit Token"
+ :action (fn [event]
+ (let [{:keys [key fields]} modal]
+ (st/emit! dt/hide-token-context-menu)
+ (dom/stop-propagation event)
+ (modal/show! key {:x (.-clientX ^js event)
+ :y (.-clientY ^js event)
+ :position :right
+ :fields fields
+ :action "edit"
+ :selected-token-set-id selected-token-set-id
+ :token token})))}]))
+
+(defn selection-actions [{:keys [type token] :as context-data}]
+ (let [with-actions (get shape-attribute-actions-map (or type (:type token)))
+ attribute-actions (if with-actions (with-actions context-data) [])]
+ (concat
+ attribute-actions
+ (when (seq attribute-actions) [:separator])
+ (default-actions context-data))))
+
+;; Components ------------------------------------------------------------------
+
+(def tokens-menu-ref
+ (l/derived :token-context-menu refs/workspace-local))
+
+(defn- prevent-default
+ [event]
+ (dom/prevent-default event)
+ (dom/stop-propagation event))
+
+(mf/defc menu-entry
+ {::mf/props :obj}
+ [{:keys [title value on-click selected? children submenu-offset]}]
+ (let [submenu-ref (mf/use-ref nil)
+ hovering? (mf/use-ref false)
+ on-pointer-enter
+ (mf/use-callback
+ (fn []
+ (mf/set-ref-val! hovering? true)
+ (when-let [submenu-node (mf/ref-val submenu-ref)]
+ (dom/set-css-property! submenu-node "display" "block"))))
+ on-pointer-leave
+ (mf/use-callback
+ (fn []
+ (mf/set-ref-val! hovering? false)
+ (when-let [submenu-node (mf/ref-val submenu-ref)]
+ (timers/schedule 50 #(when-not (mf/ref-val hovering?)
+ (dom/set-css-property! submenu-node "display" "none"))))))
+ set-dom-node
+ (mf/use-callback
+ (fn [dom]
+ (let [submenu-node (mf/ref-val submenu-ref)]
+ (when (and (some? dom) (some? submenu-node))
+ (dom/set-css-property! submenu-node "top" (str (.-offsetTop dom) "px"))))))]
+ [:li
+ {:class (stl/css :context-menu-item)
+ :ref set-dom-node
+ :data-value value
+ :on-click on-click
+ :on-pointer-enter on-pointer-enter
+ :on-pointer-leave on-pointer-leave}
+ (when selected?
+ [:span {:class (stl/css :icon-wrapper)}
+ [:span {:class (stl/css :selected-icon)} i/tick]])
+ [:span {:class (stl/css :title)} title]
+ (when children
+ [:*
+ [:span {:class (stl/css :submenu-icon)} i/arrow]
+ [:ul {:class (stl/css :token-context-submenu)
+ :ref submenu-ref
+ :style {:display "none"
+ :top 0
+ :left (str submenu-offset "px")}
+ :on-context-menu prevent-default}
+ children]])]))
+
+(mf/defc menu-tree
+ [{:keys [selected-shapes] :as context-data}]
+ (let [entries (if (seq selected-shapes)
+ (selection-actions context-data)
+ (default-actions context-data))]
+ (for [[index {:keys [title action selected? submenu] :as entry}] (d/enumerate entries)]
+ [:* {:key (str title " " index)}
+ (cond
+ (= :separator entry) [:li {:class (stl/css :separator)}]
+ submenu [:& menu-entry {:title title
+ :submenu-offset (:submenu-offset context-data)}
+ [:& menu-tree (assoc context-data :type submenu)]]
+ :else [:& menu-entry
+ {:title title
+ :on-click action
+ :selected? selected?}])])))
+
+(mf/defc token-context-menu-tree
+ [{:keys [width] :as mdata}]
+ (let [objects (mf/deref refs/workspace-page-objects)
+ selected (mf/deref refs/selected-shapes)
+ selected-shapes (into [] (keep (d/getf objects)) selected)
+ token-name (:token-name mdata)
+ token (mf/deref (refs/workspace-selected-token-set-token token-name))
+ selected-token-set-id (mf/deref refs/workspace-selected-token-set-id)]
+ [:ul {:class (stl/css :context-list)}
+ [:& menu-tree {:submenu-offset width
+ :token token
+ :selected-token-set-id selected-token-set-id
+ :selected-shapes selected-shapes}]]))
+
+(mf/defc token-context-menu
+ []
+ (let [mdata (mf/deref tokens-menu-ref)
+ top (+ (get-in mdata [:position :y]) 5)
+ left (+ (get-in mdata [:position :x]) 5)
+ width (mf/use-state 0)
+ dropdown-ref (mf/use-ref)]
+ (mf/use-effect
+ (mf/deps mdata)
+ (fn []
+ (when-let [node (mf/ref-val dropdown-ref)]
+ (reset! width (.-offsetWidth node)))))
+ [:& dropdown {:show (boolean mdata)
+ :on-close #(st/emit! dt/hide-token-context-menu)}
+ [:div {:class (stl/css :token-context-menu)
+ :ref dropdown-ref
+ :style {:top top :left left}
+ :on-context-menu prevent-default}
+ (when mdata
+ [:& token-context-menu-tree (assoc mdata :offset @width)])]]))
diff --git a/frontend/src/app/main/ui/workspace/tokens/context_menu.scss b/frontend/src/app/main/ui/workspace/tokens/context_menu.scss
new file mode 100644
index 000000000..c1d6cc573
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/context_menu.scss
@@ -0,0 +1,103 @@
+// 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
+
+@import "refactor/common-refactor.scss";
+
+.token-context-menu {
+ position: absolute;
+ z-index: $z-index-4;
+}
+
+.context-list,
+.token-context-submenu {
+ @include menuShadow;
+ display: grid;
+ width: $s-240;
+ padding: $s-4;
+ border-radius: $br-8;
+ border: $s-2 solid var(--panel-border-color);
+ background-color: var(--menu-background-color);
+ max-height: 100vh;
+ overflow-y: auto;
+
+ li {
+ @include bodySmallTypography;
+ color: var(--menu-foreground-color);
+ }
+}
+
+.token-context-submenu {
+ position: absolute;
+ padding: $s-4;
+ margin-left: $s-6;
+}
+
+.separator {
+ @include bodySmallTypography;
+ margin: $s-6;
+ border-block-start: $s-1 solid var(--panel-border-color);
+}
+
+.context-menu-item {
+ display: flex;
+ align-items: center;
+ height: $s-28;
+ width: 100%;
+ padding: $s-6;
+ border-radius: $br-8;
+ cursor: pointer;
+
+ .title {
+ flex-grow: 1;
+ @include bodySmallTypography;
+ color: var(--menu-foreground-color);
+ margin-left: calc(($s-32 + $s-28) / 2);
+ }
+
+ .icon-wrapper {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .icon-wrapper + .title {
+ margin-left: $s-6;
+ }
+
+ .selected-icon {
+ svg {
+ @extend .button-icon-small;
+ stroke: var(--menu-foreground-color);
+ }
+ }
+
+ .submenu-icon {
+ margin-left: $s-2;
+ svg {
+ @extend .button-icon-small;
+ stroke: var(--menu-foreground-color);
+ }
+ }
+
+ &:hover {
+ background-color: var(--menu-background-color-hover);
+ .title {
+ color: var(--menu-foreground-color-hover);
+ }
+ .shortcut {
+ color: var(--menu-shortcut-foreground-color-hover);
+ }
+ }
+
+ &:focus {
+ border: 1px solid var(--menu-border-color-focus);
+ background-color: var(--menu-background-color-focus);
+ }
+
+ &[disabled] {
+ pointer-events: none;
+ opacity: 0.6;
+ }
+}
diff --git a/frontend/src/app/main/ui/workspace/tokens/core.cljs b/frontend/src/app/main/ui/workspace/tokens/core.cljs
new file mode 100644
index 000000000..c61cf0e40
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/core.cljs
@@ -0,0 +1,34 @@
+;; 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.workspace.tokens.core
+ (:require
+ [app.common.data :as d]
+ [app.main.ui.workspace.tokens.token :as wtt]))
+
+;; Helpers ---------------------------------------------------------------------
+
+(defn resolve-token-value [{:keys [value resolved-value] :as _token}]
+ (or
+ resolved-value
+ (d/parse-double value)))
+
+(defn maybe-resolve-token-value [{:keys [value] :as token}]
+ (when value (resolve-token-value token)))
+
+(defn tokens->select-options [{:keys [shape tokens attributes selected-attributes]}]
+ (map
+ (fn [{:keys [name] :as token}]
+ (cond-> (assoc token :label name)
+ (wtt/token-applied? token shape (or selected-attributes attributes)) (assoc :selected? true)))
+ tokens))
+
+(defn tokens-name-map->select-options [{:keys [shape tokens attributes selected-attributes]}]
+ (map
+ (fn [[_k {:keys [name] :as token}]]
+ (cond-> (assoc token :label name)
+ (wtt/token-applied? token shape (or selected-attributes attributes)) (assoc :selected? true)))
+ tokens))
diff --git a/frontend/src/app/main/ui/workspace/tokens/editable_select.cljs b/frontend/src/app/main/ui/workspace/tokens/editable_select.cljs
new file mode 100644
index 000000000..7ec0f9051
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/editable_select.cljs
@@ -0,0 +1,301 @@
+;; 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.workspace.tokens.editable-select
+ (:require-macros [app.main.style :as stl])
+ (:require
+ [app.common.data :as d]
+ [app.common.data.macros :as dm]
+ [app.common.math :as mth]
+ [app.common.uuid :as uuid]
+ [app.main.data.shortcuts :as dsc]
+ [app.main.store :as st]
+ [app.main.ui.components.dropdown :refer [dropdown]]
+ [app.main.ui.components.numeric-input :refer [numeric-input*]]
+ [app.main.ui.icons :as i]
+ [app.main.ui.workspace.tokens.core :as wtc]
+ [app.util.dom :as dom]
+ [app.util.globals :as globals]
+ [app.util.keyboard :as kbd]
+ [app.util.timers :as timers]
+ [cuerdas.core :as str]
+ [goog.events :as events]
+ [rumext.v2 :as mf])
+ (:import goog.events.EventType))
+
+(defn on-number-input-key-down [{:keys [event min-val max-val set-value!]}]
+ (let [up? (kbd/up-arrow? event)
+ down? (kbd/down-arrow? event)]
+ (when (or up? down?)
+ (dom/prevent-default event)
+ (let [value (-> event dom/get-target dom/get-value)
+ value (or (d/parse-double value) value)
+ increment (cond
+ (kbd/shift? event) (if up? 10 -10)
+ (kbd/alt? event) (if up? 0.1 -0.1)
+ :else (if up? 1 -1))
+ new-value (+ value increment)
+ new-value (cond
+ (and (d/num? min-val) (< new-value min-val)) min-val
+ (and (d/num? max-val) (> new-value max-val)) max-val
+ :else new-value)]
+ (set-value! new-value)))))
+
+(defn direction-select
+ "Returns next `n` in `direction` while wrapping around at the last item at the count of `coll`.
+
+ `direction` accepts `:up` or `:down`."
+ [direction n coll]
+ (let [last-n (dec (count coll))
+ next-n (case direction
+ :up (dec n)
+ :down (inc n))
+ wrap-around-n (cond
+ (neg? next-n) last-n
+ (> next-n last-n) 0
+ :else next-n)]
+ wrap-around-n))
+
+(mf/defc dropdown-select [{:keys [position on-close element-id element-ref options on-select]}]
+ (let [highlighted* (mf/use-state nil)
+ highlighted (deref highlighted*)
+ on-keyup (fn [event]
+ (cond
+ (and (kbd/enter? event) highlighted) (on-select (nth options highlighted))
+ (kbd/up-arrow? event) (do
+ (dom/prevent-default event)
+ (->> (direction-select :up (or highlighted 0) options)
+ (reset! highlighted*)))
+ (kbd/down-arrow? event) (do
+ (dom/prevent-default event)
+ (->> (direction-select :down (or highlighted -1) options)
+ (reset! highlighted*)))))]
+ (mf/with-effect [highlighted]
+ (let [keys [(events/listen globals/document EventType.KEYUP on-keyup)
+ (events/listen globals/document EventType.KEYDOWN dom/prevent-default)]]
+ (st/emit! (dsc/push-shortcuts :token {}))
+ (fn []
+ (doseq [key keys]
+ (events/unlistenByKey key))
+ (st/emit! (dsc/pop-shortcuts :token)))))
+ [:& dropdown {:show true
+ :on-close on-close}
+ [:> :div {:class (stl/css-case :custom-select-dropdown true
+ :custom-select-dropdown-right (= position :right)
+ :custom-select-dropdown-left (= position :left))
+ :on-mouse-enter #(reset! highlighted* nil)
+ :ref element-ref}
+ [:ul {:class (stl/css :custom-select-dropdown-list)}
+ (for [[index item] (d/enumerate options)]
+ (cond
+ (= :separator item) [:li {:class (stl/css :separator)
+ :key (dm/str element-id "-" index)}]
+ ;; Remove items with missing references
+ (seq (:errors item)) nil
+ :else (let [{:keys [label selected? errors]} item
+ highlighted? (= highlighted index)]
+ [:li
+ {:key (str element-id "-" index)
+ :class (stl/css-case :dropdown-element true
+ :is-selected selected?
+ :is-highlighted highlighted?)
+ :data-label label
+ :disabled (seq errors)
+ :on-click #(on-select item)}
+ [:span {:class (stl/css :label)} label]
+ [:span {:class (stl/css :value)} (wtc/resolve-token-value item)]
+ [:span {:class (stl/css :check-icon)} i/tick]])))]]]))
+
+(mf/defc editable-select
+ [{:keys [value options disabled class on-change placeholder on-blur on-token-remove position input-props] :as params}]
+ (let [{:keys [type]} input-props
+ input-class (:class input-props)
+ state* (mf/use-state {:id (uuid/next)
+ :is-open? false
+ :current-value value
+ :token-value nil
+ :current-item nil
+ :top nil
+ :left nil
+ :bottom nil})
+ state (deref state*)
+ is-open? (:is-open? state)
+ refocus? (:refocus? state)
+ current-value (:current-value state)
+ element-id (:id state)
+
+ min-val (get params :min)
+ max-val (get params :max)
+
+ multiple? (= :multiple value)
+ token (when-not multiple?
+ (-> (filter :selected? options) (first)))
+
+ emit-blur? (mf/use-ref nil)
+ select-wrapper-ref (mf/use-ref)
+
+ toggle-dropdown
+ (mf/use-fn
+ (mf/deps state)
+ #(swap! state* update :is-open? not))
+
+ close-dropdown
+ (fn [event]
+ (dom/stop-propagation event)
+ (swap! state* assoc :is-open? false))
+
+ labels-map (->> (map (fn [{:keys [label] :as item}]
+ [label item])
+ options)
+ (into {}))
+
+ set-token-value!
+ (fn [value]
+ (swap! state* assoc :token-value value))
+
+ set-value
+ (fn [value event]
+ (swap! state* assoc
+ :current-value value
+ :token-value value)
+ (when on-change (on-change value event)))
+
+ select-item
+ (mf/use-fn
+ (mf/deps on-change on-blur labels-map)
+ (fn [{:keys [value] :as item}]
+ (swap! state* assoc
+ :current-value value
+ :token-value nil
+ :current-item item)
+ (when on-change (on-change item))
+ (when on-blur (on-blur))))
+
+ handle-change-input
+ (fn [event]
+ (let [value (-> event dom/get-target dom/get-value)
+ value (or (d/parse-double value) value)]
+ (set-value value event)))
+
+ handle-token-change-input
+ (fn [event]
+ (let [value (-> event dom/get-target dom/get-value)
+ value (or (d/parse-double value) value)]
+ (set-token-value! value)))
+
+ handle-key-down
+ (mf/use-fn
+ (mf/deps set-value is-open? token)
+ (fn [^js event]
+ (cond
+ token (let [backspace? (kbd/backspace? event)
+ enter? (kbd/enter? event)
+ value (-> event dom/get-target dom/get-value)
+ caret-at-beginning? (zero? (.. event -target -selectionStart))
+ no-text-selected? (str/empty? (.toString (js/document.getSelection)))
+ delete-token? (and backspace? caret-at-beginning? no-text-selected?)
+ replace-token-with-value? (and enter? (seq (str/trim value)))]
+ (cond
+ delete-token? (do
+ (dom/prevent-default event)
+ (on-token-remove token)
+ ;; Re-focus the input value of the newly rendered input element
+ (swap! state* assoc :refocus? true))
+ replace-token-with-value? (do
+ (dom/prevent-default event)
+ (on-token-remove token)
+ (handle-change-input event)
+ (set-token-value! nil))
+ :else (set-token-value! value)))
+ (= type "number") (on-number-input-key-down {:event event
+ :min-val min-val
+ :max-val max-val
+ :set-value! set-value}))))
+
+ handle-focus
+ (mf/use-fn
+ (mf/deps refocus?)
+ (fn []
+ (when refocus?
+ (swap! state* dissoc :refocus?))
+ (mf/set-ref-val! emit-blur? false)))
+
+ handle-blur
+ (mf/use-fn
+ (fn []
+ (mf/set-ref-val! emit-blur? true)
+ (swap! state* assoc :token-value nil)
+ (timers/schedule
+ 200
+ (fn []
+ (when (and on-blur (mf/ref-val emit-blur?)) (on-blur))))))]
+
+ (mf/use-effect
+ (mf/deps value current-value)
+ #(when (not= (str value) current-value)
+ (swap! state* assoc :current-value value)))
+
+ (mf/with-effect [is-open?]
+ (let [wrapper-node (mf/ref-val select-wrapper-ref)
+ node (dom/get-element-by-class "checked-element is-selected" wrapper-node)
+ nodes (dom/get-elements-by-class "checked-element-value" wrapper-node)
+ closest (fn [a b] (first (sort-by #(mth/abs (- % b)) a)))
+ closest-value (str (closest options value))]
+ (when is-open?
+ (if (some? node)
+ (dom/scroll-into-view-if-needed! node)
+ (some->> nodes
+ (d/seek #(= closest-value (dom/get-inner-text %)))
+ (dom/scroll-into-view-if-needed!)))))
+
+ (mf/set-ref-val! emit-blur? (not is-open?)))
+
+
+ [:div {:class (dm/str class " " (stl/css-case :editable-select true
+ :editable-select-disabled disabled))}
+ (when-let [{:keys [label value]} token]
+ [:div {:title (str label ": " value)
+ :class (stl/css :token-pill)}
+ (wtc/resolve-token-value token)])
+ (cond
+ token [:& :input (merge input-props
+ {:value (or (:token-value state) "")
+ :type "text"
+ :class input-class
+ :onChange handle-token-change-input
+ :onKeyDown handle-key-down
+ :onFocus handle-focus
+ :onBlur handle-blur})]
+ (= type "number") [:& numeric-input* (merge input-props
+ {:autoFocus refocus?
+ :value (or current-value "")
+ :className input-class
+ :onChange set-value
+ :onFocus handle-focus
+ :onBlur handle-blur
+ :placeholder placeholder})]
+ :else [:& :input (merge input-props
+ {:value (or current-value "")
+ :class input-class
+ :onChange handle-change-input
+ :onKeyDown handle-key-down
+ :onFocus handle-focus
+ :onBlur handle-blur
+ :placeholder placeholder
+ :type type})])
+
+ (when (seq options)
+ [:div {:class (stl/css :dropdown-button)
+ :on-click toggle-dropdown}
+ i/arrow])
+
+ (when (and is-open? (seq options))
+ [:& dropdown-select {:position position
+ :on-close close-dropdown
+ :element-id element-id
+ :element-ref select-wrapper-ref
+ :options options
+ :on-select select-item}])]))
diff --git a/frontend/src/app/main/ui/workspace/tokens/editable_select.scss b/frontend/src/app/main/ui/workspace/tokens/editable_select.scss
new file mode 100644
index 000000000..c404919ec
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/editable_select.scss
@@ -0,0 +1,155 @@
+// 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
+
+@import "refactor/common-refactor.scss";
+
+.editable-select {
+ @extend .asset-element;
+ margin: 0;
+ display: flex;
+ height: calc($s-32 - 2px); // Fixes border being clipped by the input field
+ width: 100%;
+ padding: $s-8;
+ border-radius: $br-8;
+ position: relative;
+ cursor: pointer;
+
+ background: transparent;
+ &:hover {
+ background: transparent;
+ }
+ &:focus-within {
+ .token-pill {
+ background-color: var(--button-primary-background-color-rest);
+ color: var(--button-primary-foreground-color-rest);
+ }
+ }
+
+ .dropdown-button {
+ @include flexCenter;
+ margin-right: -$s-8;
+ padding-right: $s-8;
+ padding-left: 0;
+ aspect-ratio: 0.8 / 1;
+ width: auto;
+
+ svg {
+ @extend .button-icon-small;
+ transform: rotate(90deg);
+ stroke: var(--icon-foreground);
+ }
+ }
+
+ .custom-select-dropdown-list {
+ min-width: 150px;
+ width: 100%;
+ max-width: 200px;
+ margin-bottom: 0;
+ }
+
+ .token-pill {
+ background-color: rgb(94 107 120 / 25%);
+ border-radius: $br-4;
+ padding: $s-2 $s-6;
+ text-overflow: ellipsis;
+ flex: 0 0 auto;
+ }
+
+ .token-pill + input {
+ flex: 1 1 auto;
+ width: 0;
+ }
+
+ .custom-select-dropdown-left {
+ left: 0;
+ right: unset;
+ }
+
+ .custom-select-dropdown-right {
+ right: 0;
+ left: unset;
+ }
+
+ .custom-select-dropdown {
+ @extend .dropdown-wrapper;
+ max-height: $s-320;
+ width: auto;
+ margin-top: $s-4;
+
+ .separator {
+ margin: 0;
+ height: $s-12;
+ }
+
+ .dropdown-element {
+ @extend .dropdown-element-base;
+ color: var(--menu-foreground-color-rest);
+ padding: 0;
+ display: flex;
+
+ & > span {
+ display: flex;
+ justify-content: flex-start;
+ align-content: center;
+ }
+
+ .label,
+ .value {
+ width: fit-content;
+ }
+
+ .label {
+ text-transform: unset;
+ flex: 1;
+ }
+
+ .value {
+ text-align: right;
+ justify-content: flex-end;
+ flex: 0.6;
+ }
+
+ .check-icon {
+ @include flexCenter;
+ translate: -$s-4 0;
+ svg {
+ @extend .button-icon-small;
+ visibility: hidden;
+ stroke: var(--icon-foreground);
+ }
+ }
+
+ &.is-selected {
+ color: var(--menu-foreground-color);
+ .check-icon svg {
+ stroke: var(--menu-foreground-color);
+ visibility: visible;
+ }
+ }
+
+ &:hover {
+ background-color: var(--menu-background-color-hover);
+ color: var(--menu-foreground-color-hover);
+ .check-icon svg {
+ stroke: var(--menu-foreground-color-hover);
+ }
+ }
+ &.is-highlighted {
+ background-color: var(--button-primary-background-color-rest);
+ span {
+ color: var(--button-primary-foreground-color-rest);
+ }
+ .check-icon svg {
+ stroke: var(--button-primary-foreground-color-rest);
+ }
+ }
+ }
+ }
+}
+
+.editable-select-disabled {
+ pointer-events: none;
+}
diff --git a/frontend/src/app/main/ui/workspace/tokens/errors.cljs b/frontend/src/app/main/ui/workspace/tokens/errors.cljs
new file mode 100644
index 000000000..9d3b94b78
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/errors.cljs
@@ -0,0 +1,63 @@
+(ns app.main.ui.workspace.tokens.errors
+ (:require
+ [cuerdas.core :as str]))
+
+(def error-codes
+ {:error.import/json-parse-error
+ {:error/code :error.import/json-parse-error
+ :error/message "Import Error: Could not parse json"}
+
+ :error.import/invalid-json-data
+ {:error/code :error.import/invalid-json-data
+ :error/message "Import Error: Invalid token data in json."}
+
+ :error.import/style-dictionary-reference-errors
+ {:error/code :error.import/style-dictionary-reference-errors
+ :error/fn #(str "Import Error:\n\n" (str/join "\n\n" %))}
+
+ :error.import/style-dictionary-unknown-error
+ {:error/code :error.import/style-dictionary-reference-errors
+ :error/message "Import Error:"}
+
+ :error.token/direct-self-reference
+ {:error/code :error.token/direct-self-reference
+ :error/message "Token has self reference"}
+
+ :error.token/invalid-color
+ {:error/code :error.token/invalid-color
+ :error/fn #(str "Invalid color value: " %)}
+
+ :error.style-dictionary/missing-reference
+ {:error/code :error.style-dictionary/missing-reference
+ :error/fn #(str "Missing token references: " (str/join " " %))}
+
+ :error.style-dictionary/invalid-token-value
+ {:error/code :error.style-dictionary/invalid-token-value
+ :error/fn #(str "Invalid token value: " %)}
+
+ :error/unknown
+ {:error/code :error/unknown
+ :error/message "Unknown error"}})
+
+(defn get-error-code [error-key]
+ (get error-codes error-key (:error/unknown error-codes)))
+
+(defn error-with-value [error-key error-value]
+ (-> (get-error-code error-key)
+ (assoc :error/value error-value)))
+
+(defn error-ex-info [error-key error-value exception]
+ (let [err (-> (error-with-value error-key error-value)
+ (assoc :error/exception exception))]
+ (ex-info (:error/code err) err)))
+
+(defn has-error-code? [error-key errors]
+ (some #(= (:error/code %) error-key) errors))
+
+(defn humanize-errors [errors]
+ (->> errors
+ (map (fn [err]
+ (cond
+ (:error/fn err) ((:error/fn err) (:error/value err))
+ (:error/message err) (:error/message err)
+ :else err)))))
diff --git a/frontend/src/app/main/ui/workspace/tokens/form.cljs b/frontend/src/app/main/ui/workspace/tokens/form.cljs
new file mode 100644
index 000000000..04810d879
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/form.cljs
@@ -0,0 +1,431 @@
+;; 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.workspace.tokens.form
+ (:require-macros [app.main.style :as stl])
+ (:require
+ ;; ["lodash.debounce" :as debounce]
+ [app.common.colors :as c]
+ [app.common.data :as d]
+ [app.common.data.macros :as dm]
+ [app.common.types.tokens-lib :as ctob]
+ [app.main.data.modal :as modal]
+ [app.main.data.tokens :as dt]
+ [app.main.refs :as refs]
+ [app.main.store :as st]
+ [app.main.ui.components.color-bullet :refer [color-bullet]]
+ [app.main.ui.ds.buttons.button :refer [button*]]
+ [app.main.ui.ds.foundations.assets.icon :as i]
+ [app.main.ui.ds.foundations.typography.heading :refer [heading*]]
+ [app.main.ui.ds.foundations.typography.text :refer [text*]]
+ [app.main.ui.workspace.colorpicker :as colorpicker]
+ [app.main.ui.workspace.colorpicker.ramp :refer [ramp-selector]]
+ [app.main.ui.workspace.tokens.common :as tokens.common]
+ [app.main.ui.workspace.tokens.errors :as wte]
+ [app.main.ui.workspace.tokens.style-dictionary :as sd]
+ [app.main.ui.workspace.tokens.tinycolor :as tinycolor]
+ [app.main.ui.workspace.tokens.token :as wtt]
+ [app.main.ui.workspace.tokens.update :as wtu]
+ [app.util.dom :as dom]
+ [app.util.functions :as uf]
+ [app.util.i18n :refer [tr]]
+ [cuerdas.core :as str]
+ [malli.core :as m]
+ [malli.error :as me]
+ [promesa.core :as p]
+ [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 %) " is not a valid token name.
+Token names should only contain letters and digits separated by . characters.")}}))
+
+(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 (wtt/token-name-path-exists? % tokens-tree))
+ :type-properties {:error/fn #(str "A token already exists at the path: " (:value %))}})]
+ (m/schema
+ [:and
+ [:string {:min 1 :max 255}]
+ valid-token-name-schema
+ path-exists-schema])))
+
+(def token-description-schema
+ (m/schema
+ [:string {:max 2048}]))
+
+;; Helpers ---------------------------------------------------------------------
+
+(defn finalize-name [name]
+ (-> (str/trim name)
+ ;; Remove trailing dots
+ (str/replace #"\.+$" "")))
+
+(defn valid-name? [name]
+ (seq (finalize-name (str name))))
+
+(defn finalize-value [value]
+ (-> (str value)
+ (str/trim)))
+
+(defn valid-value? [value]
+ (seq (finalize-value value)))
+
+(defn schema-validation->promise [validated]
+ (if (:errors validated)
+ (p/rejected validated)
+ (p/resolved validated)))
+
+;; Component -------------------------------------------------------------------
+
+(defn validate-token-value+
+ "Validates token value by resolving the value `input` using `StyleDictionary`.
+ Returns a promise of either resolved tokens or rejects with an error state."
+ [{:keys [value name-value token tokens]}]
+ (let [;; When creating a new token we dont have a token name yet,
+ ;; so we use a temporary token name that hopefully doesn't clash with any of the users token names
+ token-name (if (str/empty? name-value) "__TOKEN_STUDIO_SYSTEM.TEMP" name-value)]
+ (cond
+ (empty? (str/trim value))
+ (p/rejected {:errors [{:error/code :error/empty-input}]})
+
+ (ctob/token-value-self-reference? token-name value)
+ (p/rejected {:errors [(wte/get-error-code :error.token/direct-self-reference)]})
+
+ :else
+ (-> (update tokens token-name merge {:value value
+ :name token-name
+ :type (:type token)})
+ (sd/resolve-tokens+)
+ (p/then
+ (fn [resolved-tokens]
+ (let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens token-name)]
+ (cond
+ resolved-value (p/resolved resolved-token)
+ :else (p/rejected {:errors (or errors (wte/get-error-code :error/unknown-error))})))))))))
+
+(defn use-debonced-resolve-callback
+ "Resolves a token values using `StyleDictionary`.
+ This function is debounced as the resolving might be an expensive calculation.
+ Uses a custom debouncing logic, as the resolve function is async."
+ [name-ref token tokens callback & {:keys [timeout] :or {timeout 160}}]
+ (let [timeout-id-ref (mf/use-ref nil)
+ debounced-resolver-callback
+ (mf/use-fn
+ (mf/deps token callback tokens)
+ (fn [value]
+ (let [timeout-id (js/Symbol)
+ ;; Dont execute callback when the timout-id-ref is outdated because this function got called again
+ timeout-outdated-cb? #(not= (mf/ref-val timeout-id-ref) timeout-id)]
+ (mf/set-ref-val! timeout-id-ref timeout-id)
+ (js/setTimeout
+ (fn []
+ (when (not (timeout-outdated-cb?))
+ (-> (validate-token-value+ {:value value
+ :name-value @name-ref
+ :token token
+ :tokens tokens})
+ (p/finally
+ (fn [x err]
+ (when-not (timeout-outdated-cb?)
+ (callback (or err x))))))))
+ timeout))))]
+ debounced-resolver-callback))
+
+(defonce form-token-cache-atom (atom nil))
+
+(mf/defc ramp
+ [{:keys [color on-change]}]
+ (let [wrapper-node-ref (mf/use-ref nil)
+ dragging? (mf/use-state)
+ hex->value (fn [hex]
+ (when-let [tc (tinycolor/valid-color hex)]
+ (let [hex (str "#" (tinycolor/->hex tc))
+ [r g b] (c/hex->rgb hex)
+ [h s v] (c/hex->hsv hex)]
+ {:hex hex
+ :r r :g g :b b
+ :h h :s s :v v
+ :alpha 1})))
+ value (mf/use-state (hex->value color))
+ on-change' (fn [{:keys [hex]}]
+ (reset! value (hex->value hex))
+ (when-not (and @dragging? hex)
+ (on-change hex)))]
+ (colorpicker/use-color-picker-css-variables! wrapper-node-ref @value)
+ [:div {:ref wrapper-node-ref}
+ [:& ramp-selector
+ {:color @value
+ :disable-opacity true
+ :on-start-drag #(reset! dragging? true)
+ :on-finish-drag #(reset! dragging? false)
+ :on-change on-change'}]]))
+
+(mf/defc token-value-or-errors
+ [{:keys [result-or-errors]}]
+ (let [{:keys [errors]} result-or-errors
+ empty-message? (or (nil? result-or-errors)
+ (wte/has-error-code? :error/empty-input errors))
+ message (cond
+ empty-message? (dm/str (tr "workspace.token.resolved-value") "-")
+ errors (->> (wte/humanize-errors errors)
+ (str/join "\n"))
+ :else (dm/str (tr "workspace.token.resolved-value") result-or-errors))]
+ [:> text* {:as "p"
+ :typography "body-small"
+ :class (stl/css-case :resolved-value true
+ :resolved-value-placeholder empty-message?
+ :resolved-value-error (seq errors))}
+ message]))
+
+(mf/defc form
+ {::mf/wrap-props false}
+ [{:keys [token token-type action selected-token-set-id]}]
+ (let [validate-name? (mf/use-state (not (:id token)))
+ token (or token {:type token-type})
+ color? (wtt/color-token? token)
+ selected-set-tokens (mf/deref refs/workspace-selected-token-set-tokens)
+ active-theme-tokens (mf/deref refs/workspace-active-theme-sets-tokens)
+ resolved-tokens (sd/use-resolved-tokens active-theme-tokens {:cache-atom form-token-cache-atom
+ :interactive? true})
+ token-path (mf/use-memo
+ (mf/deps (:name token))
+ #(wtt/token-name->path (:name token)))
+ selected-set-tokens-tree (mf/use-memo
+ (mf/deps token-path selected-set-tokens)
+ (fn []
+ (-> (ctob/tokens-tree selected-set-tokens)
+ ;; Allow setting editing token to it's own path
+ (d/dissoc-in token-path))))
+
+ ;; Name
+ name-ref (mf/use-var (:name token))
+ name-errors (mf/use-state nil)
+ validate-name
+ (mf/use-fn
+ (mf/deps selected-set-tokens-tree)
+ (fn [value]
+ (let [schema (token-name-schema {:token token
+ :tokens-tree selected-set-tokens-tree})]
+ (m/explain schema (finalize-name value)))))
+
+ on-update-name-debounced
+ (mf/use-fn
+ (uf/debounce (fn [e]
+ (let [value (dom/get-target-val e)
+ errors (validate-name value)]
+ ;; Prevent showing error when just going to another field on a new token
+ (when-not (and validate-name? (str/empty? value))
+ (reset! validate-name? false)
+ (reset! name-errors errors))))))
+
+ on-update-name
+ (mf/use-fn
+ (mf/deps on-update-name-debounced)
+ (fn [e]
+ (reset! name-ref (dom/get-target-val e))
+ (on-update-name-debounced e)))
+
+ valid-name-field? (and
+ (not @name-errors)
+ (valid-name? @name-ref))
+
+ ;; Value
+ color (mf/use-state (when color? (:value token)))
+ color-ramp-open? (mf/use-state false)
+ value-input-ref (mf/use-ref nil)
+ value-ref (mf/use-var (:value token))
+ token-resolve-result (mf/use-state (get-in resolved-tokens [(wtt/token-identifier token) :resolved-value]))
+ set-resolve-value
+ (mf/use-fn
+ (fn [token-or-err]
+ (let [error? (:errors token-or-err)
+ v (if error?
+ token-or-err
+ (:resolved-value token-or-err))]
+ (when color? (reset! color (if error? nil v)))
+ (reset! token-resolve-result v))))
+ on-update-value-debounced (use-debonced-resolve-callback name-ref token active-theme-tokens set-resolve-value)
+ on-update-value (mf/use-fn
+ (mf/deps on-update-value-debounced)
+ (fn [e]
+ (let [value (dom/get-target-val e)]
+ (reset! value-ref value)
+ (on-update-value-debounced value))))
+ on-update-color (mf/use-fn
+ (mf/deps on-update-value-debounced)
+ (fn [hex-value]
+ (reset! value-ref hex-value)
+ (set! (.-value (mf/ref-val value-input-ref)) hex-value)
+ (on-update-value-debounced hex-value)))
+
+ value-error? (seq (:errors @token-resolve-result))
+ valid-value-field? (and
+ (not value-error?)
+ (valid-value? @token-resolve-result))
+
+ ;; Description
+ description-ref (mf/use-var (:description token))
+ description-errors (mf/use-state nil)
+ validate-descripion (mf/use-fn #(m/explain token-description-schema %))
+ on-update-description-debounced (mf/use-fn
+ (uf/debounce (fn [e]
+ (let [value (dom/get-target-val e)
+ errors (validate-descripion value)]
+ (reset! description-errors errors)))))
+ on-update-description
+ (mf/use-fn
+ (mf/deps on-update-description-debounced)
+ (fn [e]
+ (reset! description-ref (dom/get-target-val e))
+ (on-update-description-debounced e)))
+ valid-description-field? (not @description-errors)
+
+ ;; Form
+ disabled? (or (not valid-name-field?)
+ (not valid-value-field?)
+ (not valid-description-field?))
+
+ on-submit
+ (mf/use-fn
+ (mf/deps validate-name validate-descripion token resolved-tokens)
+ (fn [e]
+ (dom/prevent-default e)
+ ;; We have to re-validate the current form values before submitting
+ ;; because the validation is asynchronous/debounced
+ ;; and the user might have edited a valid form to make it invalid,
+ ;; and press enter before the next validations could return.
+ (let [final-name (finalize-name @name-ref)
+ valid-name?+ (-> (validate-name final-name) schema-validation->promise)
+ final-value (finalize-value @value-ref)
+ final-description @description-ref
+ valid-description?+ (some-> final-description validate-descripion schema-validation->promise)]
+ (-> (p/all [valid-name?+
+ valid-description?+
+ (validate-token-value+ {:value final-value
+ :name-value final-name
+ :token token
+ :tokens resolved-tokens})])
+ (p/finally (fn [result err]
+ ;; The result should be a vector of all resolved validations
+ ;; We do not handle the error case as it will be handled by the components validations
+ (when (and (seq result) (not err))
+ (st/emit! (dt/update-create-token {:token (ctob/make-token :name final-name
+ :type (or (:type token) token-type)
+ :value final-value
+ :description final-description)
+ :prev-token-name (:name token)}))
+ (st/emit! (wtu/update-workspace-tokens))
+ (modal/hide!))))))))
+ on-delete-token
+ (mf/use-fn
+ (mf/deps selected-token-set-id)
+ (fn [e]
+ (dom/prevent-default e)
+ (modal/hide!)
+ (st/emit! (dt/delete-token selected-token-set-id (:name token)))))
+
+ on-cancel
+ (mf/use-fn
+ (fn [e]
+ (dom/prevent-default e)
+ (modal/hide!)))]
+
+ [:form {:class (stl/css :form-wrapper)
+ :on-submit on-submit}
+ [:div {:class (stl/css :token-rows)}
+ [:> heading* {:level 2 :typography "headline-medium" :class (stl/css :form-modal-title)}
+ (if (= action "edit")
+ (tr "workspace.token.edit-token")
+ (tr "workspace.token.create-token" token-type))]
+
+ [:div {:class (stl/css :input-row)}
+ ;; This should be remove when labeled-imput is modified
+ [:span {:class (stl/css :labeled-input-label)} "Name"]
+ [:& tokens.common/labeled-input {:label "Name"
+ :error? @name-errors
+ :input-props {:default-value @name-ref
+ :auto-focus true
+ :on-blur on-update-name
+ :on-change on-update-name}}]
+ (for [error (->> (:errors @name-errors)
+ (map #(-> (assoc @name-errors :errors [%])
+ (me/humanize))))]
+ [:> text* {:as "p"
+ :key error
+ :typography "body-small"
+ :class (stl/css :error)}
+ error])]
+
+ [:div {:class (stl/css :input-row)}
+ ;; This should be remove when labeled-imput is modified
+ [:span {:class (stl/css :labeled-input-label)} "value"]
+ [:& tokens.common/labeled-input {:label "Value"
+ :input-props {:default-value @value-ref
+ :on-blur on-update-value
+ :on-change on-update-value
+ :ref value-input-ref}
+ :render-right (when color?
+ (mf/fnc color-bullet []
+ [:div {:class (stl/css :color-bullet)
+ :on-click #(swap! color-ramp-open? not)}
+ (if-let [hex (some-> @color tinycolor/valid-color tinycolor/->hex)]
+ [:& color-bullet {:color hex
+ :mini? true}]
+ [:div {:class (stl/css :color-bullet-placeholder)}])]))}]
+ (when @color-ramp-open?
+ [:& ramp {:color (some-> (or @token-resolve-result (:value token))
+ (tinycolor/valid-color))
+ :on-change on-update-color}])
+ [:& token-value-or-errors {:result-or-errors @token-resolve-result}]]
+
+
+ [:div {:class (stl/css :input-row)}
+ ;; This should be remove when labeled-imput is modified
+ [:span {:class (stl/css :labeled-input-label)} "Description"]
+ [:& tokens.common/labeled-input {:label "Description"
+ :input-props {:default-value @description-ref
+ :on-change on-update-description}}]
+ (when @description-errors
+ [:> text* {:as "p"
+ :typography "body-small"
+ :class (stl/css :error)}
+ (me/humanize @description-errors)])]
+
+ [:div {:class (stl/css-case :button-row true
+ :with-delete (= action "edit"))}
+ (when (= action "edit")
+ [:> button* {:on-click on-delete-token
+ :class (stl/css :delete-btn)
+ :type "button"
+ :icon i/delete
+ :variant "secondary"}
+ (tr "labels.delete")])
+ [:> button* {:on-click on-cancel
+ :type "button"
+ :variant "secondary"}
+ (tr "labels.cancel")]
+ [:> button* {:type "submit"
+ :variant "primary"
+ :disabled disabled?}
+ (tr "labels.save")]]]]))
diff --git a/frontend/src/app/main/ui/workspace/tokens/form.scss b/frontend/src/app/main/ui/workspace/tokens/form.scss
new file mode 100644
index 000000000..0c0dfff67
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/form.scss
@@ -0,0 +1,85 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+@import "refactor/common-refactor.scss";
+@import "./common.scss";
+
+.form-wrapper {
+ width: $s-384;
+}
+
+.button-row {
+ display: grid;
+ grid-template-columns: auto auto;
+ justify-content: end;
+ gap: $s-12;
+ padding-block-start: $s-8;
+}
+
+.with-delete {
+ grid-template-columns: 1fr auto auto;
+}
+
+.delete-btn {
+ justify-self: start;
+}
+
+.token-rows {
+ display: flex;
+ flex-direction: column;
+ gap: $s-16;
+}
+
+.input-row {
+ display: flex;
+ flex-direction: column;
+ gap: $s-4;
+}
+
+.labeled-input-label {
+ color: var(--color-foreground-primary);
+}
+
+.error {
+ padding: $s-4 $s-6;
+ margin-bottom: 0;
+ color: var(--status-color-error-500);
+}
+
+.resolved-value {
+ --input-hint-color: var(--color-foreground-primary);
+ margin-bottom: 0;
+ padding: $s-4 $s-6;
+ color: var(--input-hint-color);
+}
+
+.resolved-value-placeholder {
+ --input-hint-color: var(--color-foreground-secondary);
+}
+
+.resolved-value-error {
+ --input-hint-color: var(--status-color-error-500);
+}
+
+.color-bullet {
+ margin-right: $s-8;
+ cursor: pointer;
+}
+
+.color-bullet-placeholder {
+ width: var(--bullet-size, $s-16);
+ height: var(--bullet-size, $s-16);
+ min-width: var(--bullet-size, $s-16);
+ min-height: var(--bullet-size, $s-16);
+ margin-top: 0;
+ background-color: color-mix(in hsl, var(--color-foreground-secondary) 30%, transparent);
+ border-radius: $br-4;
+ cursor: pointer;
+}
+
+.form-modal-title {
+ color: var(--color-foreground-primary);
+}
diff --git a/frontend/src/app/main/ui/workspace/tokens/modals.cljs b/frontend/src/app/main/ui/workspace/tokens/modals.cljs
new file mode 100644
index 000000000..a34ccfe61
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/modals.cljs
@@ -0,0 +1,146 @@
+;; 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.workspace.tokens.modals
+ (:require-macros [app.main.style :as stl])
+ (:require
+ [app.main.data.modal :as modal]
+ [app.main.refs :as refs]
+ [app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
+ [app.main.ui.ds.foundations.assets.icon :as i]
+ [app.main.ui.workspace.tokens.form :refer [form]]
+ [app.util.i18n :refer [tr]]
+ [okulary.core :as l]
+ [rumext.v2 :as mf]))
+
+;; Component -------------------------------------------------------------------
+
+(defn calculate-position
+ "Calculates the style properties for the given coordinates and position"
+ [{vh :height} position x y]
+ (let [;; picker height in pixels
+ h 510
+ ;; Checks for overflow outside the viewport height
+ overflow-fix (max 0 (+ y (- 50) h (- vh)))
+
+ x-pos 325]
+ (cond
+ (or (nil? x) (nil? y)) {:left "auto" :right "16rem" :top "4rem"}
+ (= position :left) {:left (str (- x x-pos) "px")
+ :top (str (- y 50 overflow-fix) "px")}
+ :else {:left (str (+ x 80) "px")
+ :top (str (- y 70 overflow-fix) "px")})))
+
+(defn use-viewport-position-style [x y position]
+ (let [vport (-> (l/derived :vport refs/workspace-local)
+ (mf/deref))]
+ (-> (calculate-position vport position x y)
+ (clj->js))))
+
+(mf/defc token-update-create-modal
+ {::mf/wrap-props false}
+ [{:keys [x y position token token-type action selected-token-set-id] :as _args}]
+ (let [wrapper-style (use-viewport-position-style x y position)
+ close-modal (mf/use-fn
+ (fn []
+ (modal/hide!)))]
+ [:div {:class (stl/css :token-modal-wrapper)
+ :style wrapper-style}
+ [:> icon-button* {:on-click close-modal
+ :class (stl/css :close-btn)
+ :icon i/close
+ :variant "action"
+ :aria-label (tr "labels.close")}]
+ [:& form {:token token
+ :action action
+ :selected-token-set-id selected-token-set-id
+ :token-type token-type}]]))
+
+;; Modals ----------------------------------------------------------------------
+
+(mf/defc boolean-modal
+ {::mf/register modal/components
+ ::mf/register-as :tokens/boolean}
+ [properties]
+ [:& token-update-create-modal properties])
+
+(mf/defc border-radius-modal
+ {::mf/register modal/components
+ ::mf/register-as :tokens/border-radius}
+ [properties]
+ [:& token-update-create-modal properties])
+
+(mf/defc color-modal
+ {::mf/register modal/components
+ ::mf/register-as :tokens/color}
+ [properties]
+ [:& token-update-create-modal properties])
+
+(mf/defc stroke-width-modal
+ {::mf/register modal/components
+ ::mf/register-as :tokens/stroke-width}
+ [properties]
+ [:& token-update-create-modal properties])
+
+(mf/defc box-shadow-modal
+ {::mf/register modal/components
+ ::mf/register-as :tokens/box-shadow}
+ [properties]
+ [:& token-update-create-modal properties])
+
+(mf/defc sizing-modal
+ {::mf/register modal/components
+ ::mf/register-as :tokens/sizing}
+ [properties]
+ [:& token-update-create-modal properties])
+
+(mf/defc dimensions-modal
+ {::mf/register modal/components
+ ::mf/register-as :tokens/dimensions}
+ [properties]
+ [:& token-update-create-modal properties])
+
+(mf/defc numeric-modal
+ {::mf/register modal/components
+ ::mf/register-as :tokens/numeric}
+ [properties]
+ [:& token-update-create-modal properties])
+
+(mf/defc opacity-modal
+ {::mf/register modal/components
+ ::mf/register-as :tokens/opacity}
+ [properties]
+ [:& token-update-create-modal properties])
+
+(mf/defc other-modal
+ {::mf/register modal/components
+ ::mf/register-as :tokens/other}
+ [properties]
+ [:& token-update-create-modal properties])
+
+(mf/defc rotation-modal
+ {::mf/register modal/components
+ ::mf/register-as :tokens/rotation}
+ [properties]
+ [:& token-update-create-modal properties])
+
+(mf/defc spacing-modal
+ {::mf/register modal/components
+ ::mf/register-as :tokens/spacing}
+ [properties]
+ [:& token-update-create-modal properties])
+
+(mf/defc string-modal
+ {::mf/register modal/components
+ ::mf/register-as :tokens/string}
+ [properties]
+ [:& token-update-create-modal properties])
+
+(mf/defc typography-modal
+ {::mf/register modal/components
+ ::mf/register-as :tokens/typography}
+ [properties]
+ [:& token-update-create-modal properties])
diff --git a/frontend/src/app/main/ui/workspace/tokens/modals.scss b/frontend/src/app/main/ui/workspace/tokens/modals.scss
new file mode 100644
index 000000000..b1e924616
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/modals.scss
@@ -0,0 +1,24 @@
+// 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
+
+@import "refactor/common-refactor.scss";
+
+.token-modal-wrapper {
+ @extend .modal-container-base;
+ @include menuShadow;
+ position: absolute;
+ width: auto;
+ min-width: auto;
+ z-index: 11;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+.close-btn {
+ position: absolute;
+ top: $s-6;
+ right: $s-6;
+}
diff --git a/frontend/src/app/main/ui/workspace/tokens/modals/themes.cljs b/frontend/src/app/main/ui/workspace/tokens/modals/themes.cljs
new file mode 100644
index 000000000..b8399ae9a
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/modals/themes.cljs
@@ -0,0 +1,369 @@
+;; 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.workspace.tokens.modals.themes
+ (:require-macros [app.main.style :as stl])
+ (:require
+ [app.common.data.macros :as dm]
+ [app.common.types.tokens-lib :as ctob]
+ [app.main.data.modal :as modal]
+ [app.main.data.tokens :as wdt]
+ [app.main.refs :as refs]
+ [app.main.store :as st]
+ [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]]
+ [app.main.ui.ds.buttons.button :refer [button*]]
+ [app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
+ [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as ic]
+ [app.main.ui.ds.foundations.typography.heading :refer [heading*]]
+ [app.main.ui.ds.foundations.typography.text :refer [text*]]
+ [app.main.ui.icons :as i]
+ [app.main.ui.workspace.tokens.common :refer [labeled-input] :as wtco]
+ [app.main.ui.workspace.tokens.sets :as wts]
+ [app.main.ui.workspace.tokens.sets-context :as sets-context]
+ [app.util.dom :as dom]
+ [app.util.i18n :refer [tr]]
+ [cuerdas.core :as str]
+ [rumext.v2 :as mf]))
+
+(mf/defc empty-themes
+ [{:keys [set-state]}]
+ (let [create-theme
+ (mf/use-fn
+ (mf/deps set-state)
+ #(set-state (fn [_] {:type :create-theme})))]
+ [:div {:class (stl/css :themes-modal-wrapper)}
+ [:> heading* {:level 2 :typography "headline-medium" :class (stl/css :themes-modal-title)}
+ (tr "workspace.token.themes")]
+ [:div {:class (stl/css :empty-themes-wrapper)}
+ [:div {:class (stl/css :empty-themes-message)}
+ [:> text* {:as "span" :typography "title-medium" :class (stl/css :empty-theme-title)}
+ (tr "workspace.token.no-themes-currently")]
+ [:> text* {:as "span"
+ :class (stl/css :empty-theme-subtitle)
+ :typography "body-medium"}
+ (tr "workspace.token.create-new-theme")]]
+ [:div {:class (stl/css :button-footer)}
+ [:> button* {:variant "primary"
+ :type "button"
+ :on-click create-theme}
+ (tr "workspace.token.new-theme")]]]]))
+
+(mf/defc switch
+ [{:keys [selected? name on-change]}]
+ (let [selected (if selected? :on :off)]
+ [:& radio-buttons {:selected selected
+ :on-change on-change
+ :name name}
+ [:& radio-button {:id :on
+ :value :on
+ :icon i/tick
+ :label ""}]
+ [:& radio-button {:id :off
+ :value :off
+ :icon i/close
+ :label ""}]]))
+
+(mf/defc themes-overview
+ [{:keys [set-state]}]
+ (let [active-theme-ids (mf/deref refs/workspace-active-theme-paths)
+ themes-groups (mf/deref refs/workspace-token-theme-tree-no-hidden)
+
+ create-theme
+ (mf/use-fn
+ (mf/deps set-state)
+ (fn [e]
+ (dom/prevent-default e)
+ (dom/stop-propagation e)
+ (set-state (fn [_] {:type :create-theme}))))]
+
+ [:div {:class (stl/css :themes-modal-wrapper)}
+ [:> heading* {:level 2 :typography "headline-medium" :class (stl/css :themes-modal-title)}
+ (tr "workspace.token.themes")]
+ [:ul {:class (stl/css :theme-group-wrapper)}
+ (for [[group themes] themes-groups]
+ [:li {:key (dm/str "token-theme-group" group)}
+ (when (seq group)
+ [:> heading* {:level 3
+ :class (stl/css :theme-group-label)
+ :typography "body-large"}
+ [:span {:class (stl/css :group-title)}
+ [:> icon* {:id "group"}]
+ group]])
+ [:ul {:class (stl/css :theme-group-rows-wrapper)}
+ (for [[_ {:keys [group name] :as theme}] themes
+ :let [theme-id (ctob/theme-path theme)
+ selected? (some? (get active-theme-ids theme-id))
+ delete-theme
+ (fn [e]
+ (dom/prevent-default e)
+ (dom/stop-propagation e)
+ (st/emit! (wdt/delete-token-theme group name)))
+ on-edit-theme
+ (fn [e]
+ (dom/prevent-default e)
+ (dom/stop-propagation e)
+ (set-state (fn [_] {:type :edit-theme
+ :theme-path [(:id theme) (:group theme) (:name theme)]})))]]
+ [:li {:key theme-id
+ :class (stl/css :theme-row)}
+ [:div {:class (stl/css :theme-row-left)}
+
+ ;; FIREEEEEEEEEE THIS
+ [:div {:on-click (fn [e]
+ (dom/prevent-default e)
+ (dom/stop-propagation e)
+ (st/emit! (wdt/toggle-token-theme-active? group name)))}
+ [:& switch {:name (tr "workspace.token.theme" name)
+ :on-change (constantly nil)
+ :selected? selected?}]]
+ [:> text* {:as "span" :typography "body-medium" :class (stl/css :theme-name)} name]]
+
+
+ [:div {:class (stl/css :theme-row-right)}
+ (if-let [sets-count (some-> theme :sets seq count)]
+ [:> button* {:class (stl/css :sets-count-button)
+ :variant "secondary"
+ :type "button"
+ :on-click on-edit-theme}
+ [:div {:class (stl/css :label-wrapper)}
+ [:> text* {:as "span" :typography "body-medium"}
+ (tr "workspace.token.num-sets" sets-count)]
+ [:> icon* {:id "arrow-right"}]]]
+
+ [:> button* {:class (stl/css :sets-count-empty-button)
+ :type "button"
+ :variant "secondary"
+ :on-click on-edit-theme}
+ [:div {:class (stl/css :label-wrapper)}
+ [:> text* {:as "span" :typography "body-medium"}
+ (tr "workspace.token.no-sets")]
+ [:> icon* {:id "arrow-right"}]]])
+
+ [:> icon-button* {:on-click delete-theme
+ :variant "ghost"
+ :aria-label (tr "workspace.token.delete-theme-title")
+ :icon "delete"}]]])]])]
+
+ [:div {:class (stl/css :button-footer)}
+ [:> button* {:variant "primary"
+ :type "button"
+ :icon "add"
+ :on-click create-theme}
+ (tr "workspace.token.create-theme-title")]]]))
+
+(mf/defc theme-inputs
+ [{:keys [theme dropdown-open? on-close-dropdown on-toggle-dropdown on-change-field]}]
+ (let [theme-groups (mf/deref refs/workspace-token-theme-groups)
+ group-input-ref (mf/use-ref)
+ on-update-group (partial on-change-field :group)
+ on-update-name (partial on-change-field :name)]
+ [:div {:class (stl/css :edit-theme-inputs-wrapper)}
+ [:div {:class (stl/css :group-input-wrapper)}
+ (when dropdown-open?
+ [:& wtco/dropdown-select {:id ::groups-dropdown
+ :shortcuts-key ::groups-dropdown
+ :options (map (fn [group]
+ {:label group
+ :value group})
+ theme-groups)
+ :on-select (fn [{:keys [value]}]
+ (set! (.-value (mf/ref-val group-input-ref)) value)
+ (on-update-group value))
+ :on-close on-close-dropdown}])
+ ;; TODO: This span should be remove when labeled-input is updated
+ [:span {:class (stl/css :labeled-input-label)} "Theme group"]
+ [:& labeled-input {:label "Group"
+ :input-props {:ref group-input-ref
+ :default-value (:group theme)
+ :on-change (comp on-update-group dom/get-target-val)}
+ :render-right (when (seq theme-groups)
+ (mf/fnc drop-down-button []
+ [:button {:class (stl/css :group-drop-down-button)
+ :type "button"
+ :on-click (fn [e]
+ (dom/stop-propagation e)
+ (on-toggle-dropdown))}
+ [:> icon* {:id "arrow-down"}]]))}]]
+ [:div {:class (stl/css :group-input-wrapper)}
+ ;; TODO: This span should be remove when labeled-input is updated
+ [:span {:class (stl/css :labeled-input-label)} "Theme"]
+ [:& labeled-input {:label "Theme"
+ :input-props {:default-value (:name theme)
+ :on-change (comp on-update-name dom/get-target-val)}}]]]))
+
+(mf/defc theme-modal-buttons
+ [{:keys [close-modal on-save-form disabled?] :as props}]
+ [:*
+ [:> button* {:variant "secondary"
+ :type "button"
+ :on-click close-modal}
+ (tr "labels.cancel")]
+ [:> button* {:variant "primary"
+ :type "submit"
+ :on-click on-save-form
+ :disabled disabled?}
+ (tr "workspace.token.save-theme")]])
+
+(mf/defc create-theme
+ [{:keys [set-state]}]
+ (let [{:keys [dropdown-open? _on-open-dropdown on-close-dropdown on-toggle-dropdown]} (wtco/use-dropdown-open-state)
+ theme (ctob/make-token-theme :name "")
+ on-back #(set-state (constantly {:type :themes-overview}))
+ on-submit #(st/emit! (wdt/create-token-theme %))
+ theme-state (mf/use-state theme)
+ disabled? (-> (:name @theme-state)
+ (str/trim)
+ (str/empty?))
+ on-change-field (fn [field value]
+ (swap! theme-state #(assoc % field value)))
+ on-save-form (mf/use-callback
+ (mf/deps theme-state on-submit)
+ (fn [e]
+ (dom/prevent-default e)
+ (let [theme (-> @theme-state
+ (update :name str/trim)
+ (update :group str/trim)
+ (update :description str/trim))]
+ (when-not (str/empty? (:name theme))
+ (on-submit theme)))
+ (on-back)))
+ close-modal (mf/use-fn
+ (fn [e]
+ (dom/prevent-default e)
+ (st/emit! (modal/hide))))]
+ [:div {:class (stl/css :themes-modal-wrapper)}
+ [:> heading* {:level 2 :typography "headline-medium" :class (stl/css :themes-modal-title)}
+ (tr "workspace.token.create-theme-title")]
+ [:form {:on-submit on-save-form}
+ [:div {:class (stl/css :create-theme-wrapper)}
+ [:& theme-inputs {:dropdown-open? dropdown-open?
+ :on-close-dropdown on-close-dropdown
+ :on-toggle-dropdown on-toggle-dropdown
+ :theme theme
+ :on-change-field on-change-field}]
+
+ [:div {:class (stl/css :button-footer)}
+ [:& theme-modal-buttons {:close-modal close-modal
+ :on-save-form on-save-form
+ :disabled? disabled?}]]]]]))
+
+(mf/defc controlled-edit-theme
+ [{:keys [state set-state]}]
+ (let [{:keys [theme-path]} @state
+ [_ theme-group theme-name] theme-path
+ token-sets (mf/deref refs/workspace-ordered-token-sets)
+ theme (mf/deref (refs/workspace-token-theme theme-group theme-name))
+ on-back #(set-state (constantly {:type :themes-overview}))
+ on-submit #(st/emit! (wdt/update-token-theme [(:group theme) (:name theme)] %))
+ {:keys [dropdown-open? _on-open-dropdown on-close-dropdown on-toggle-dropdown]} (wtco/use-dropdown-open-state)
+ theme-state (mf/use-state theme)
+ disabled? (-> (:name @theme-state)
+ (str/trim)
+ (str/empty?))
+ token-set-active? (mf/use-callback
+ (mf/deps theme-state)
+ (fn [set-name]
+ (get-in @theme-state [:sets set-name])))
+ on-toggle-token-set (mf/use-callback
+ (mf/deps theme-state)
+ (fn [set-name]
+ (swap! theme-state #(ctob/toggle-set % set-name))))
+ on-change-field (fn [field value]
+ (swap! theme-state #(assoc % field value)))
+ on-save-form (mf/use-callback
+ (mf/deps theme-state on-submit)
+ (fn [e]
+ (dom/prevent-default e)
+ (let [theme (-> @theme-state
+ (update :name str/trim)
+ (update :group str/trim)
+ (update :description str/trim))]
+ (when-not (str/empty? (:name theme))
+ (on-submit theme)))
+ (on-back)))
+ close-modal
+ (mf/use-fn
+ (fn [e]
+ (dom/prevent-default e)
+ (st/emit! (modal/hide))))
+
+ on-delete-token
+ (mf/use-fn
+ (mf/deps theme on-back)
+ (fn []
+ (st/emit! (wdt/delete-token-theme (:group theme) (:name theme)))
+ (on-back)))]
+
+ [:div {:class (stl/css :themes-modal-wrapper)}
+ [:> heading* {:level 2 :typography "headline-medium" :class (stl/css :themes-modal-title)}
+ (tr "workspace.token.edit-theme-title")]
+
+ [:form {:on-submit on-save-form}
+ [:div {:class (stl/css :edit-theme-wrapper)}
+ [:button {:on-click on-back
+ :class (stl/css :back-btn)
+ :type "button"}
+ [:> icon* {:id ic/arrow-left :aria-hidden true}]
+ (tr "workspace.token.back-to-themes")]
+
+ [:& theme-inputs {:dropdown-open? dropdown-open?
+ :on-close-dropdown on-close-dropdown
+ :on-toggle-dropdown on-toggle-dropdown
+ :theme theme
+ :on-change-field on-change-field}]
+ [:> text* {:as "span" :typography "body-small" :class (stl/css :select-sets-message)}
+ (tr "workspace.token.set-selection-theme")]
+ [:div {:class (stl/css :sets-list-wrapper)}
+
+ [:& wts/controlled-sets-list
+ {:token-sets token-sets
+ :token-set-selected? (constantly false)
+ :token-set-active? token-set-active?
+ :on-select on-toggle-token-set
+ :on-toggle-token-set on-toggle-token-set
+ :origin "theme-modal"
+ :context sets-context/static-context}]]
+
+ [:div {:class (stl/css :edit-theme-footer)}
+ [:> button* {:variant "secondary"
+ :type "button"
+ :icon "delete"
+ :on-click on-delete-token}
+ (tr "labels.delete")]
+ [:div {:class (stl/css :button-footer)}
+ [:& theme-modal-buttons {:close-modal close-modal
+ :on-save-form on-save-form
+ :disabled? disabled?}]]]]]]))
+
+(mf/defc themes-modal-body
+ [_]
+ (let [themes (mf/deref refs/workspace-token-themes-no-hidden)
+ state (mf/use-state (if (empty? themes)
+ {:type :create-theme}
+ {:type :themes-overview}))
+ set-state (mf/use-callback #(swap! state %))
+ component (case (:type @state)
+ :empty-themes empty-themes
+ :themes-overview (if (empty? themes) empty-themes themes-overview)
+ :edit-theme controlled-edit-theme
+ :create-theme create-theme)]
+ [:& component {:state state
+ :set-state set-state}]))
+
+(mf/defc token-themes-modal
+ {::mf/wrap-props false
+ ::mf/register modal/components
+ ::mf/register-as :tokens/themes}
+ [_args]
+ (let [handle-close-dialog (mf/use-callback #(st/emit! (modal/hide)))]
+ [:div {:class (stl/css :modal-overlay)}
+ [:div {:class (stl/css :modal-dialog)}
+ [:> icon-button* {:class (stl/css :close-btn)
+ :on-click handle-close-dialog
+ :aria-label (tr "labels.close")
+ :variant "action"
+ :icon "close"}]
+ [:& themes-modal-body]]]))
\ No newline at end of file
diff --git a/frontend/src/app/main/ui/workspace/tokens/modals/themes.scss b/frontend/src/app/main/ui/workspace/tokens/modals/themes.scss
new file mode 100644
index 000000000..08c9c2a1f
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/modals/themes.scss
@@ -0,0 +1,198 @@
+// 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
+
+@import "refactor/common-refactor.scss";
+
+.modal-overlay {
+ @extend .modal-overlay-base;
+}
+
+.modal-dialog {
+ @extend .modal-container-base;
+ display: grid;
+ grid-template-rows: auto 1fr auto;
+ width: 100%;
+ max-width: $s-468;
+ user-select: none;
+}
+
+.empty-themes-message {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ gap: $s-12;
+ padding: $s-72 0;
+}
+
+.themes-modal-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: $s-24;
+}
+
+.themes-modal-title {
+ color: var(--color-foreground-primary);
+}
+
+.back-btn {
+ background-color: transparent;
+ border: none;
+ appearance: none;
+ color: var(--color-foreground-secondary);
+ width: fit-content;
+ display: grid;
+ grid-template-columns: auto auto;
+ gap: $s-4;
+ align-items: center;
+ padding: 0;
+ &:hover {
+ color: var(--color-accent-primary);
+ }
+}
+
+.labeled-input-label {
+ color: var(--color-foreground-primary);
+}
+
+.button-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: $s-6;
+}
+
+.edit-theme-footer {
+ display: flex;
+ justify-content: space-between;
+}
+
+.empty-themes-wrapper {
+ display: flex;
+ flex-direction: column;
+ color: var(--color-foreground-secondary);
+}
+
+.empty-theme-subtitle {
+ color: var(--color-foreground-secondary);
+}
+
+.empty-theme-title {
+ color: var(--color-foreground-primary);
+}
+
+.select-sets-message {
+ color: var(--color-foreground-secondary);
+}
+
+.create-theme-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: $s-24;
+}
+
+.close-btn {
+ position: absolute;
+ top: $s-8;
+ right: $s-6;
+}
+
+.theme-group-label {
+ color: var(--color-foreground-secondary);
+}
+
+.group-title {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: $s-4;
+}
+
+.theme-group-rows-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: $s-6;
+}
+
+.theme-group-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: $s-8;
+}
+
+.theme-row {
+ display: flex;
+ align-items: center;
+ gap: $s-12;
+ justify-content: space-between;
+}
+
+.theme-row-left {
+ display: flex;
+ align-items: center;
+ gap: $s-16;
+}
+
+.theme-name {
+ color: var(--color-foreground-primary);
+}
+
+.theme-row-right {
+ display: flex;
+ align-items: center;
+ gap: $s-6;
+}
+
+.sets-count-button {
+ text-transform: lowercase;
+ padding: $s-6;
+ padding-left: $s-12;
+}
+
+.label-wrapper {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.edit-theme-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: $s-12;
+}
+
+.sets-list-wrapper {
+ border: 1px solid color-mix(in hsl, var(--color-foreground-secondary) 30%, transparent);
+ border-radius: $s-8;
+ overflow: hidden;
+}
+
+.sets-count-empty-button {
+ text-transform: lowercase;
+ padding: $s-6;
+ padding-left: $s-12;
+}
+
+.group-input-wrapper {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: $s-4;
+}
+
+.edit-theme-inputs-wrapper {
+ display: grid;
+ grid-template-columns: 0.6fr 1fr;
+ gap: $s-12;
+}
+
+.group-drop-down-button {
+ @include buttonStyle;
+ color: var(--color-foreground-secondary);
+ width: $s-24;
+ height: 100%;
+ padding: 0;
+ margin: 0 $s-6;
+}
diff --git a/frontend/src/app/main/ui/workspace/tokens/sets.cljs b/frontend/src/app/main/ui/workspace/tokens/sets.cljs
new file mode 100644
index 000000000..37c4d6588
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/sets.cljs
@@ -0,0 +1,270 @@
+;; 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.workspace.tokens.sets
+ (:require-macros [app.main.style :as stl])
+ (:require
+ [app.main.data.notifications :as ntf]
+ [app.main.data.tokens :as wdt]
+ [app.main.refs :as refs]
+ [app.main.store :as st]
+ [app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
+ [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as ic]
+ [app.main.ui.ds.foundations.typography.text :refer [text*]]
+ [app.main.ui.hooks :as h]
+ [app.main.ui.workspace.tokens.sets-context :as sets-context]
+ [app.util.dom :as dom]
+ [app.util.i18n :refer [tr]]
+ [app.util.keyboard :as kbd]
+ [cuerdas.core :as str]
+ [rumext.v2 :as mf]))
+
+(defn on-toggle-token-set-click [token-set-name]
+ (st/emit! (wdt/toggle-token-set {:token-set-name token-set-name})))
+
+(defn on-select-token-set-click [name]
+ (st/emit! (wdt/set-selected-token-set-id name)))
+
+(defn on-update-token-set [set-name token-set]
+ (st/emit! (wdt/update-token-set set-name token-set)))
+
+(defn on-create-token-set [token-set]
+ (st/emit! (wdt/create-token-set token-set)))
+
+(mf/defc editing-node
+ [{:keys [default-value on-cancel on-submit]}]
+ (let [ref (mf/use-ref)
+ on-submit-valid (mf/use-fn
+ (fn [event]
+ (let [value (str/trim (dom/get-target-val event))]
+ (if (or (str/empty? value)
+ (= value default-value))
+ (on-cancel)
+ (on-submit value)))))
+ on-key-down (mf/use-fn
+ (fn [event]
+ (cond
+ (kbd/enter? event) (on-submit-valid event)
+ (kbd/esc? event) (on-cancel))))]
+ [:input
+ {:class (stl/css :editing-node)
+ :type "text"
+ :ref ref
+ :on-blur on-submit-valid
+ :on-key-down on-key-down
+ :auto-focus true
+ :default-value default-value}]))
+
+(mf/defc sets-tree
+ [{:keys [token-set
+ token-set-active?
+ token-set-selected?
+ editing?
+ on-select
+ on-toggle
+ on-edit
+ on-submit
+ on-cancel]
+ :as _props}]
+ (let [{:keys [name _children]} token-set
+ selected? (and set? (token-set-selected? name))
+ visible? (token-set-active? name)
+ collapsed? (mf/use-state false)
+ set? true #_(= type :set)
+ group? false #_(= type :group)
+ editing-node? (editing? name)
+
+ on-click
+ (mf/use-fn
+ (mf/deps editing-node?)
+ (fn [event]
+ (dom/stop-propagation event)
+ (when-not editing-node?
+ (on-select name))))
+
+ on-context-menu
+ (mf/use-fn
+ (mf/deps editing-node? name)
+ (fn [event]
+ (dom/prevent-default event)
+ (dom/stop-propagation event)
+ (when-not editing-node?
+ (st/emit!
+ (wdt/show-token-set-context-menu
+ {:position (dom/get-client-position event)
+ :token-set-name name})))))
+
+ on-drag
+ (mf/use-fn
+ (mf/deps name)
+ (fn [_]
+ (when-not selected?
+ (on-select name))))
+
+ on-drop
+ (mf/use-fn
+ (mf/deps name)
+ (fn [position data]
+ (st/emit! (wdt/move-token-set (:name data) name position))))
+
+ on-submit-edit
+ (mf/use-fn
+ (mf/deps on-submit token-set)
+ #(on-submit (assoc token-set :name %)))
+
+ on-edit-name
+ (mf/use-fn
+ (fn [e]
+ (let [name (-> (dom/get-current-target e)
+ (dom/get-data "name"))]
+ (on-edit name))))
+ on-toggle-set (fn [event]
+ (dom/stop-propagation event)
+ (on-toggle name))
+
+ on-collapse (mf/use-fn #(swap! collapsed? not))
+
+
+ [dprops dref]
+ (h/use-sortable
+ :data-type "penpot/token-set"
+ :on-drag on-drag
+ :on-drop on-drop
+ :data {:name name}
+ :draggable? true)]
+ [:div {:ref dref
+ :role "button"
+ :class (stl/css-case :set-item-container true
+ :dnd-over (= (:over dprops) :center)
+ :dnd-over-top (= (:over dprops) :top)
+ :dnd-over-bot (= (:over dprops) :bot))
+ :on-click on-click
+ :on-double-click on-edit-name
+ :on-context-menu on-context-menu
+ :data-name name}
+ [:div {:class (stl/css-case :set-item-group group?
+ :set-item-set set?
+ :selected-set selected?)}
+ (when group?
+ [:> icon-button* {:on-click on-collapse
+ :aria-label (tr "labels.collapse")
+ :icon (if @collapsed?
+ "arrow-right"
+ "arrow-down")
+ :variant "action"}])
+
+ [:> icon* {:id (if set? "document" "group")
+ :class (stl/css :icon)}]
+ (if editing-node?
+ [:& editing-node {:default-value name
+ :on-submit on-submit-edit
+ :on-cancel on-cancel}]
+ [:*
+ [:div {:class (stl/css :set-name)} name]
+ (if set?
+ [:button {:on-click on-toggle-set
+ :class (stl/css-case :checkbox-style true
+ :checkbox-checked-style visible?)}
+ (when visible?
+ [:> icon* {:aria-label (tr "workspace.token.select-set")
+ :class (stl/css :check-icon)
+ :size "s"
+ :id ic/tick}])]
+ nil
+ #_(when (and children (not @collapsed?))
+ [:div {:class (stl/css :set-children)}
+ (for [child-id children]
+ [:& sets-tree (assoc props :key child-id
+ {:key child-id}
+ :set-id child-id
+ :selected-set-id selected-token-set-id)])]))])]]))
+
+(defn warn-on-try-create-token-set-group! []
+ (st/emit! (ntf/show {:content (tr "workspace.token.grouping-set-alert")
+ :notification-type :toast
+ :type :warning
+ :timeout 3000})))
+
+(mf/defc controlled-sets-list
+ [{:keys [token-sets
+ on-update-token-set
+ token-set-selected?
+ token-set-active?
+ on-create-token-set
+ on-toggle-token-set
+ origin
+ on-select
+ context]
+ :as _props}]
+ (let [{:keys [editing? new? on-edit on-create on-reset] :as ctx} (or context (sets-context/use-context))
+ avoid-token-set-grouping #(str/replace % "/" "-")
+ submit-token
+ #(do
+ ;; TODO: We don't support set grouping for now so we rename sets for now
+ (when (str/includes? (:name %) "/")
+ (warn-on-try-create-token-set-group!))
+ (on-create-token-set (update % :name avoid-token-set-grouping))
+ (on-reset))]
+ [:ul {:class (stl/css :sets-list)}
+ (if (and
+ (= origin "theme-modal")
+ (empty? token-sets))
+ [:> text* {:as "span" :typography "body-small" :class (stl/css :empty-state-message-sets)}
+ (tr "workspace.token.no-sets-create")]
+ (for [token-set token-sets]
+ (when token-set
+ (let [update-token
+ #(do
+ ;; TODO: We don't support set grouping for now so we rename sets for now
+ (when (str/includes? (:name %) "/")
+ (warn-on-try-create-token-set-group!))
+ (on-update-token-set (avoid-token-set-grouping (:name token-set)) (update % :name avoid-token-set-grouping))
+ (on-reset))]
+ [:& sets-tree
+ {:key (:name token-set)
+ :token-set token-set
+ :token-set-selected? (if new? (constantly false) token-set-selected?)
+ :token-set-active? token-set-active?
+ :editing? editing?
+ :on-select on-select
+ :on-edit on-edit
+ :on-toggle on-toggle-token-set
+ :on-submit update-token
+ :on-cancel on-reset}]))))
+
+ (when new?
+ [:& sets-tree
+ {:token-set {:name ""}
+ :token-set-selected? (constantly true)
+ :token-set-active? (constantly true)
+ :editing? (constantly true)
+ :on-select (constantly nil)
+ :on-edit on-create
+ :on-submit submit-token
+ :on-cancel on-reset}])]))
+
+(mf/defc sets-list
+ [{:keys []}]
+ (let [token-sets (mf/deref refs/workspace-ordered-token-sets)
+ selected-token-set-id (mf/deref refs/workspace-selected-token-set-id)
+ token-set-selected? (mf/use-fn
+ (mf/deps token-sets selected-token-set-id)
+ (fn [set-name]
+ (= set-name selected-token-set-id)))
+ active-token-set-ids (mf/deref refs/workspace-active-set-names)
+ token-set-active? (mf/use-fn
+ (mf/deps active-token-set-ids)
+ (fn [id]
+ (get active-token-set-ids id)))]
+ [:& controlled-sets-list
+ {:token-sets token-sets
+ :token-set-selected? token-set-selected?
+ :token-set-active? token-set-active?
+ :on-select on-select-token-set-click
+ :origin "set-panel"
+ :on-toggle-token-set on-toggle-token-set-click
+ :on-update-token-set on-update-token-set
+ :on-create-token-set on-create-token-set}]))
diff --git a/frontend/src/app/main/ui/workspace/tokens/sets.scss b/frontend/src/app/main/ui/workspace/tokens/sets.scss
new file mode 100644
index 000000000..24a18a77e
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/sets.scss
@@ -0,0 +1,125 @@
+// 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
+
+@import "refactor/common-refactor.scss";
+
+.sets-list {
+ width: 100%;
+ margin-bottom: 0;
+ overflow-y: auto;
+}
+
+.set-item-container {
+ width: 100%;
+ cursor: pointer;
+ color: var(--layer-row-foreground-color);
+ padding-left: $s-20;
+ border: $s-2 solid transparent;
+
+ &.dnd-over-bot {
+ border-bottom: $s-2 solid var(--layer-row-foreground-color-hover);
+ }
+ &.dnd-over-top {
+ border-top: $s-2 solid var(--layer-row-foreground-color-hover);
+ }
+ &.dnd-over {
+ border: $s-2 solid var(--layer-row-foreground-color-hover);
+ }
+}
+
+.set-item-set,
+.set-item-group {
+ @include bodySmallTypography;
+ display: flex;
+ align-items: center;
+ min-height: $s-32;
+ width: 100%;
+ cursor: pointer;
+ color: var(--layer-row-foreground-color);
+}
+
+.set-name {
+ @include textEllipsis;
+ flex-grow: 1;
+ padding-left: $s-2;
+}
+
+.icon {
+ display: flex;
+ align-items: center;
+ width: $s-20;
+ height: $s-20;
+ padding-right: $s-4;
+}
+
+.checkbox-style {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: $s-16;
+ height: $s-16;
+ margin-inline: $s-6;
+ background-color: var(--input-checkbox-background-color-rest);
+ border: 1px solid var(--input-checkbox-border-color-rest);
+ border-radius: 0.25rem;
+ padding: 0;
+}
+
+.checkbox-checked-style {
+ background-color: var(--input-border-color-active);
+}
+
+.check-icon {
+ color: var(--color-background-secondary);
+}
+
+.set-item-set:hover {
+ background-color: var(--layer-row-background-color-hover);
+ color: var(--layer-row-foreground-color-hover);
+ box-shadow: -100px 0 0 0 var(--layer-row-background-color-hover);
+}
+
+.empty-state-message-sets {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 100%;
+ padding: $s-12;
+ color: var(--color-foreground-secondary);
+}
+.selected-set {
+ background-color: var(--layer-row-background-color-selected);
+ color: var(--layer-row-foreground-color-selected);
+ box-shadow: -100px 0 0 0 var(--layer-row-background-color-selected);
+}
+
+.collapsabled-icon {
+ @include buttonStyle;
+ @include flexCenter;
+ height: $s-24;
+ border-radius: $br-8;
+ &:hover {
+ color: var(--title-foreground-color-hover);
+ }
+}
+
+.editing-node {
+ @include textEllipsis;
+ color: var(--layer-row-foreground-color-focus);
+}
+
+.editing-node {
+ @include textEllipsis;
+ @include bodySmallTypography;
+ @include removeInputStyle;
+ flex-grow: 1;
+ height: $s-28;
+ padding-left: $s-6;
+ margin: 0;
+ border-radius: $br-8;
+ border: $s-1 solid var(--input-border-color-focus);
+ color: var(--layer-row-foreground-color);
+}
diff --git a/frontend/src/app/main/ui/workspace/tokens/sets_context.cljs b/frontend/src/app/main/ui/workspace/tokens/sets_context.cljs
new file mode 100644
index 000000000..d2be84e0b
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/sets_context.cljs
@@ -0,0 +1,47 @@
+;; 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.workspace.tokens.sets-context
+ (:require
+ [rumext.v2 :as mf]))
+
+(def initial {:editing-id nil
+ :new? false})
+
+(def context (mf/create-context initial))
+
+(def static-context
+ {:editing? (constantly false)
+ :new? false
+ :on-edit (constantly nil)
+ :on-create (constantly nil)
+ :on-reset (constantly nil)})
+
+(mf/defc provider
+ {::mf/wrap-props false}
+ [props]
+ (let [children (unchecked-get props "children")
+ state (mf/use-state initial)]
+ [:& (mf/provider context) {:value state}
+ children]))
+
+(defn use-context []
+ (let [ctx (mf/use-ctx context)
+ {:keys [editing-id new?]} @ctx
+ editing? (mf/use-callback
+ (mf/deps editing-id)
+ #(= editing-id %))
+ on-edit (mf/use-fn
+ #(swap! ctx assoc :editing-id %))
+ on-create (mf/use-fn
+ #(swap! ctx assoc :editing-id (random-uuid) :new? true))
+ on-reset (mf/use-fn
+ #(reset! ctx initial))]
+ {:editing? editing?
+ :new? new?
+ :on-edit on-edit
+ :on-create on-create
+ :on-reset on-reset}))
diff --git a/frontend/src/app/main/ui/workspace/tokens/sets_context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/sets_context_menu.cljs
new file mode 100644
index 000000000..1b396740d
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/sets_context_menu.cljs
@@ -0,0 +1,65 @@
+;; 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.workspace.tokens.sets-context-menu
+ (:require-macros [app.main.style :as stl])
+ (:require
+ [app.main.data.tokens :as wdt]
+ [app.main.refs :as refs]
+ [app.main.store :as st]
+ [app.main.ui.components.dropdown :refer [dropdown]]
+ [app.main.ui.workspace.tokens.sets-context :as sets-context]
+ [app.util.dom :as dom]
+ [app.util.i18n :refer [tr]]
+ [okulary.core :as l]
+ [rumext.v2 :as mf]))
+
+(def sets-menu-ref
+ (l/derived :token-set-context-menu refs/workspace-local))
+
+(defn- prevent-default
+ [event]
+ (dom/prevent-default event)
+ (dom/stop-propagation event))
+
+(mf/defc menu-entry
+ {::mf/props :obj}
+ [{:keys [title value on-click]}]
+ [:li
+ {:class (stl/css :context-menu-item)
+ :data-value value
+ :on-click on-click}
+ [:span {:class (stl/css :title)} title]])
+
+(mf/defc menu
+ [{:keys [token-set-name]}]
+ (let [{:keys [on-edit]} (sets-context/use-context)
+ edit-name (mf/use-fn #(on-edit token-set-name))
+ delete-set (mf/use-fn #(st/emit! (wdt/delete-token-set token-set-name)))]
+ [:ul {:class (stl/css :context-list)}
+ [:& menu-entry {:title (tr "labels.rename") :on-click edit-name}]
+ [:& menu-entry {:title (tr "labels.delete") :on-click delete-set}]]))
+
+(mf/defc sets-context-menu
+ []
+ (let [mdata (mf/deref sets-menu-ref)
+ top (+ (get-in mdata [:position :y]) 5)
+ left (+ (get-in mdata [:position :x]) 5)
+ width (mf/use-state 0)
+ dropdown-ref (mf/use-ref)
+ token-set-name (:token-set-name mdata)]
+ (mf/use-effect
+ (mf/deps mdata)
+ (fn []
+ (when-let [node (mf/ref-val dropdown-ref)]
+ (reset! width (.-offsetWidth node)))))
+ [:& dropdown {:show (boolean mdata)
+ :on-close #(st/emit! wdt/hide-token-set-context-menu)}
+ [:div {:class (stl/css :token-set-context-menu)
+ :ref dropdown-ref
+ :style {:top top :left left}
+ :on-context-menu prevent-default}
+ [:& menu {:token-set-name token-set-name}]]]))
diff --git a/frontend/src/app/main/ui/workspace/tokens/sets_context_menu.scss b/frontend/src/app/main/ui/workspace/tokens/sets_context_menu.scss
new file mode 100644
index 000000000..ccf1b7bea
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/sets_context_menu.scss
@@ -0,0 +1,46 @@
+// 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
+
+@import "refactor/common-refactor.scss";
+
+.token-set-context-menu {
+ position: absolute;
+ z-index: $z-index-4;
+}
+
+.context-list {
+ @include menuShadow;
+ display: grid;
+ width: $s-240;
+ padding: $s-4;
+ border-radius: $br-8;
+ border: $s-2 solid var(--panel-border-color);
+ background-color: var(--menu-background-color);
+ max-height: 100vh;
+ overflow-y: auto;
+
+ li {
+ @include bodySmallTypography;
+ color: var(--menu-foreground-color);
+ }
+}
+
+.context-menu-item {
+ display: flex;
+ align-items: center;
+ height: $s-28;
+ width: 100%;
+ padding: $s-6;
+ border-radius: $br-8;
+ cursor: pointer;
+
+ &:hover {
+ background-color: var(--menu-background-color-hover);
+ .title {
+ color: var(--menu-foreground-color-hover);
+ }
+ }
+}
diff --git a/frontend/src/app/main/ui/workspace/tokens/sidebar.cljs b/frontend/src/app/main/ui/workspace/tokens/sidebar.cljs
new file mode 100644
index 000000000..74b6bc02b
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/sidebar.cljs
@@ -0,0 +1,360 @@
+;; 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.workspace.tokens.sidebar
+ (:require-macros [app.main.style :as stl])
+ (:require
+ [app.common.data :as d]
+ [app.common.types.tokens-lib :as ctob]
+ [app.main.data.modal :as modal]
+ [app.main.data.notifications :as ntf]
+ [app.main.data.tokens :as dt]
+ [app.main.refs :as refs]
+ [app.main.store :as st]
+ [app.main.ui.components.color-bullet :refer [color-bullet]]
+ [app.main.ui.components.dropdown-menu :refer [dropdown-menu dropdown-menu-item*]]
+ [app.main.ui.components.title-bar :refer [title-bar]]
+ [app.main.ui.ds.buttons.button :refer [button*]]
+ [app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
+ [app.main.ui.ds.foundations.typography.text :refer [text*]]
+ [app.main.ui.hooks :as h]
+ [app.main.ui.hooks.resize :refer [use-resize-hook]]
+ [app.main.ui.icons :as i]
+ [app.main.ui.workspace.sidebar.assets.common :as cmm]
+ [app.main.ui.workspace.tokens.changes :as wtch]
+ [app.main.ui.workspace.tokens.context-menu :refer [token-context-menu]]
+ [app.main.ui.workspace.tokens.errors :as wte]
+ [app.main.ui.workspace.tokens.sets :refer [sets-list]]
+ [app.main.ui.workspace.tokens.sets-context :as sets-context]
+ [app.main.ui.workspace.tokens.sets-context-menu :refer [sets-context-menu]]
+ [app.main.ui.workspace.tokens.style-dictionary :as sd]
+ [app.main.ui.workspace.tokens.theme-select :refer [theme-select]]
+ [app.main.ui.workspace.tokens.token :as wtt]
+ [app.main.ui.workspace.tokens.token-types :as wtty]
+ [app.util.dom :as dom]
+ [app.util.i18n :refer [tr]]
+ [app.util.webapi :as wapi]
+ [beicon.v2.core :as rx]
+ [cuerdas.core :as str]
+ [okulary.core :as l]
+ [rumext.v2 :as mf]
+ [shadow.resource]))
+
+(def lens:token-type-open-status
+ (l/derived (l/in [:workspace-tokens :open-status]) st/state))
+
+(def ^:private download-icon
+ (i/icon-xref :download (stl/css :download-icon)))
+
+;; Components ------------------------------------------------------------------
+
+(mf/defc token-pill
+ {::mf/wrap-props false}
+ [{:keys [on-click token theme-token highlighted? on-context-menu]}]
+ (let [{:keys [name value resolved-value errors]} token
+ errors? (and (seq errors) (seq (:errors theme-token)))]
+ [:button
+ {:class (stl/css-case :token-pill true
+ :token-pill-highlighted highlighted?
+ :token-pill-invalid errors?)
+ :title (cond
+ errors? (sd/humanize-errors token)
+ :else (->> [(str "Token: " name)
+ (str (tr "workspace.token.original-value") value)
+ (str (tr "workspace.token.resolved-value") resolved-value)]
+ (str/join "\n")))
+ :on-click on-click
+ :on-context-menu on-context-menu
+ :disabled errors?}
+ (when-let [color (if (seq (ctob/find-token-value-references (:value token)))
+ (wtt/resolved-value-hex theme-token)
+ (wtt/resolved-value-hex token))]
+ [:& color-bullet {:color color
+ :mini? true}])
+ name]))
+
+(mf/defc token-section-icon
+ {::mf/wrap-props false}
+ [{:keys [type]}]
+ (case type
+ :border-radius i/corner-radius
+ :numeric [:span {:class (stl/css :section-text-icon)} "123"]
+ :color i/drop-icon
+ :boolean i/boolean-difference
+ :opacity [:span {:class (stl/css :section-text-icon)} "%"]
+ :rotation i/rotation
+ :spacing i/padding-extended
+ :string i/text-mixed
+ :stroke-width i/stroke-size
+ :typography i/text
+ ;; TODO: Add diagonal icon here when it's available
+ :dimensions [:div {:style {:rotate "45deg"}} i/constraint-horizontal]
+ :sizing [:div {:style {:rotate "45deg"}} i/constraint-horizontal]
+ i/add))
+
+(mf/defc token-component
+ [{:keys [type tokens selected-shapes token-type-props active-theme-tokens]}]
+ (let [open? (mf/deref (-> (l/key type)
+ (l/derived lens:token-type-open-status)))
+ {:keys [modal attributes all-attributes title]} token-type-props
+
+ on-context-menu (mf/use-fn
+ (fn [event token]
+ (dom/prevent-default event)
+ (dom/stop-propagation event)
+ (st/emit! (dt/show-token-context-menu {:type :token
+ :position (dom/get-client-position event)
+ :token-name (:name token)}))))
+
+ on-toggle-open-click (mf/use-fn
+ (mf/deps open? tokens)
+ #(st/emit! (dt/set-token-type-section-open type (not open?))))
+ on-popover-open-click (mf/use-fn
+ (fn [event]
+ (mf/deps type title)
+ (let [{:keys [key fields]} modal]
+ (dom/stop-propagation event)
+ (st/emit! (dt/set-token-type-section-open type true))
+ (modal/show! key {:x (.-clientX ^js event)
+ :y (.-clientY ^js event)
+ :position :right
+ :fields fields
+ :title title
+ :action "create"
+ :token-type type}))))
+
+ on-token-pill-click (mf/use-fn
+ (mf/deps selected-shapes token-type-props)
+ (fn [event token]
+ (dom/stop-propagation event)
+ (when (seq selected-shapes)
+ (st/emit!
+ (wtch/toggle-token {:token token
+ :shapes selected-shapes
+ :token-type-props token-type-props})))))
+ tokens-count (count tokens)]
+ [:div {:on-click on-toggle-open-click}
+ [:& cmm/asset-section {:icon (mf/fnc icon-wrapper []
+ [:div {:class (stl/css :section-icon)}
+ [:& token-section-icon {:type type}]])
+ :title title
+ :assets-count tokens-count
+ :open? open?}
+ [:& cmm/asset-section-block {:role :title-button}
+ [:button {:class (stl/css :action-button)
+ :on-click on-popover-open-click}
+ i/add]]
+ (when open?
+ [:& cmm/asset-section-block {:role :content}
+ [:div {:class (stl/css :token-pills-wrapper)}
+ (for [token (sort-by :name tokens)]
+ (let [theme-token (get active-theme-tokens (wtt/token-identifier token))]
+ [:& token-pill
+ {:key (:name token)
+ :token token
+ :theme-token theme-token
+ :highlighted? (wtt/shapes-token-applied? token selected-shapes (or all-attributes attributes))
+ :on-click #(on-token-pill-click % token)
+ :on-context-menu #(on-context-menu % token)}]))]])]]))
+
+(defn sorted-token-groups
+ "Separate token-types into groups of `:empty` or `:filled` depending if tokens exist for that type.
+ Sort each group alphabetically (by their `:token-key`)."
+ [tokens]
+ (let [tokens-by-type (ctob/group-by-type tokens)
+ {:keys [empty filled]} (->> wtty/token-types
+ (map (fn [[token-key token-type-props]]
+ {:token-key token-key
+ :token-type-props token-type-props
+ :tokens (get tokens-by-type token-key [])}))
+ (group-by (fn [{:keys [tokens]}]
+ (if (empty? tokens) :empty :filled))))]
+ {:empty (sort-by :token-key empty)
+ :filled (sort-by :token-key filled)}))
+
+(mf/defc themes-header
+ [_props]
+ (let [ordered-themes (mf/deref refs/workspace-token-themes-no-hidden)
+ open-modal
+ (mf/use-fn
+ (fn [e]
+ (dom/stop-propagation e)
+ (modal/show! :tokens/themes {})))]
+ [:div {:class (stl/css :themes-wrapper)}
+ [:span {:class (stl/css :themes-header)} (tr "labels.themes")]
+ (if (empty? ordered-themes)
+ [:div {:class (stl/css :empty-theme-wrapper)}
+ [:> text* {:as "span" :typography "body-small" :class (stl/css :empty-state-message)}
+ (tr "workspace.token.no-themes")]
+ [:button {:on-click open-modal
+ :class (stl/css :create-theme-button)}
+ (tr "workspace.token.create-one")]]
+ [:div {:class (stl/css :theme-select-wrapper)}
+ [:& theme-select]
+ [:> button* {:variant "secondary"
+ :on-click open-modal}
+ (tr "labels.edit")]])]))
+
+(mf/defc add-set-button
+ [{:keys [on-open style]}]
+ (let [{:keys [on-create]} (sets-context/use-context)
+ on-click #(do
+ (on-open)
+ (on-create))]
+ (if (= style "inline")
+ [:button {:on-click on-click
+ :class (stl/css :create-theme-button)}
+ (tr "workspace.token.create-one")]
+ [:> icon-button* {:variant "ghost"
+ :icon "add"
+ :on-click on-click
+ :aria-label (tr "workspace.token.add set")}])))
+
+(mf/defc themes-sets-tab
+ []
+ (let [token-sets (mf/deref refs/workspace-ordered-token-sets)
+ open? (mf/use-state true)
+ on-open (mf/use-fn #(reset! open? true))]
+ [:& sets-context/provider {}
+ [:& sets-context-menu]
+ [:div {:class (stl/css :sets-sidebar)}
+ [:& themes-header]
+ [:div {:class (stl/css :sidebar-header)}
+ [:& title-bar {:collapsable true
+ :collapsed (not @open?)
+ :all-clickable true
+ :title (tr "labels.sets")
+ :on-collapsed #(swap! open? not)}
+ [:& add-set-button {:on-open on-open
+ :style "header"}]]]
+ (when @open?
+ [:& h/sortable-container {}
+ [:*
+ (when (empty? token-sets)
+ [:div {:class (stl/css :empty-sets-wrapper)}
+ [:> text* {:as "span" :typography "body-small" :class (stl/css :empty-state-message)}
+ (tr "workspace.token.no-sets-yet")]
+ [:& add-set-button {:on-open on-open
+ :style "inline"}]])
+ [:& sets-list]]])]]))
+
+(mf/defc tokens-tab
+ [_props]
+ (let [objects (mf/deref refs/workspace-page-objects)
+
+ selected (mf/deref refs/selected-shapes)
+ selected-shapes (into [] (keep (d/getf objects)) selected)
+
+ active-theme-tokens (sd/use-active-theme-sets-tokens)
+
+ tokens (sd/use-resolved-workspace-tokens)
+ token-groups (mf/with-memo [tokens]
+ (sorted-token-groups tokens))]
+ [:*
+ [:& token-context-menu]
+ [:& title-bar {:all-clickable true
+ :title "TOKENS"}]
+ [:div.assets-bar
+ (for [{:keys [token-key token-type-props tokens]} (concat (:filled token-groups)
+ (:empty token-groups))]
+ [:& token-component {:key token-key
+ :type token-key
+ :selected-shapes selected-shapes
+ :active-theme-tokens active-theme-tokens
+ :tokens tokens
+ :token-type-props token-type-props}])]]))
+
+(mf/defc json-import-button []
+ (let []
+ [:div
+
+ [:button {:class (stl/css :download-json-button)
+ :on-click #(.click (js/document.getElementById "file-input"))}
+ download-icon
+ "Import JSON"]]))
+
+(mf/defc import-export-button
+ {::mf/wrap-props false}
+ [{:keys []}]
+ (let [show-menu* (mf/use-state false)
+ show-menu? (deref show-menu*)
+
+ open-menu
+ (mf/use-fn
+ (fn [event]
+ (dom/stop-propagation event)
+ (reset! show-menu* true)))
+
+ close-menu
+ (mf/use-fn
+ (fn [event]
+ (dom/stop-propagation event)
+ (reset! show-menu* false)))
+
+ input-ref (mf/use-ref)
+ on-import
+ (fn [event]
+ (let [file (-> event .-target .-files (aget 0))]
+ (->> (wapi/read-file-as-text file)
+ (sd/process-json-stream)
+ (rx/subs! (fn [lib]
+ (st/emit! (dt/import-tokens-lib lib)))
+ (fn [err]
+ (js/console.error err)
+ (st/emit! (ntf/show {:content (wte/humanize-errors [(ex-data err)])
+ :type :toast
+ :level :warning
+ :timeout 9000})))))
+ (set! (.-value (mf/ref-val input-ref)) "")))
+ on-export (fn []
+ (let [tokens-blob (some-> (deref refs/tokens-lib)
+ (ctob/encode-dtcg)
+ (clj->js)
+ (js/JSON.stringify nil 2)
+ (wapi/create-blob "application/json"))]
+ (dom/trigger-download "tokens.json" tokens-blob)))]
+ [:div {:class (stl/css :import-export-button-wrapper)}
+ [:input {:type "file"
+ :ref input-ref
+ :style {:display "none"}
+ :id "file-input"
+ :accept ".json"
+ :on-change on-import}]
+ [:button {:class (stl/css :import-export-button)
+ :on-click open-menu}
+ download-icon
+ "Tokens"]
+ [:& dropdown-menu {:show show-menu?
+ :on-close close-menu
+ :list-class (stl/css :import-export-menu)}
+ [:> dropdown-menu-item* {:class (stl/css :import-export-menu-item)
+ :on-click #(.click (mf/ref-val input-ref))}
+ "Import"]
+
+ [:> dropdown-menu-item* {:class (stl/css :import-export-menu-item)
+ :on-click on-export}
+ "Export"]]]))
+
+(mf/defc tokens-sidebar-tab
+ {::mf/wrap [mf/memo]
+ ::mf/wrap-props false}
+ [_props]
+ (let [{on-pointer-down-pages :on-pointer-down
+ on-lost-pointer-capture-pages :on-lost-pointer-capture
+ on-pointer-move-pages :on-pointer-move
+ size-pages-opened :size}
+ (use-resize-hook :tokens 200 38 400 :y false nil)]
+ [:div {:class (stl/css :sidebar-wrapper)}
+ [:article {:class (stl/css :sets-section-wrapper)
+ :style {"--resize-height" (str size-pages-opened "px")}}
+ [:& themes-sets-tab]]
+ [:article {:class (stl/css :tokens-section-wrapper)}
+ [:div {:class (stl/css :resize-area-horiz)
+ :on-pointer-down on-pointer-down-pages
+ :on-lost-pointer-capture on-lost-pointer-capture-pages
+ :on-pointer-move on-pointer-move-pages}]
+ [:& tokens-tab]
+ [:& import-export-button]]]))
diff --git a/frontend/src/app/main/ui/workspace/tokens/sidebar.scss b/frontend/src/app/main/ui/workspace/tokens/sidebar.scss
new file mode 100644
index 000000000..d3b0c6c23
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/sidebar.scss
@@ -0,0 +1,196 @@
+// 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 "../../ds/typography.scss" as *;
+@import "refactor/common-refactor.scss";
+@import "./common.scss";
+
+.sidebar-wrapper {
+ display: grid;
+ grid-template-rows: auto auto 1fr;
+ // Overflow on the bottom section can't be done without hardcoded values for the height
+ // This has to be changed from the wrapping sidebar styles
+ height: calc(100vh - #{$s-84});
+ overflow: hidden;
+}
+
+.sets-section-wrapper {
+ position: relative;
+ display: flex;
+ flex: 1;
+ height: var(--resize-height);
+ flex-direction: column;
+ overflow-y: auto;
+ scrollbar-gutter: stable;
+}
+
+.tokens-section-wrapper {
+ height: 100%;
+ padding-left: $s-12;
+ overflow-y: auto;
+ scrollbar-gutter: stable;
+}
+
+.sets-sidebar {
+ position: relative;
+}
+
+.themes-header {
+ display: block;
+ @include headlineSmallTypography;
+ margin-bottom: $s-8;
+ padding-left: $s-8;
+ color: var(--title-foreground-color);
+}
+
+.themes-wrapper {
+ padding: $s-12 0 0 $s-12;
+}
+
+.empty-theme-wrapper {
+ padding: $s-12;
+ color: var(--color-foreground-secondary);
+}
+
+.empty-sets-wrapper {
+ padding: $s-12;
+ padding-inline-start: $s-24;
+ color: var(--color-foreground-secondary);
+}
+
+.sidebar-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-left: $s-8;
+ padding-top: $s-12;
+ color: var(--layer-row-foreground-color);
+}
+
+.empty-state-message {
+ color: var(--color-foreground-secondary);
+}
+
+.token-pills-wrapper {
+ display: flex;
+ gap: $s-4;
+ flex-wrap: wrap;
+}
+
+.token-pill {
+ @extend .button-secondary;
+ gap: $s-8;
+ padding: $s-4 $s-8;
+ border-radius: $br-6;
+ font-size: $fs-14;
+
+ &.token-pill-highlighted {
+ color: var(--button-primary-foreground-color-rest);
+ background: var(--button-primary-background-color-rest);
+ }
+
+ &.token-pill-invalid {
+ background-color: var(--button-secondary-background-color-rest);
+ color: var(--status-color-error-500);
+ opacity: 0.8;
+ }
+}
+
+.section-text-icon {
+ font-size: $fs-12;
+ width: 16px;
+ height: 16px;
+ display: flex;
+ place-content: center;
+}
+
+.section-icon {
+ margin-right: $s-4;
+ // Align better with the label
+ translate: 0px -1px;
+}
+
+.import-export-button-wrapper {
+ position: absolute;
+ bottom: $s-12;
+ right: $s-12;
+}
+
+.import-export-button {
+ @extend .button-secondary;
+ display: flex;
+ align-items: center;
+ padding: $s-6 $s-8;
+ text-transform: uppercase;
+ gap: $s-8;
+
+ .download-icon {
+ @extend .button-icon;
+ stroke: var(--icon-foreground);
+ width: 20px;
+ height: 20px;
+ }
+}
+
+.import-export-menu {
+ @extend .menu-dropdown;
+ top: -#{$s-6};
+ right: 0;
+ translate: 0 -100%;
+ width: $s-192;
+ margin: 0;
+}
+
+.import-export-menu-item {
+ @extend .menu-item-base;
+ cursor: pointer;
+ .open-arrow {
+ @include flexCenter;
+ svg {
+ @extend .button-icon;
+ stroke: var(--icon-foreground);
+ }
+ }
+ &:hover {
+ color: var(--menu-foreground-color-hover);
+ .open-arrow {
+ svg {
+ stroke: var(--menu-foreground-color-hover);
+ }
+ }
+ .shortcut-key {
+ color: var(--menu-shortcut-foreground-color-hover);
+ }
+ }
+}
+
+.theme-select-wrapper {
+ display: grid;
+ grid-template-columns: 1fr 0.28fr;
+ gap: $s-6;
+}
+
+.themes-button {
+ @extend .button-secondary;
+ width: auto;
+}
+
+.create-theme-button {
+ @include use-typography("body-small");
+ background-color: transparent;
+ border: none;
+ appearance: none;
+ color: var(--color-accent-primary);
+ cursor: pointer;
+}
+
+.resize-area-horiz {
+ position: absolute;
+ left: 0;
+ width: 100%;
+ border-bottom: $s-2 solid var(--resize-area-border-color);
+ cursor: ns-resize;
+}
diff --git a/frontend/src/app/main/ui/workspace/tokens/style_dictionary.cljs b/frontend/src/app/main/ui/workspace/tokens/style_dictionary.cljs
new file mode 100644
index 000000000..72771c4fa
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/style_dictionary.cljs
@@ -0,0 +1,261 @@
+(ns app.main.ui.workspace.tokens.style-dictionary
+ (:require
+ ["@tokens-studio/sd-transforms" :as sd-transforms]
+ ["style-dictionary$default" :as sd]
+ [app.common.logging :as l]
+ [app.common.transit :as t]
+ [app.common.types.tokens-lib :as ctob]
+ [app.main.refs :as refs]
+ [app.main.ui.workspace.tokens.errors :as wte]
+ [app.main.ui.workspace.tokens.tinycolor :as tinycolor]
+ [app.main.ui.workspace.tokens.token :as wtt]
+ [beicon.v2.core :as rx]
+ [cuerdas.core :as str]
+ [promesa.core :as p]
+ [rumext.v2 :as mf]))
+
+(l/set-level! "app.main.ui.workspace.tokens.style-dictionary" :warn)
+
+;; === Style Dictionary
+
+(def setup-style-dictionary
+ "Initiates the StyleDictionary instance.
+ Setup transforms from tokens-studio used to parse and resolved token values."
+ (do
+ (sd-transforms/registerTransforms sd)
+ (.registerFormat sd #js {:name "custom/json"
+ :format (fn [^js res]
+ (.-tokens (.-dictionary res)))})
+ sd))
+
+(def default-config
+ {:platforms {:json
+ {:transformGroup "tokens-studio"
+ ;; Required: The StyleDictionary API is focused on files even when working in the browser
+ :files [{:format "custom/json" :destination "penpot"}]}}
+ :preprocessors ["tokens-studio"]
+ ;; Silences style dictionary logs and errors
+ ;; We handle token errors in the UI
+ :log {:verbosity "silent"
+ :warnings "silent"
+ :errors {:brokenReferences "console"}}})
+
+(defn parse-sd-token-color-value
+ "Parses `value` of a color `sd-token` into a map like `{:value 1 :unit \"px\"}`.
+ If the value is not parseable and/or has missing references returns a map with `:errors`."
+ [value]
+ (if-let [tc (tinycolor/valid-color value)]
+ {:value value :unit (tinycolor/color-format tc)}
+ {:errors [(wte/error-with-value :error.token/invalid-color value)]}))
+
+(defn parse-sd-token-dimensions-value
+ "Parses `value` of a dimensions `sd-token` into a map like `{:value 1 :unit \"px\"}`.
+ If the `value` is not parseable and/or has missing references returns a map with `:errors`."
+ [value]
+ (or
+ (wtt/parse-token-value value)
+ (if-let [references (seq (ctob/find-token-value-references value))]
+ {:errors [(wte/error-with-value :error.style-dictionary/missing-reference references)]
+ :references references}
+ {:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value value)]})))
+
+(defn process-sd-tokens
+ "Converts a StyleDictionary dictionary with resolved tokens (aka `sd-tokens`) back to clojure.
+ The `get-origin-token` argument should be a function that takes an
+ `sd-token` and returns the original penpot token, so we can merge
+ the resolved attributes back in.
+
+ The `sd-token` will have references in `value` replaced with the computed value as a string.
+ Here's an example for a `sd-token`:
+ ```js
+ {
+ name: 'token.with.reference',
+ value: '12px',
+ type: 'border-radius',
+ path: ['token', 'with', 'reference'],
+
+ // The penpot origin token converted to a js object
+ original: {
+ name: 'token.with.reference',
+ value: '{referenced.token}',
+ type: 'border-radius'
+ },
+ }
+ ```
+
+ We also convert `sd-token` value string into a unit that can be used as penpot shape attributes.
+ - Dimensions like '12px' will be converted into numbers
+ - Colors will be validated & converted to hex
+
+ Lastly we check for errors in each token
+ `sd-token` will keep the missing references in the `value` (E.g \"{missing} + {existing}\" -> \"{missing} + 12px\")
+ So we parse out the missing references and add them to `:errors` in the final token."
+ [sd-tokens get-origin-token]
+ (reduce
+ (fn [acc ^js sd-token]
+ (let [origin-token (get-origin-token sd-token)
+ value (.-value sd-token)
+ parsed-token-value (case (:type origin-token)
+ :color (parse-sd-token-color-value value)
+ (parse-sd-token-dimensions-value value))
+ output-token (if (:errors parsed-token-value)
+ (merge origin-token parsed-token-value)
+ (assoc origin-token
+ :resolved-value (:value parsed-token-value)
+ :unit (:unit parsed-token-value)))]
+ (assoc acc (:name output-token) output-token)))
+ {} sd-tokens))
+
+(defprotocol IStyleDictionary
+ (add-tokens [_ tokens])
+ (enable-debug [_])
+ (get-config [_])
+ (build-dictionary [_]))
+
+(deftype StyleDictionary [config]
+ IStyleDictionary
+ (add-tokens [_ tokens]
+ (StyleDictionary. (assoc config :tokens tokens)))
+
+ (enable-debug [_]
+ (StyleDictionary. (update config :log merge {:verbosity "verbose"})))
+
+ (get-config [_]
+ config)
+
+ (build-dictionary [_]
+ (-> (sd. (clj->js config))
+ (.buildAllPlatforms "json")
+ (p/then #(.-allTokens ^js %)))))
+
+(defn resolve-tokens-tree+
+ ([tokens-tree get-token]
+ (resolve-tokens-tree+ tokens-tree get-token (StyleDictionary. default-config)))
+ ([tokens-tree get-token style-dictionary]
+ (-> style-dictionary
+ (add-tokens tokens-tree)
+ (build-dictionary)
+ (p/then #(process-sd-tokens % get-token)))))
+
+(defn sd-token-name [^js sd-token]
+ (.. sd-token -original -name))
+
+(defn sd-token-uuid [^js sd-token]
+ (uuid (.-uuid (.-id ^js sd-token))))
+
+(defn resolve-tokens+ [tokens]
+ (resolve-tokens-tree+ (ctob/tokens-tree tokens) #(get tokens (sd-token-name %))))
+
+(defn resolve-tokens-interactive+
+ "Interactive check of resolving tokens.
+ Uses a ids map to backtrace the original token from the resolved StyleDictionary token.
+
+ We have to pass in all tokens from all sets in the entire library to style dictionary
+ so we know if references are missing / to resolve them and possibly show interactive previews (in the tokens form) to the user.
+
+ Since we're using the :name path as the identifier we might be throwing away or overriding tokens in the tree that we pass to StyleDictionary.
+
+ So to get back the original token from the resolved sd-token (see my updates for what an sd-token is) we include a temporary :id for the token that we pass to StyleDictionary,
+ this way after the resolving computation we can restore any token, even clashing ones with the same :name path by just looking up that :id in the ids map."
+ [tokens]
+ (let [{:keys [tokens-tree ids]} (ctob/backtrace-tokens-tree tokens)]
+ (resolve-tokens-tree+ tokens-tree #(get ids (sd-token-uuid %)))))
+
+(defn resolve-tokens-with-errors+ [tokens]
+ (resolve-tokens-tree+
+ (ctob/tokens-tree tokens)
+ #(get tokens (sd-token-name %))
+ (StyleDictionary. (assoc default-config :log {:verbosity "verbose"}))))
+
+;; === Import
+
+(defn reference-errors
+ "Extracts reference errors from StyleDictionary."
+ [err]
+ (let [[header-1 header-2 & errors] (str/split err "\n")]
+ (when (and
+ (= header-1 "Error: ")
+ (= header-2 "Reference Errors:"))
+ errors)))
+
+(defn process-json-stream [data-stream]
+ (->> data-stream
+ (rx/map (fn [data]
+ (try
+ (-> (str/replace data "/" "-") ;; TODO Remove when token groups work
+ (t/decode-str))
+ (catch js/Error e
+ (throw (wte/error-ex-info :error.import/json-parse-error data e))))))
+ (rx/map (fn [json-data]
+ (try
+ (ctob/decode-dtcg-json (ctob/ensure-tokens-lib nil) json-data)
+ (catch js/Error e
+ (throw (wte/error-ex-info :error.import/invalid-json-data json-data e))))))
+ (rx/mapcat (fn [tokens-lib]
+ (try
+ (-> (ctob/get-all-tokens tokens-lib)
+ (resolve-tokens-with-errors+)
+ (p/then (fn [_] tokens-lib))
+ (p/catch (fn [sd-error]
+ (let [reference-errors (reference-errors sd-error)
+ err (if reference-errors
+ (wte/error-ex-info :error.import/style-dictionary-reference-errors reference-errors sd-error)
+ (wte/error-ex-info :error.import/style-dictionary-unknown-error sd-error sd-error))]
+ (throw err)))))
+ (catch js/Error e
+ (p/rejected (wte/error-ex-info :error.import/style-dictionary-unknown-error "" e))))))))
+
+;; === Errors
+
+(defn humanize-errors [{:keys [errors value] :as _token}]
+ (->> (map (fn [err]
+ (case err
+ :error.style-dictionary/missing-reference (str "Could not resolve reference token with the name: " value)
+ nil))
+ errors)
+ (str/join "\n")))
+
+;; === Hooks
+
+(defonce !tokens-cache (atom nil))
+
+(defonce !theme-tokens-cache (atom nil))
+
+(defn use-resolved-tokens
+ "The StyleDictionary process function is async, so we can't use resolved values directly.
+
+ This hook will return the unresolved tokens as state until they are processed,
+ then the state will be updated with the resolved tokens."
+ [tokens & {:keys [cache-atom interactive?]
+ :or {cache-atom !tokens-cache}
+ :as config}]
+ (let [tokens-state (mf/use-state (get @cache-atom tokens))]
+ (mf/use-effect
+ (mf/deps tokens config)
+ (fn []
+ (let [cached (get @cache-atom tokens)]
+ (cond
+ (nil? tokens) nil
+ ;; The tokens are already processing somewhere
+ (p/promise? cached) (-> cached
+ (p/then #(reset! tokens-state %))
+ #_(p/catch js/console.error))
+ ;; Get the cached entry
+ (some? cached) (reset! tokens-state cached)
+ ;; No cached entry, start processing
+ :else (let [promise+ (if interactive?
+ (resolve-tokens-interactive+ tokens)
+ (resolve-tokens+ tokens))]
+ (swap! cache-atom assoc tokens promise+)
+ (p/then promise+ (fn [resolved-tokens]
+ (swap! cache-atom assoc tokens resolved-tokens)
+ (reset! tokens-state resolved-tokens))))))))
+ @tokens-state))
+
+(defn use-resolved-workspace-tokens []
+ (-> (mf/deref refs/workspace-selected-token-set-tokens)
+ (use-resolved-tokens)))
+
+(defn use-active-theme-sets-tokens []
+ (-> (mf/deref refs/workspace-active-theme-sets-tokens)
+ (use-resolved-tokens {:cache-atom !theme-tokens-cache})))
diff --git a/frontend/src/app/main/ui/workspace/tokens/theme_select.cljs b/frontend/src/app/main/ui/workspace/tokens/theme_select.cljs
new file mode 100644
index 000000000..484c806c5
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/theme_select.cljs
@@ -0,0 +1,117 @@
+;; 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.workspace.tokens.theme-select
+ (:require-macros [app.main.style :as stl])
+ (:require
+ [app.common.data.macros :as dm]
+ [app.common.types.tokens-lib :as ctob]
+ [app.common.uuid :as uuid]
+ [app.main.data.modal :as modal]
+ [app.main.data.tokens :as wdt]
+ [app.main.refs :as refs]
+ [app.main.store :as st]
+ [app.main.ui.components.dropdown :refer [dropdown]]
+ [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
+ [app.main.ui.ds.foundations.typography.text :refer [text*]]
+ [app.util.dom :as dom]
+ [app.util.i18n :refer [tr]]
+ [cuerdas.core :as str]
+ [rumext.v2 :as mf]))
+
+(mf/defc themes-list
+ [{:keys [themes active-theme-paths on-close grouped?]}]
+ (when (seq themes)
+ [:ul {:class (stl/css :theme-options)}
+ (for [[_ {:keys [group name] :as theme}] themes
+ :let [theme-id (ctob/theme-path theme)
+ selected? (get active-theme-paths theme-id)
+ select-theme (fn [e]
+ (dom/stop-propagation e)
+ (st/emit! (wdt/toggle-token-theme-active? group name))
+ (on-close))]]
+ [:li {:key theme-id
+ :role "option"
+ :aria-selected selected?
+ :class (stl/css-case
+ :checked-element true
+ :sub-item grouped?
+ :is-selected selected?)
+ :on-click select-theme}
+ [:> text* {:as "span" :typography "body-small" :class (stl/css :label)} name]
+ [:> icon* {:id i/tick
+ :aria-hidden true
+ :class (stl/css-case :check-icon true
+ :check-icon-visible selected?)}]])]))
+
+(mf/defc theme-options
+ [{:keys [active-theme-paths themes on-close]}]
+ (let []
+ (let [on-edit-click #(modal/show! :tokens/themes {})]
+ [:ul {:class (stl/css :theme-options :custom-select-dropdown)
+ :role "listbox"}
+ (for [[group themes] themes]
+ [:li {:key group
+ :aria-labelledby (dm/str group "-label")
+ :role "group"}
+ (when (seq group)
+ [:> text* {:as "span" :typography "headline-small" :class (stl/css :group) :id (dm/str group "-label")} group])
+ [:& themes-list {:themes themes
+ :active-theme-paths active-theme-paths
+ :on-close on-close
+ :grouped? true}]])
+ [:li {:class (stl/css :separator)
+ :aria-hidden true}]
+ [:li {:class (stl/css-case :checked-element true
+ :checked-element-button true)
+ :role "option"
+ :on-click on-edit-click}
+ [:> text* {:as "span" :typography "body-small"} (tr "workspace.token.edit-themes")]
+ [:> icon* {:id i/arrow-right :aria-hidden true}]]])))
+
+(mf/defc theme-select
+ [{:keys []}]
+ (let [;; Store
+ active-theme-paths (mf/deref refs/workspace-active-theme-paths-no-hidden)
+ active-themes-count (count active-theme-paths)
+ themes (mf/deref refs/workspace-token-theme-tree-no-hidden)
+
+ ;; Data
+ current-label (cond
+ (> active-themes-count 1) (tr "workspace.token.active-themes" active-themes-count)
+ (= active-themes-count 1) (some->> (first active-theme-paths)
+ (ctob/split-token-theme-path)
+ (str/join " / "))
+ :else (tr "workspace.token.no-active-theme"))
+
+ ;; State
+ state* (mf/use-state
+ {:id (uuid/next)
+ :is-open? false})
+ state (deref state*)
+ is-open? (:is-open? state)
+
+ ;; Dropdown
+ dropdown-element* (mf/use-ref nil)
+ on-close-dropdown (mf/use-fn #(swap! state* assoc :is-open? false))
+ on-open-dropdown (mf/use-fn #(swap! state* assoc :is-open? true))]
+
+ ;; TODO: This element should be accessible by keyboard
+ [:div {:on-click on-open-dropdown
+ :aria-expanded is-open?
+ :aria-haspopup "listbox"
+ :tab-index "0"
+ :role "combobox"
+ :class (stl/css :custom-select)}
+ [:> text* {:as "span" :typography "body-small" :class (stl/css :current-label)}
+ current-label]
+ [:> icon* {:id i/arrow-down :class (stl/css :dropdown-button) :aria-hidden true}]
+ [:& dropdown {:show is-open?
+ :on-close on-close-dropdown
+ :ref dropdown-element*}
+ [:& theme-options {:active-theme-paths active-theme-paths
+ :themes themes
+ :on-close on-close-dropdown}]]]))
diff --git a/frontend/src/app/main/ui/workspace/tokens/theme_select.scss b/frontend/src/app/main/ui/workspace/tokens/theme_select.scss
new file mode 100644
index 000000000..79c0f3fc2
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/theme_select.scss
@@ -0,0 +1,124 @@
+// 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
+
+@import "refactor/common-refactor.scss";
+
+.custom-select {
+ --custom-select-border-color: var(--menu-background-color);
+ --custom-select-bg-color: var(--menu-background-color);
+ --custom-select-icon-color: var(--color-foreground-secondary);
+ --custom-select-text-color: var(--menu-foreground-color);
+ @extend .new-scrollbar;
+ position: relative;
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: center;
+ height: $s-32;
+ width: 100%;
+ margin: 0;
+ padding: $s-8;
+ border-radius: $br-8;
+ background-color: var(--custom-select-bg-color);
+ border: $s-1 solid var(--custom-select-border-color);
+ color: var(--custom-select-text-color);
+ cursor: pointer;
+ &:hover {
+ --custom-select-bg-color: var(--menu-background-color-hover);
+ --custom-select-border-color: var(--menu-background-color);
+ --custom-select-icon-color: var(--menu-foreground-color-hover);
+ }
+
+ &:focus {
+ --custom-select-bg-color: var(--menu-background-color-focus);
+ --custom-select-border-color: var(--menu-background-focus);
+ }
+}
+
+.theme-options {
+ margin-bottom: 0;
+}
+
+.group {
+ display: block;
+ padding: $s-8;
+ color: var(--color-foreground-secondary);
+}
+
+.disabled {
+ --custom-select-bg-color: var(--menu-background-color-disabled);
+ --custom-select-border-color: var(--menu-border-color-disabled);
+ --custom-select-icon-color: var(--menu-foreground-color-disabled);
+ --custom-select-text-color: var(--menu-foreground-color-disabled);
+ pointer-events: none;
+ cursor: default;
+}
+
+.dropdown-button {
+ @include flexCenter;
+ color: var(--color-foreground-secondary);
+}
+
+.current-icon {
+ @include flexCenter;
+ width: $s-24;
+ padding-right: $s-4;
+}
+
+.custom-select-dropdown {
+ @extend .dropdown-wrapper;
+}
+
+.separator {
+ margin: 0;
+ height: $s-2;
+ border-block-start: $s-1 solid color-mix(in hsl, var(--color-foreground-secondary) 20%, transparent);
+}
+
+.custom-select-dropdown[data-direction="up"] {
+ bottom: $s-32;
+ top: auto;
+}
+
+.sub-item {
+ padding-left: $s-16;
+}
+
+.checked-element-button {
+ @extend .dropdown-element-base;
+ position: relative;
+ display: flex;
+ justify-content: space-between;
+ padding-right: 0;
+}
+
+.checked-element {
+ @extend .dropdown-element-base;
+ &.is-selected {
+ color: var(--menu-foreground-color);
+ }
+ &.disabled {
+ display: none;
+ }
+}
+
+.check-icon {
+ @include flexCenter;
+ color: var(--icon-foreground-primary);
+ visibility: hidden;
+}
+
+.label {
+ flex-grow: 1;
+ width: 100%;
+}
+
+.check-icon-visible {
+ visibility: visible;
+}
+
+.current-label {
+ @include textEllipsis;
+}
diff --git a/frontend/src/app/main/ui/workspace/tokens/tinycolor.cljs b/frontend/src/app/main/ui/workspace/tokens/tinycolor.cljs
new file mode 100644
index 000000000..9a8d74f10
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/tinycolor.cljs
@@ -0,0 +1,27 @@
+(ns app.main.ui.workspace.tokens.tinycolor
+ "Bindings for tinycolor2 which supports a wide range of css compatible colors.
+
+ This library was chosen as it is already used by StyleDictionary,
+ so there is no extra dependency cost and there was no clojure alternatives with all the necessary features."
+ (:require
+ ["tinycolor2" :as tinycolor]))
+
+(defn tinycolor? [^js x]
+ (and (instance? tinycolor x) (.isValid x)))
+
+(defn valid-color [color-str]
+ (let [tc (tinycolor color-str)]
+ (when (.isValid tc) tc)))
+
+(defn ->hex [^js tc]
+ (assert (tinycolor? tc))
+ (.toHex tc))
+
+(defn color-format [^js tc]
+ (assert (tinycolor? tc))
+ (.getFormat tc))
+
+(comment
+ (some-> (valid-color "red") ->hex)
+ (some-> (valid-color "red") color-format)
+ nil)
diff --git a/frontend/src/app/main/ui/workspace/tokens/token.cljs b/frontend/src/app/main/ui/workspace/tokens/token.cljs
new file mode 100644
index 000000000..215f9ca51
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/token.cljs
@@ -0,0 +1,142 @@
+(ns app.main.ui.workspace.tokens.token
+ (:require
+ [app.common.data :as d]
+ [app.main.ui.workspace.tokens.tinycolor :as tinycolor]
+ [clojure.set :as set]
+ [cuerdas.core :as str]))
+
+(def parseable-token-value-regexp
+ "Regexp that can be used to parse a number value out of resolved token value.
+ This regexp also trims whitespace around the value."
+ #"^\s*(-?[0-9]+\.?[0-9]*)(px|%)?\s*$")
+
+(defn parse-token-value
+ "Parses a resolved value and separates the unit from the value.
+ Returns a map of {:value `number` :unit `string`}."
+ [value]
+ (cond
+ (number? value) {:value value}
+ (string? value) (when-let [[_ value unit] (re-find parseable-token-value-regexp value)]
+ (when-let [parsed-value (d/parse-double value)]
+ {:value parsed-value
+ :unit unit}))))
+
+(defn token-identifier [{:keys [name] :as _token}]
+ name)
+
+(defn attributes-map
+ "Creats an attributes map using collection of `attributes` for `id`."
+ [attributes token]
+ (->> (map (fn [attr] [attr (token-identifier token)]) attributes)
+ (into {})))
+
+(defn remove-attributes-for-token
+ "Removes applied tokens with `token-id` for the given `attributes` set from `applied-tokens`."
+ [attributes token applied-tokens]
+ (let [attr? (set attributes)]
+ (->> (remove (fn [[k v]]
+ (and (attr? k)
+ (= v (token-identifier token))))
+ applied-tokens)
+ (into {}))))
+
+(defn token-attribute-applied?
+ "Test if `token` is applied to a `shape` on single `token-attribute`."
+ [token shape token-attribute]
+ (when-let [id (get-in shape [:applied-tokens token-attribute])]
+ (= (token-identifier token) id)))
+
+(defn token-applied?
+ "Test if `token` is applied to a `shape` with at least one of the one of the given `token-attributes`."
+ [token shape token-attributes]
+ (some #(token-attribute-applied? token shape %) token-attributes))
+
+(defn shapes-token-applied?
+ "Test if `token` is applied to to any of `shapes` with at least one of the one of the given `token-attributes`."
+ [token shapes token-attributes]
+ (some #(token-applied? token % token-attributes) shapes))
+
+(defn shapes-ids-by-applied-attributes [token shapes token-attributes]
+ (reduce (fn [acc shape]
+ (let [applied-ids-by-attribute (->> (map #(when (token-attribute-applied? token shape %)
+ [% #{(:id shape)}])
+ token-attributes)
+ (filter some?)
+ (into {}))]
+ (merge-with into acc applied-ids-by-attribute)))
+ {} shapes))
+
+(defn shapes-applied-all? [ids-by-attributes shape-ids attributes]
+ (every? #(set/superset? (get ids-by-attributes %) shape-ids) attributes))
+
+(defn token-name->path
+ "Splits token-name into a path vector split by `.` characters.
+
+ Will concatenate multiple `.` characters into one."
+ [token-name]
+ (str/split token-name #"\.+"))
+
+(defn token-name->path-selector
+ "Splits token-name into map with `:path` and `:selector` using `token-name->path`.
+
+ `:selector` is the last item of the names path
+ `:path` is everything leading up the the `:selector`."
+ [token-name]
+ (let [path-segments (token-name->path token-name)
+ last-idx (dec (count path-segments))
+ [path [selector]] (split-at last-idx path-segments)]
+ {:path (seq path)
+ :selector selector}))
+
+(defn token-names-tree-id-map [tokens]
+ (reduce
+ (fn [acc [_ {:keys [name] :as token}]]
+ (when (string? name)
+ (let [temp-id (random-uuid)
+ token (assoc token :temp/id temp-id)]
+ (-> acc
+ (assoc-in (concat [:tree] (token-name->path name)) token)
+ (assoc-in [:ids-map temp-id] token)))))
+ {:tree {}
+ :ids-map {}}
+ tokens))
+
+(defn token-name-path-exists?
+ "Traverses the path from `token-name` down a `token-tree` and checks if a token at that path exists.
+
+ It's not allowed to create a token inside a token. E.g.:
+ Creating a token with
+
+ {:name \"foo.bar\"}
+
+ in the tokens tree:
+
+ {\"foo\" {:name \"other\"}}"
+ [token-name token-names-tree]
+ (let [{:keys [path selector]} (token-name->path-selector token-name)
+ path-target (reduce
+ (fn [acc cur]
+ (let [target (get acc cur)]
+ (cond
+ ;; Path segment doesn't exist yet
+ (nil? target) (reduced false)
+ ;; A token exists at this path
+ (:name target) (reduced true)
+ ;; Continue traversing the true
+ :else target)))
+ token-names-tree path)]
+ (cond
+ (boolean? path-target) path-target
+ (get path-target :name) true
+ :else (-> (get path-target selector)
+ (seq)
+ (boolean)))))
+
+(defn color-token? [token]
+ (= (:type token) :color))
+
+(defn resolved-value-hex [{:keys [resolved-value] :as token}]
+ (when (and resolved-value (color-token? token))
+ (some->> (tinycolor/valid-color resolved-value)
+ (tinycolor/->hex)
+ (str "#"))))
diff --git a/frontend/src/app/main/ui/workspace/tokens/token_set.cljs b/frontend/src/app/main/ui/workspace/tokens/token_set.cljs
new file mode 100644
index 000000000..9e1af19c4
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/token_set.cljs
@@ -0,0 +1,56 @@
+(ns app.main.ui.workspace.tokens.token-set
+ (:require
+ [app.common.types.tokens-lib :as ctob]))
+
+(defn get-workspace-tokens-lib [state]
+ (get-in state [:workspace-data :tokens-lib]))
+
+;; Themes ----------------------------------------------------------------------
+
+(defn get-active-theme-ids [state]
+ (get-in state [:workspace-data :token-active-themes] #{}))
+
+(defn get-temp-theme-id [state]
+ (get-in state [:workspace-data :token-theme-temporary-id]))
+
+(defn update-theme-id
+ [state]
+ (let [active-themes (get-active-theme-ids state)
+ temporary-theme-id (get-temp-theme-id state)]
+ (cond
+ (empty? active-themes) temporary-theme-id
+ (= 1 (count active-themes)) (first active-themes)
+ :else temporary-theme-id)))
+
+(defn get-workspace-token-theme [id state]
+ (get-in state [:workspace-data :token-themes-index id]))
+
+(defn add-token-set-to-token-theme [token-set-id token-theme]
+ (update token-theme :sets conj token-set-id))
+
+ ;; Sets ------------------------------------------------------------------------
+
+(defn get-active-theme-sets-tokens-names-map [state]
+ (when-let [lib (get-workspace-tokens-lib state)]
+ (ctob/get-active-themes-set-tokens lib)))
+
+;; === Set selection
+
+(defn get-selected-token-set-id [state]
+ (or (get-in state [:workspace-local :selected-token-set-id])
+ (some-> (get-workspace-tokens-lib state)
+ (ctob/get-sets)
+ (first)
+ (:name))))
+
+(defn get-selected-token-set [state]
+ (when-let [id (get-selected-token-set-id state)]
+ (some-> (get-workspace-tokens-lib state)
+ (ctob/get-set id))))
+
+(defn get-selected-token-set-tokens [state]
+ (some-> (get-selected-token-set state)
+ :tokens))
+
+(defn assoc-selected-token-set-id [state id]
+ (assoc-in state [:workspace-local :selected-token-set-id] id))
diff --git a/frontend/src/app/main/ui/workspace/tokens/token_types.cljs b/frontend/src/app/main/ui/workspace/tokens/token_types.cljs
new file mode 100644
index 000000000..ce4c5cbf3
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/token_types.cljs
@@ -0,0 +1,88 @@
+;; 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.workspace.tokens.token-types
+ (:require
+ [app.common.data :as d :refer [ordered-map]]
+ [app.common.types.token :as ctt]
+ [app.main.ui.workspace.tokens.changes :as wtch]
+ [clojure.set :as set]))
+
+(def token-types
+ (ordered-map
+ :border-radius
+ {:title "Border Radius"
+ :attributes ctt/border-radius-keys
+ :on-update-shape wtch/update-shape-radius-all
+ :modal {:key :tokens/border-radius
+ :fields [{:label "Border Radius"
+ :key :border-radius}]}}
+
+ :color
+ {:title "Color"
+ :attributes ctt/color-keys
+ :on-update-shape wtch/update-color
+ :modal {:key :tokens/color
+ :fields [{:label "Color" :key :color}]}}
+
+ :stroke-width
+ {:title "Stroke Width"
+ :attributes ctt/stroke-width-keys
+ :on-update-shape wtch/update-stroke-width
+ :modal {:key :tokens/stroke-width
+ :fields [{:label "Stroke Width"
+ :key :stroke-width}]}}
+
+ :sizing
+ {:title "Sizing"
+ :attributes #{:width :height}
+ :all-attributes ctt/sizing-keys
+ :on-update-shape wtch/update-shape-dimensions
+ :modal {:key :tokens/sizing
+ :fields [{:label "Sizing"
+ :key :sizing}]}}
+ :dimensions
+ {:title "Dimensions"
+ :attributes #{:width :height}
+ :all-attributes (set/union
+ ctt/spacing-keys
+ ctt/sizing-keys
+ ctt/border-radius-keys
+ ctt/stroke-width-keys)
+ :on-update-shape wtch/update-shape-dimensions
+ :modal {:key :tokens/dimensions
+ :fields [{:label "Dimensions"
+ :key :dimensions}]}}
+
+ :opacity
+ {:title "Opacity"
+ :attributes ctt/opacity-keys
+ :on-update-shape wtch/update-opacity
+ :modal {:key :tokens/opacity
+ :fields [{:label "Opacity"
+ :key :opacity}]}}
+
+ :rotation
+ {:title "Rotation"
+ :attributes ctt/rotation-keys
+ :on-update-shape wtch/update-rotation
+ :modal {:key :tokens/rotation
+ :fields [{:label "Rotation"
+ :key :rotation}]}}
+ :spacing
+ {:title "Spacing"
+ :attributes #{:column-gap :row-gap}
+ :all-attributes ctt/spacing-keys
+ :on-update-shape wtch/update-layout-spacing
+ :modal {:key :tokens/spacing
+ :fields [{:label "Spacing"
+ :key :spacing}]}}))
+
+(defn get-token-properties [token]
+ (get token-types (:type token)))
+
+(defn token-attributes [token-type]
+ (get-in token-types [token-type :attributes]))
diff --git a/frontend/src/app/main/ui/workspace/tokens/update.cljs b/frontend/src/app/main/ui/workspace/tokens/update.cljs
new file mode 100644
index 000000000..d0c6dbec6
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/tokens/update.cljs
@@ -0,0 +1,135 @@
+(ns app.main.ui.workspace.tokens.update
+ (:require
+ [app.common.types.token :as ctt]
+ [app.main.data.workspace.shape-layout :as dwsl]
+ [app.main.data.workspace.undo :as dwu]
+ [app.main.refs :as refs]
+ [app.main.ui.workspace.tokens.changes :as wtch]
+ [app.main.ui.workspace.tokens.style-dictionary :as wtsd]
+ [app.main.ui.workspace.tokens.token-set :as wtts]
+ [beicon.v2.core :as rx]
+ [clojure.data :as data]
+ [clojure.set :as set]
+ [potok.v2.core :as ptk]))
+
+;; Constants -------------------------------------------------------------------
+
+(def filter-existing-values? false)
+
+(def attributes->shape-update
+ {#{:rx :ry} (fn [v ids _] (wtch/update-shape-radius-all v ids))
+ #{:r1 :r2 :r3 :r4} wtch/update-shape-radius-single-corner
+ ctt/color-keys wtch/update-color
+ ctt/stroke-width-keys wtch/update-stroke-width
+ ctt/sizing-keys wtch/update-shape-dimensions
+ ctt/opacity-keys wtch/update-opacity
+ #{:x :y} wtch/update-shape-position
+ #{:p1 :p2 :p3 :p4} (fn [resolved-value shape-ids attrs]
+ (dwsl/update-layout shape-ids {:layout-padding (zipmap attrs (repeat resolved-value))}))
+ #{:column-gap :row-gap} wtch/update-layout-spacing
+ #{:width :height} wtch/update-shape-dimensions
+ #{:layout-item-min-w :layout-item-min-h :layout-item-max-w :layout-item-max-h} wtch/update-layout-sizing-limits
+ ctt/rotation-keys wtch/update-rotation})
+
+(def attribute-actions-map
+ (reduce
+ (fn [acc [ks action]]
+ (into acc (map (fn [k] [k action]) ks)))
+ {} attributes->shape-update))
+
+;; Helpers ---------------------------------------------------------------------
+
+(defn deep-merge
+ "Like d/deep-merge but unions set values."
+ ([a b]
+ (cond
+ (map? a) (merge-with deep-merge a b)
+ (set? a) (set/union a b)
+ :else b))
+ ([a b & rest]
+ (reduce deep-merge a (cons b rest))))
+
+;; Data flows ------------------------------------------------------------------
+
+(defn invert-collect-key-vals
+ [xs resolved-tokens shape]
+ (-> (reduce
+ (fn [acc [k v]]
+ (let [resolved-token (get resolved-tokens v)
+ resolved-value (get resolved-token :resolved-value)
+ skip? (or
+ (not (get resolved-tokens v))
+ (and filter-existing-values? (= (get shape k) resolved-value)))]
+ (if skip?
+ acc
+ (update acc resolved-value (fnil conj #{}) k))))
+ {} xs)))
+
+(defn split-attribute-groups [attrs-values-map]
+ (reduce
+ (fn [acc [attrs v]]
+ (cond
+ (some attrs #{:rx :ry}) (let [[_ a b] (data/diff #{:rx :ry} attrs)]
+ (cond-> (assoc acc b v)
+ ;; Exact match in attrs
+ a (assoc a v)))
+
+ (some attrs #{:widht :height}) (let [[_ a b] (data/diff #{:width :height} attrs)]
+ (cond-> (assoc acc b v)
+ ;; Exact match in attrs
+ a (assoc a v)))
+ (some attrs ctt/spacing-keys) (let [[_ rst gap] (data/diff #{:row-gap :column-gap} attrs)
+ [_ position padding] (data/diff #{:p1 :p2 :p3 :p4} rst)]
+ (cond-> acc
+ (seq gap) (assoc gap v)
+ (seq position) (assoc position v)
+ (seq padding) (assoc padding v)))
+ attrs (assoc acc attrs v)))
+ {} attrs-values-map))
+
+(defn shape-ids-by-values
+ [attrs-values-map object-id]
+ (->> (map (fn [[value attrs]] [attrs {value #{object-id}}]) attrs-values-map)
+ (into {})))
+
+(defn collect-shapes-update-info [resolved-tokens shapes]
+ (reduce
+ (fn [acc [object-id {:keys [applied-tokens] :as shape}]]
+ (if (seq applied-tokens)
+ (let [applied-tokens (-> (invert-collect-key-vals applied-tokens resolved-tokens shape)
+ (shape-ids-by-values object-id)
+ (split-attribute-groups))]
+ (deep-merge acc applied-tokens))
+ acc))
+ {} shapes))
+
+(defn actionize-shapes-update-info [shapes-update-info]
+ (mapcat (fn [[attrs update-infos]]
+ (let [action (some attribute-actions-map attrs)]
+ (map
+ (fn [[v shape-ids]]
+ (action v shape-ids attrs))
+ update-infos)))
+ shapes-update-info))
+
+(defn update-tokens [resolved-tokens]
+ (->> @refs/workspace-page-objects
+ (collect-shapes-update-info resolved-tokens)
+ (actionize-shapes-update-info)))
+
+(defn update-workspace-tokens []
+ (ptk/reify ::update-workspace-tokens
+ ptk/WatchEvent
+ (watch [_ state _]
+ (->>
+ (rx/from
+ (->
+ (wtts/get-active-theme-sets-tokens-names-map state)
+ (wtsd/resolve-tokens+)))
+ (rx/mapcat
+ (fn [sd-tokens]
+ (let [undo-id (js/Symbol)]
+ (rx/concat
+ (rx/of (dwu/start-undo-transaction undo-id))
+ (update-tokens sd-tokens)
+ (rx/of (dwu/commit-undo-transaction undo-id))))))))))
diff --git a/frontend/src/app/util/functions.cljs b/frontend/src/app/util/functions.cljs
index fa7818ea7..2398e371a 100644
--- a/frontend/src/app/util/functions.cljs
+++ b/frontend/src/app/util/functions.cljs
@@ -25,5 +25,7 @@
lodash-debounce))
(defn debounce
- [f timeout]
- (ext-debounce f timeout #{:leading false :trailing true}))
+ ([f]
+ (debounce f 0))
+ ([f timeout]
+ (ext-debounce f timeout #{:leading false :trailing true})))
diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs
index 59388fe8e..d1bedcfaa 100644
--- a/frontend/test/frontend_tests/runner.cljs
+++ b/frontend/test/frontend_tests/runner.cljs
@@ -8,6 +8,10 @@
[frontend-tests.logic.frame-guides-test]
[frontend-tests.logic.groups-test]
[frontend-tests.plugins.context-shapes-test]
+ [frontend-tests.tokens.logic.token-actions-test]
+ [frontend-tests.tokens.style-dictionary-test]
+ [frontend-tests.tokens.token-form-test]
+ [frontend-tests.tokens.token-test]
[frontend-tests.util-range-tree-test]
[frontend-tests.util-simple-math-test]
[frontend-tests.util-snap-data-test]))
@@ -19,16 +23,20 @@
(.exit js/process 0)
(.exit js/process 1)))
-
(defn init
[]
- (t/run-tests 'frontend-tests.helpers-shapes-test
- 'frontend-tests.logic.comp-remove-swap-slots-test
- 'frontend-tests.logic.copying-and-duplicating-test
- 'frontend-tests.logic.frame-guides-test
- 'frontend-tests.logic.groups-test
- 'frontend-tests.plugins.context-shapes-test
- 'frontend-tests.util-range-tree-test
- 'frontend-tests.util-snap-data-test
- 'frontend-tests.util-simple-math-test
- 'frontend-tests.basic-shapes-test))
+ (t/run-tests
+ 'frontend-tests.helpers-shapes-test
+ 'frontend-tests.logic.comp-remove-swap-slots-test
+ 'frontend-tests.logic.copying-and-duplicating-test
+ 'frontend-tests.logic.frame-guides-test
+ 'frontend-tests.logic.groups-test
+ 'frontend-tests.plugins.context-shapes-test
+ 'frontend-tests.util-range-tree-test
+ 'frontend-tests.util-snap-data-test
+ 'frontend-tests.util-simple-math-test
+ 'frontend-tests.basic-shapes-test
+ ;; 'frontend-tests.tokens.logic.token-actions-test
+ ;; 'frontend-tests.tokens.style-dictionary-test
+ 'frontend-tests.tokens.token-test
+ 'frontend-tests.tokens.token-form-test))
diff --git a/frontend/test/frontend_tests/tokens/helpers/state.cljs b/frontend/test/frontend_tests/tokens/helpers/state.cljs
new file mode 100644
index 000000000..0593a2099
--- /dev/null
+++ b/frontend/test/frontend_tests/tokens/helpers/state.cljs
@@ -0,0 +1,74 @@
+(ns frontend-tests.tokens.helpers.state
+ (:require
+ [app.common.types.tokens-lib :as ctob]
+ [app.main.ui.workspace.tokens.style-dictionary :as sd]
+ [beicon.v2.core :as rx]
+ [potok.v2.core :as ptk]))
+
+(defn end
+ "Apply `attributes` that match `token` for `shape-ids`.
+
+ Optionally remove attributes from `attributes-to-remove`,
+ this is useful for applying a single attribute from an attributes set
+ while removing other applied tokens from this set."
+ []
+ (ptk/reify ::end
+ ptk/WatchEvent
+ (watch [_ _ _]
+ (rx/empty))))
+
+(defn end+
+ []
+ (ptk/reify ::end+
+ ptk/WatchEvent
+ (watch [_ state _]
+ (->> (rx/from (-> (get-in state [:workspace-data :tokens-lib])
+ (ctob/get-active-themes-set-tokens)
+ (sd/resolve-tokens+)))
+ (rx/mapcat #(rx/of (end)))))))
+
+(defn stop-on
+ "Helper function to be used with async version of run-store.
+
+ Will stop the execution after event with `event-type` has completed."
+ [event-type]
+ (fn [stream]
+ (->> stream
+ #_(rx/tap #(prn (ptk/type %)))
+ (rx/filter #(ptk/type? event-type %)))))
+
+(def stop-on-send-update-indices
+ "Stops on `send-update-indices` function being called, which should be the last function of an event chain."
+ (stop-on ::end))
+
+;; Support for async events in tests
+;; https://chat.kaleidos.net/penpot-partners/pl/tz1yoes3w3fr9qanxqpuhoz3ch
+(defn run-store
+ "Async version of `frontend-tests.helpers.state/run-store`."
+ ([store done events completed-cb]
+ (run-store store done events completed-cb nil))
+ ([store done events completed-cb stopper]
+ (let [stream (ptk/input-stream store)
+ stopper-s (if (fn? stopper)
+ (stopper stream)
+ (rx/filter #(= :the/end %) stream))]
+ (->> stream
+ (rx/take-until stopper-s)
+ (rx/last)
+ (rx/tap (fn [_]
+ (completed-cb @store)))
+ (rx/subs! (fn [_] (done))
+ (fn [cause]
+ (js/console.log "[error]:" cause))
+ (fn [_]
+ #_(js/console.log "[complete]"))))
+ (doseq [event (concat events [(end+)])]
+ (ptk/emit! store event))
+ (ptk/emit! store :the/end))))
+
+(defn run-store-async
+ "Helper version of `run-store` that automatically stops on the `send-update-indices` event"
+ ([store done events completed-cb]
+ (run-store store done events completed-cb stop-on-send-update-indices))
+ ([store done events completed-cb stop-on]
+ (run-store store done events completed-cb stop-on)))
diff --git a/frontend/test/frontend_tests/tokens/helpers/tokens.cljs b/frontend/test/frontend_tests/tokens/helpers/tokens.cljs
new file mode 100644
index 000000000..29316a1fa
--- /dev/null
+++ b/frontend/test/frontend_tests/tokens/helpers/tokens.cljs
@@ -0,0 +1,26 @@
+(ns frontend-tests.tokens.helpers.tokens
+ (:require
+ [app.common.test-helpers.ids-map :as thi]
+ [app.common.types.tokens-lib :as ctob]
+ [app.main.ui.workspace.tokens.token :as wtt]))
+
+(defn add-token [state label params]
+ (let [id (thi/new-id! label)
+ token (assoc params :id id)]
+ (update-in state [:data :tokens] assoc id token)))
+
+(defn get-token [file name]
+ (some-> (get-in file [:data :tokens-lib])
+ (ctob/get-active-themes-set-tokens)
+ (get name)))
+
+(defn apply-token-to-shape [file shape-label token-label attributes]
+ (let [first-page-id (get-in file [:data :pages 0])
+ shape-id (thi/id shape-label)
+ token (get-token file token-label)
+ applied-attributes (wtt/attributes-map attributes token)]
+ (update-in file [:data
+ :pages-index first-page-id
+ :objects shape-id
+ :applied-tokens]
+ merge applied-attributes)))
diff --git a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs
new file mode 100644
index 000000000..5c482021c
--- /dev/null
+++ b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs
@@ -0,0 +1,407 @@
+(ns frontend-tests.tokens.logic.token-actions-test
+ (:require
+ [app.common.logging :as log]
+ [app.common.test-helpers.compositions :as ctho]
+ [app.common.test-helpers.files :as cthf]
+ [app.common.test-helpers.shapes :as cths]
+ [app.common.types.tokens-lib :as ctob]
+ [app.main.ui.workspace.tokens.changes :as wtch]
+ [app.main.ui.workspace.tokens.token :as wtt]
+ [cljs.test :as t :include-macros true]
+ [frontend-tests.helpers.pages :as thp]
+ [frontend-tests.helpers.state :as ths]
+ [frontend-tests.tokens.helpers.state :as tohs]
+ [frontend-tests.tokens.helpers.tokens :as toht]))
+
+(t/use-fixtures :each
+ {:before (fn []
+ ;; Ignore rxjs async errors
+ (log/set-level! "app.main.data.changes" :error)
+ (thp/reset-idmap!))})
+
+(defn setup-file []
+ (cthf/sample-file :file-1 :page-label :page-1))
+
+(def border-radius-token
+ {:name "borderRadius.sm"
+ :value "12"
+ :type :border-radius})
+
+(def reference-border-radius-token
+ {:name "borderRadius.md"
+ :value "{borderRadius.sm} * 2"
+ :type :border-radius})
+
+(defn setup-file-with-tokens
+ [& {:keys [rect-1 rect-2 rect-3]}]
+ (-> (setup-file)
+ (ctho/add-rect :rect-1 rect-1)
+ (ctho/add-rect :rect-2 rect-2)
+ (ctho/add-rect :rect-3 rect-3)
+ (assoc-in [:data :tokens-lib]
+ (-> (ctob/make-tokens-lib)
+ (ctob/add-theme (ctob/make-token-theme :name "Theme A" :sets #{"Set A"}))
+ (ctob/set-active-themes #{"/Theme A"})
+ (ctob/add-set (ctob/make-token-set :name "Set A"))
+ (ctob/add-token-in-set "Set A" (ctob/make-token border-radius-token))
+ (ctob/add-token-in-set "Set A" (ctob/make-token reference-border-radius-token))))))
+
+(t/deftest test-apply-token
+ (t/testing "applies token to shape and updates shape attributes to resolved value"
+ (t/async
+ done
+ (let [file (setup-file-with-tokens)
+ store (ths/setup-store file)
+ rect-1 (cths/get-shape file :rect-1)
+ events [(wtch/apply-token {:shape-ids [(:id rect-1)]
+ :attributes #{:rx :ry}
+ :token (toht/get-token file "borderRadius.md")
+ :on-update-shape wtch/update-shape-radius-all})]]
+ (tohs/run-store-async
+ store done events
+ (fn [new-state]
+ (let [file' (ths/get-file-from-store new-state)
+ token (toht/get-token file' "borderRadius.md")
+ rect-1' (cths/get-shape file' :rect-1)]
+ (t/testing "shape `:applied-tokens` got updated"
+ (t/is (some? (:applied-tokens rect-1')))
+ (t/is (= (:rx (:applied-tokens rect-1')) (:name token)))
+ (t/is (= (:ry (:applied-tokens rect-1')) (:name token))))
+ (t/testing "shape radius got update to the resolved token value."
+ (t/is (= (:rx rect-1') 24))
+ (t/is (= (:ry rect-1') 24))))))))))
+
+(t/deftest test-apply-multiple-tokens
+ (t/testing "applying a token twice with the same attributes will override the previously applied tokens values"
+ (t/async
+ done
+ (let [file (setup-file-with-tokens)
+ store (ths/setup-store file)
+ rect-1 (cths/get-shape file :rect-1)
+ events [(wtch/apply-token {:shape-ids [(:id rect-1)]
+ :attributes #{:rx :ry}
+ :token (toht/get-token file "borderRadius.sm")
+ :on-update-shape wtch/update-shape-radius-all})
+ (wtch/apply-token {:shape-ids [(:id rect-1)]
+ :attributes #{:rx :ry}
+ :token (toht/get-token file "borderRadius.md")
+ :on-update-shape wtch/update-shape-radius-all})]]
+ (tohs/run-store-async
+ store done events
+ (fn [new-state]
+ (let [file' (ths/get-file-from-store new-state)
+ token (toht/get-token file' "borderRadius.md")
+ rect-1' (cths/get-shape file' :rect-1)]
+ (t/testing "shape `:applied-tokens` got updated"
+ (t/is (some? (:applied-tokens rect-1')))
+ (t/is (= (:rx (:applied-tokens rect-1')) (:name token)))
+ (t/is (= (:ry (:applied-tokens rect-1')) (:name token))))
+ (t/testing "shape radius got update to the resolved token value."
+ (t/is (= (:rx rect-1') 24))
+ (t/is (= (:ry rect-1') 24))))))))))
+
+(t/deftest test-apply-token-overwrite
+ (t/testing "removes old token attributes and applies only single attribute"
+ (t/async
+ done
+ (let [file (setup-file-with-tokens)
+ store (ths/setup-store file)
+ rect-1 (cths/get-shape file :rect-1)
+ events [;; Apply "borderRadius.sm" to all border radius attributes
+ (wtch/apply-token {:attributes #{:rx :ry :r1 :r2 :r3 :r4}
+ :token (toht/get-token file "borderRadius.sm")
+ :shape-ids [(:id rect-1)]
+ :on-update-shape wtch/update-shape-radius-all})
+ ;; Apply single `:r1` attribute to same shape
+ ;; while removing other attributes from the border-radius set
+ ;; but keep `:r4` for testing purposes
+ (wtch/apply-token {:attributes #{:r1}
+ :attributes-to-remove #{:rx :ry :r1 :r2 :r3}
+ :token (toht/get-token file "borderRadius.md")
+ :shape-ids [(:id rect-1)]
+ :on-update-shape wtch/update-shape-radius-all})]]
+ (tohs/run-store-async
+ store done events
+ (fn [new-state]
+ (let [file' (ths/get-file-from-store new-state)
+ token-sm (toht/get-token file' "borderRadius.sm")
+ token-md (toht/get-token file' "borderRadius.md")
+ rect-1' (cths/get-shape file' :rect-1)]
+ (t/testing "other border-radius attributes got removed"
+ (t/is (nil? (:rx (:applied-tokens rect-1')))))
+ (t/testing "r1 got applied with borderRadius.md"
+ (t/is (= (:r1 (:applied-tokens rect-1')) (:name token-md))))
+ (t/testing "while :r4 was kept with borderRadius.sm"
+ (t/is (= (:r4 (:applied-tokens rect-1')) (:name token-sm)))))))))))
+
+(t/deftest test-apply-dimensions
+ (t/testing "applies dimensions token and updates the shapes width and height"
+ (t/async
+ done
+ (let [dimensions-token {:name "dimensions.sm"
+ :value "100"
+ :type :dimensions}
+ file (-> (setup-file-with-tokens)
+ (update-in [:data :tokens-lib]
+ #(ctob/add-token-in-set % "Set A" (ctob/make-token dimensions-token))))
+ store (ths/setup-store file)
+ rect-1 (cths/get-shape file :rect-1)
+ events [(wtch/apply-token {:shape-ids [(:id rect-1)]
+ :attributes #{:width :height}
+ :token (toht/get-token file "dimensions.sm")
+ :on-update-shape wtch/update-shape-dimensions})]]
+ (tohs/run-store-async
+ store done events
+ (fn [new-state]
+ (let [file' (ths/get-file-from-store new-state)
+ token-target' (toht/get-token file' "dimensions.sm")
+ rect-1' (cths/get-shape file' :rect-1)]
+ (t/testing "shape `:applied-tokens` got updated"
+ (t/is (some? (:applied-tokens rect-1')))
+ (t/is (= (:width (:applied-tokens rect-1')) (:name token-target')))
+ (t/is (= (:height (:applied-tokens rect-1')) (:name token-target'))))
+ (t/testing "shapes width and height got updated"
+ (t/is (= (:width rect-1') 100))
+ (t/is (= (:height rect-1') 100))))))))))
+
+(t/deftest test-apply-sizing
+ (t/testing "applies sizing token and updates the shapes width and height"
+ (t/async
+ done
+ (let [sizing-token {:name "sizing.sm"
+ :value "100"
+ :type :sizing}
+ file (-> (setup-file-with-tokens)
+ (update-in [:data :tokens-lib]
+ #(ctob/add-token-in-set % "Set A" (ctob/make-token sizing-token))))
+ store (ths/setup-store file)
+ rect-1 (cths/get-shape file :rect-1)
+ events [(wtch/apply-token {:shape-ids [(:id rect-1)]
+ :attributes #{:width :height}
+ :token (toht/get-token file "sizing.sm")
+ :on-update-shape wtch/update-shape-dimensions})]]
+ (tohs/run-store-async
+ store done events
+ (fn [new-state]
+ (let [file' (ths/get-file-from-store new-state)
+ token-target' (toht/get-token file' "sizing.sm")
+ rect-1' (cths/get-shape file' :rect-1)]
+ (t/testing "shape `:applied-tokens` got updated"
+ (t/is (some? (:applied-tokens rect-1')))
+ (t/is (= (:width (:applied-tokens rect-1')) (:name token-target')))
+ (t/is (= (:height (:applied-tokens rect-1')) (:name token-target'))))
+ (t/testing "shapes width and height got updated"
+ (t/is (= (:width rect-1') 100))
+ (t/is (= (:height rect-1') 100))))))))))
+
+(t/deftest test-apply-opacity
+ (t/testing "applies opacity token and updates the shapes opacity"
+ (t/async
+ done
+ (let [opacity-float {:name "opacity.float"
+ :value "0.3"
+ :type :opacity}
+ opacity-percent {:name "opacity.percent"
+ :value "40%"
+ :type :opacity}
+ opacity-invalid {:name "opacity.invalid"
+ :value "100"
+ :type :opacity}
+ file (-> (setup-file-with-tokens)
+ (update-in [:data :tokens-lib]
+ #(-> %
+ (ctob/add-token-in-set "Set A" (ctob/make-token opacity-float))
+ (ctob/add-token-in-set "Set A" (ctob/make-token opacity-percent))
+ (ctob/add-token-in-set "Set A" (ctob/make-token opacity-invalid)))))
+ store (ths/setup-store file)
+ rect-1 (cths/get-shape file :rect-1)
+ rect-2 (cths/get-shape file :rect-2)
+ rect-3 (cths/get-shape file :rect-3)
+ events [(wtch/apply-token {:shape-ids [(:id rect-1)]
+ :attributes #{:opacity}
+ :token (toht/get-token file "opacity.float")
+ :on-update-shape wtch/update-opacity})
+ (wtch/apply-token {:shape-ids [(:id rect-2)]
+ :attributes #{:opacity}
+ :token (toht/get-token file "opacity.percent")
+ :on-update-shape wtch/update-opacity})
+ (wtch/apply-token {:shape-ids [(:id rect-3)]
+ :attributes #{:opacity}
+ :token (toht/get-token file "opacity.invalid")
+ :on-update-shape wtch/update-opacity})]]
+ (tohs/run-store-async
+ store done events
+ (fn [new-state]
+ (let [file' (ths/get-file-from-store new-state)
+ rect-1' (cths/get-shape file' :rect-1)
+ rect-2' (cths/get-shape file' :rect-2)
+ rect-3' (cths/get-shape file' :rect-3)
+ token-opacity-float (toht/get-token file' "opacity.float")
+ token-opacity-percent (toht/get-token file' "opacity.percent")
+ token-opacity-invalid (toht/get-token file' "opacity.invalid")]
+ (t/testing "float value got translated to float and applied to opacity"
+ (t/is (= (:opacity (:applied-tokens rect-1')) (:name token-opacity-float)))
+ (t/is (= (:opacity rect-1') 0.3)))
+ (t/testing "percentage value got translated to float and applied to opacity"
+ (t/is (= (:opacity (:applied-tokens rect-2')) (:name token-opacity-percent)))
+ (t/is (= (:opacity rect-2') 0.4)))
+ (t/testing "invalid opacity value got applied but did not change shape"
+ (t/is (= (:opacity (:applied-tokens rect-3')) (:name token-opacity-invalid)))
+ (t/is (nil? (:opacity rect-3')))))))))))
+
+(t/deftest test-apply-rotation
+ (t/testing "applies rotation token and updates the shapes rotation"
+ (t/async
+ done
+ (let [rotation-token {:name "rotation.medium"
+ :value "120"
+ :type :rotation}
+ file (-> (setup-file-with-tokens)
+ (update-in [:data :tokens-lib]
+ #(ctob/add-token-in-set % "Set A" (ctob/make-token rotation-token))))
+ store (ths/setup-store file)
+ rect-1 (cths/get-shape file :rect-1)
+ events [(wtch/apply-token {:shape-ids [(:id rect-1)]
+ :attributes #{:rotation}
+ :token (toht/get-token file "rotation.medium")
+ :on-update-shape wtch/update-rotation})]]
+ (tohs/run-store-async
+ store done events
+ (fn [new-state]
+ (let [file' (ths/get-file-from-store new-state)
+ token-target' (toht/get-token file' "rotation.medium")
+ rect-1' (cths/get-shape file' :rect-1)]
+ (t/is (some? (:applied-tokens rect-1')))
+ (t/is (= (:rotation (:applied-tokens rect-1')) (:name token-target')))
+ (t/is (= (:rotation rect-1') 120)))))))))
+
+(t/deftest test-apply-stroke-width
+ (t/testing "applies stroke-width token and updates the shapes with stroke"
+ (t/async
+ done
+ (let [stroke-width-token {:name "stroke-width.sm"
+ :value "10"
+ :type :stroke-width}
+ file (-> (setup-file-with-tokens {:rect-1 {:strokes [{:stroke-alignment :inner,
+ :stroke-style :solid,
+ :stroke-color "#000000",
+ :stroke-opacity 1,
+ :stroke-width 5}]}})
+ (update-in [:data :tokens-lib]
+ #(ctob/add-token-in-set % "Set A" (ctob/make-token stroke-width-token))))
+ store (ths/setup-store file)
+ rect-with-stroke (cths/get-shape file :rect-1)
+ rect-without-stroke (cths/get-shape file :rect-2)
+ events [(wtch/apply-token {:shape-ids [(:id rect-with-stroke) (:id rect-without-stroke)]
+ :attributes #{:stroke-width}
+ :token (toht/get-token file "stroke-width.sm")
+ :on-update-shape wtch/update-stroke-width})]]
+ (tohs/run-store-async
+ store done events
+ (fn [new-state]
+ (let [file' (ths/get-file-from-store new-state)
+ token-target' (toht/get-token file' "stroke-width.sm")
+ rect-with-stroke' (cths/get-shape file' :rect-1)
+ rect-without-stroke' (cths/get-shape file' :rect-2)]
+ (t/testing "token got applied to rect with stroke and shape stroke got updated"
+ (t/is (= (:stroke-width (:applied-tokens rect-with-stroke')) (:name token-target')))
+ (t/is (= (get-in rect-with-stroke' [:strokes 0 :stroke-width]) 10)))
+ (t/testing "token got applied to rect without stroke but shape didnt get updated"
+ (t/is (= (:stroke-width (:applied-tokens rect-without-stroke')) (:name token-target')))
+ (t/is (empty? (:strokes rect-without-stroke')))))))))))
+
+(t/deftest test-toggle-token-none
+ (t/testing "should apply token to all selected items, where no item has the token applied"
+ (t/async
+ done
+ (let [file (setup-file-with-tokens)
+ store (ths/setup-store file)
+ rect-1 (cths/get-shape file :rect-1)
+ rect-2 (cths/get-shape file :rect-2)
+ events [(wtch/toggle-token {:shapes [rect-1 rect-2]
+ :token-type-props {:attributes #{:rx :ry}
+ :on-update-shape wtch/update-shape-radius-all}
+ :token (toht/get-token file "borderRadius.md")})]]
+ (tohs/run-store-async
+ store done events
+ (fn [new-state]
+ (let [file' (ths/get-file-from-store new-state)
+ token-2' (toht/get-token file' "borderRadius.md")
+ rect-1' (cths/get-shape file' :rect-1)
+ rect-2' (cths/get-shape file' :rect-2)]
+ (t/is (some? (:applied-tokens rect-1')))
+ (t/is (some? (:applied-tokens rect-2')))
+ (t/is (= (:rx (:applied-tokens rect-1')) (:name token-2')))
+ (t/is (= (:rx (:applied-tokens rect-2')) (:name token-2')))
+ (t/is (= (:ry (:applied-tokens rect-1')) (:name token-2')))
+ (t/is (= (:ry (:applied-tokens rect-2')) (:name token-2')))
+ (t/is (= (:rx rect-1') 24))
+ (t/is (= (:rx rect-2') 24)))))))))
+
+(t/deftest test-toggle-token-mixed
+ (t/testing "should unapply given token if one of the selected items has the token applied while keeping other tokens with some attributes"
+ (t/async
+ done
+ (let [file (-> (setup-file-with-tokens)
+ (toht/apply-token-to-shape :rect-1 "borderRadius.sm" #{:rx :ry})
+ (toht/apply-token-to-shape :rect-3 "borderRadius.md" #{:rx :ry}))
+ store (ths/setup-store file)
+
+ rect-with-token (cths/get-shape file :rect-1)
+ rect-without-token (cths/get-shape file :rect-2)
+ rect-with-other-token (cths/get-shape file :rect-3)
+
+ events [(wtch/toggle-token {:shapes [rect-with-token rect-without-token rect-with-other-token]
+ :token (toht/get-token file "borderRadius.sm")
+ :token-type-props {:attributes #{:rx :ry}}})]]
+ (tohs/run-store-async
+ store done events
+ (fn [new-state]
+ (let [file' (ths/get-file-from-store new-state)
+ rect-with-token' (cths/get-shape file' :rect-1)
+ rect-without-token' (cths/get-shape file' :rect-2)
+ rect-with-other-token' (cths/get-shape file' :rect-3)]
+
+ (t/testing "rect-with-token got the token removed"
+ (t/is (nil? (:rx (:applied-tokens rect-with-token'))))
+ (t/is (nil? (:ry (:applied-tokens rect-with-token')))))
+
+ (t/testing "rect-without-token didn't get updated"
+ (t/is (= (:applied-tokens rect-without-token') (:applied-tokens rect-without-token))))
+
+ (t/testing "rect-with-other-token didn't get updated"
+ (t/is (= (:applied-tokens rect-with-other-token') (:applied-tokens rect-with-other-token)))))))))))
+
+(t/deftest test-toggle-token-apply-to-all
+ (t/testing "should apply token to all if none of the shapes has it applied"
+ (t/async
+ done
+ (let [file (-> (setup-file-with-tokens)
+ (toht/apply-token-to-shape :rect-1 "borderRadius.md" #{:rx :ry})
+ (toht/apply-token-to-shape :rect-3 "borderRadius.md" #{:rx :ry}))
+ store (ths/setup-store file)
+
+ rect-with-other-token-1 (cths/get-shape file :rect-1)
+ rect-without-token (cths/get-shape file :rect-2)
+ rect-with-other-token-2 (cths/get-shape file :rect-3)
+
+ events [(wtch/toggle-token {:shapes [rect-with-other-token-1 rect-without-token rect-with-other-token-2]
+ :token (toht/get-token file "borderRadius.sm")
+ :token-type-props {:attributes #{:rx :ry}}})]]
+ (tohs/run-store-async
+ store done events
+ (fn [new-state]
+ (let [file' (ths/get-file-from-store new-state)
+ target-token (toht/get-token file' "borderRadius.sm")
+ rect-with-other-token-1' (cths/get-shape file' :rect-1)
+ rect-without-token' (cths/get-shape file' :rect-2)
+ rect-with-other-token-2' (cths/get-shape file' :rect-3)]
+
+ (t/testing "token got applied to all shapes"
+ (t/is (= (:rx (:applied-tokens rect-with-other-token-1')) (:name target-token)))
+ (t/is (= (:rx (:applied-tokens rect-without-token')) (:name target-token)))
+ (t/is (= (:rx (:applied-tokens rect-with-other-token-2')) (:name target-token)))
+
+ (t/is (= (:ry (:applied-tokens rect-with-other-token-1')) (:name target-token)))
+ (t/is (= (:ry (:applied-tokens rect-without-token')) (:name target-token)))
+ (t/is (= (:ry (:applied-tokens rect-with-other-token-2')) (:name target-token)))))))))))
diff --git a/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs b/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs
new file mode 100644
index 000000000..9e2f6d1e9
--- /dev/null
+++ b/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs
@@ -0,0 +1,115 @@
+(ns frontend-tests.tokens.style-dictionary-test
+ (:require
+ [app.common.transit :as tr]
+ [app.common.types.tokens-lib :as ctob]
+ [app.main.ui.workspace.tokens.style-dictionary :as sd]
+ [beicon.v2.core :as rx]
+ [cljs.test :as t :include-macros true]
+ [promesa.core :as p]))
+
+(t/deftest resolve-tokens-test
+ (t/async
+ done
+ (t/testing "resolves tokens using style-dictionary from a ids map"
+ (let [tokens (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :name "core"))
+ (ctob/add-token-in-set "core" (ctob/make-token {:name "borderRadius.sm"
+ :value "12px"
+ :type :border-radius}))
+ (ctob/add-token-in-set "core" (ctob/make-token {:value "{borderRadius.sm} * 2"
+ :name "borderRadius.md-with-dashes"
+ :type :border-radius}))
+ (ctob/get-all-tokens))]
+ (-> (sd/resolve-tokens+ tokens)
+ (p/finally
+ (fn [resolved-tokens]
+ (t/is (= 12 (get-in resolved-tokens ["borderRadius.sm" :resolved-value])))
+ (t/is (= "px" (get-in resolved-tokens ["borderRadius.sm" :unit])))
+ (t/is (= 24 (get-in resolved-tokens ["borderRadius.md-with-dashes" :resolved-value])))
+ (t/is (= "px" (get-in resolved-tokens ["borderRadius.md-with-dashes" :unit])))
+ (done))))))))
+
+(t/deftest process-json-stream-test
+ (t/async
+ done
+ (t/testing "processes empty json string"
+ (let [json (-> {"core" {"color" {"$value" "red"
+ "$type" "color"}}}
+ (tr/encode-str {:type :json-verbose}))]
+ (->> (rx/of json)
+ (sd/process-json-stream)
+ (rx/subs! (fn [tokens-lib]
+ (t/is (instance? ctob/TokensLib tokens-lib))
+ (t/is (= "red" (-> (ctob/get-set tokens-lib "core")
+ (ctob/get-token "color")
+ (:value))))
+ (done))))))))
+
+(t/deftest reference-errros-test
+ (t/testing "Extracts reference errors from StyleDictionary errors"
+ ;; Using unicode for the white-space after "Error: " as some editors might remove it and its more visible
+ (t/is (=
+ ["Some token references (2) could not be found."
+ ""
+ "foo.value tries to reference missing, which is not defined."
+ "color.value tries to reference missing, which is not defined."]
+ (sd/reference-errors "Error:\u0020
+Reference Errors:
+Some token references (2) could not be found.
+
+foo.value tries to reference missing, which is not defined.
+color.value tries to reference missing, which is not defined.")))
+ (t/is (nil? (sd/reference-errors nil)))
+ (t/is (nil? (sd/reference-errors "none")))))
+
+(t/deftest process-empty-json-stream-test
+ (t/async
+ done
+ (t/testing "processes empty json string"
+ (->> (rx/of "{}")
+ (sd/process-json-stream)
+ (rx/subs! (fn [tokens-lib]
+ (t/is (instance? ctob/TokensLib tokens-lib))
+ (done)))))))
+
+(t/deftest process-invalid-json-stream-test
+ (t/async
+ done
+ (t/testing "fails on invalid json"
+ (->> (rx/of "{,}")
+ (sd/process-json-stream)
+ (rx/subs!
+ (fn []
+ (throw (js/Error. "Should be an error")))
+ (fn [err]
+ (t/is (= :error.import/json-parse-error (:error/code (ex-data err))))
+ (done)))))))
+
+(t/deftest process-non-token-json-stream-test
+ (t/async
+ done
+ (t/testing "fails on non-token json"
+ (->> (rx/of "{\"foo\": \"bar\"}")
+ (sd/process-json-stream)
+ (rx/subs!
+ (fn []
+ (throw (js/Error. "Should be an error")))
+ (fn [err]
+ (t/is (= :error.import/invalid-json-data (:error/code (ex-data err))))
+ (done)))))))
+
+(t/deftest process-missing-references-json-test
+ (t/async
+ done
+ (t/testing "fails on missing references in tokens"
+ (let [json (-> {"core" {"color" {"$value" "{missing}"
+ "$type" "color"}}}
+ (tr/encode-str {:type :json-verbose}))]
+ (->> (rx/of json)
+ (sd/process-json-stream)
+ (rx/subs!
+ (fn []
+ (throw (js/Error. "Should be an error")))
+ (fn [err]
+ (t/is (= :error.import/style-dictionary-reference-errors (:error/code (ex-data err))))
+ (done))))))))
diff --git a/frontend/test/frontend_tests/tokens/token_form_test.cljs b/frontend/test/frontend_tests/tokens/token_form_test.cljs
new file mode 100644
index 000000000..ea623bcbe
--- /dev/null
+++ b/frontend/test/frontend_tests/tokens/token_form_test.cljs
@@ -0,0 +1,26 @@
+;; 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 frontend-tests.tokens.token-form-test
+ (:require
+ [app.main.ui.workspace.tokens.form :as wtf]
+ [cljs.test :as t :include-macros true]
+ [malli.core :as m]))
+
+(t/deftest test-valid-token-name-schema
+ ;; Allow regular namespace token names
+ (t/is (some? (m/validate wtf/valid-token-name-schema "Foo")))
+ (t/is (some? (m/validate wtf/valid-token-name-schema "foo")))
+ (t/is (some? (m/validate wtf/valid-token-name-schema "FOO")))
+ (t/is (some? (m/validate wtf/valid-token-name-schema "Foo.Bar.Baz")))
+ ;; Allow trailing tokens
+ (t/is (nil? (m/validate wtf/valid-token-name-schema "Foo.Bar.Baz....")))
+ ;; Disallow multiple separator dots
+ (t/is (nil? (m/validate wtf/valid-token-name-schema "Foo..Bar.Baz")))
+ ;; Disallow any special characters
+ (t/is (nil? (m/validate wtf/valid-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/valid-token-name-schema "Hey%Foo.Bar"))))
diff --git a/frontend/test/frontend_tests/tokens/token_test.cljs b/frontend/test/frontend_tests/tokens/token_test.cljs
new file mode 100644
index 000000000..eded6626e
--- /dev/null
+++ b/frontend/test/frontend_tests/tokens/token_test.cljs
@@ -0,0 +1,100 @@
+;; 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 frontend-tests.tokens.token-test
+ (:require
+ [app.main.ui.workspace.tokens.token :as wtt]
+ [cljs.test :as t :include-macros true]))
+
+(t/deftest test-parse-token-value
+ (t/testing "parses double from a token value"
+ (t/is (= {:value 100.1 :unit nil} (wtt/parse-token-value "100.1")))
+ (t/is (= {:value -9 :unit nil} (wtt/parse-token-value "-9"))))
+ (t/testing "trims white-space"
+ (t/is (= {:value -1.3 :unit nil} (wtt/parse-token-value " -1.3 "))))
+ (t/testing "parses unit: px"
+ (t/is (= {:value 70.3 :unit "px"} (wtt/parse-token-value " 70.3px "))))
+ (t/testing "parses unit: %"
+ (t/is (= {:value -10 :unit "%"} (wtt/parse-token-value "-10%"))))
+ (t/testing "parses unit: px")
+ (t/testing "returns nil for any invalid characters"
+ (t/is (nil? (wtt/parse-token-value " -1.3a "))))
+ (t/testing "doesnt accept invalid double"
+ (t/is (nil? (wtt/parse-token-value ".3")))))
+
+(t/deftest remove-attributes-for-token-id
+ (t/testing "removes attributes matching the `token`, keeps other attributes"
+ (t/is (= {:ry "b"}
+ (wtt/remove-attributes-for-token #{:rx :ry} {:name "a"} {:rx "a" :ry "b"})))))
+
+(t/deftest token-applied-test
+ (t/testing "matches passed token with `:token-attributes`"
+ (t/is (true? (wtt/token-applied? {:name "a"} {:applied-tokens {:x "a"}} #{:x}))))
+ (t/testing "doesn't match empty token"
+ (t/is (nil? (wtt/token-applied? {} {:applied-tokens {:x "a"}} #{:x}))))
+ (t/testing "does't match passed token `:id`"
+ (t/is (nil? (wtt/token-applied? {:name "b"} {:applied-tokens {:x "a"}} #{:x}))))
+ (t/testing "doesn't match passed `:token-attributes`"
+ (t/is (nil? (wtt/token-applied? {:name "a"} {:applied-tokens {:x "a"}} #{:y})))))
+
+(t/deftest shapes-ids-by-applied-attributes
+ (t/testing "Returns set of matched attributes that fit the applied token"
+ (let [attributes #{:x :y :z}
+ shape-applied-x {:id "shape-applied-x"
+ :applied-tokens {:x "1"}}
+ shape-applied-y {:id "shape-applied-y"
+ :applied-tokens {:y "1"}}
+ shape-applied-x-y {:id "shape-applied-x-y"
+ :applied-tokens {:x "1" :y "1"}}
+ shape-applied-none {:id "shape-applied-none"
+ :applied-tokens {}}
+ shape-applied-all {:id "shape-applied-all"
+ :applied-tokens {:x "1" :y "1" :z "1"}}
+ shape-ids (fn [& xs] (into #{} (map :id xs)))
+ shapes [shape-applied-x
+ shape-applied-y
+ shape-applied-x-y
+ shape-applied-all
+ shape-applied-none]
+ expected (wtt/shapes-ids-by-applied-attributes {:name "1"} shapes attributes)]
+ (t/is (= (:x expected) (shape-ids shape-applied-x
+ shape-applied-x-y
+ shape-applied-all)))
+ (t/is (= (:y expected) (shape-ids shape-applied-y
+ shape-applied-x-y
+ shape-applied-all)))
+ (t/is (= (:z expected) (shape-ids shape-applied-all)))
+ (t/is (true? (wtt/shapes-applied-all? expected (shape-ids shape-applied-all) attributes)))
+ (t/is (false? (wtt/shapes-applied-all? expected (apply shape-ids shapes) attributes)))
+ (shape-ids shape-applied-x
+ shape-applied-x-y
+ shape-applied-all))))
+
+(t/deftest tokens-applied-test
+ (t/testing "is true when single shape matches the token and attributes"
+ (t/is (true? (wtt/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "a"}}
+ {:applied-tokens {:x "b"}}]
+ #{:x}))))
+ (t/testing "is false when no shape matches the token or attributes"
+ (t/is (nil? (wtt/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "b"}}
+ {:applied-tokens {:x "b"}}]
+ #{:x})))
+ (t/is (nil? (wtt/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "a"}}
+ {:applied-tokens {:x "a"}}]
+ #{:y})))))
+
+(t/deftest name->path-test
+ (t/is (= ["foo" "bar" "baz"] (wtt/token-name->path "foo.bar.baz")))
+ (t/is (= ["foo" "bar" "baz"] (wtt/token-name->path "foo..bar.baz")))
+ (t/is (= ["foo" "bar" "baz"] (wtt/token-name->path "foo..bar.baz...."))))
+
+(t/deftest token-name-path-exists?-test
+ (t/is (true? (wtt/token-name-path-exists? "border-radius" {"border-radius" {"sm" {:name "sm"}}})))
+ (t/is (true? (wtt/token-name-path-exists? "border-radius" {"border-radius" {:name "sm"}})))
+ (t/is (true? (wtt/token-name-path-exists? "border-radius.sm" {"border-radius" {:name "sm"}})))
+ (t/is (true? (wtt/token-name-path-exists? "border-radius.sm.x" {"border-radius" {:name "sm"}})))
+ (t/is (false? (wtt/token-name-path-exists? "other" {"border-radius" {:name "sm"}})))
+ (t/is (false? (wtt/token-name-path-exists? "dark.border-radius.md" {"dark" {"border-radius" {"sm" {:name "sm"}}}}))))
diff --git a/frontend/translations/en.po b/frontend/translations/en.po
index a28edd422..96dc22c16 100644
--- a/frontend/translations/en.po
+++ b/frontend/translations/en.po
@@ -1613,6 +1613,10 @@ msgstr "Canva"
msgid "labels.close"
msgstr "Close"
+#: src/app/main/ui/workspace/tokens/sets.cljs
+msgid "labels.collapse"
+msgstr "Collapse"
+
#: src/app/main/ui/dashboard/comments.cljs:104, src/app/main/ui/viewer/comments.cljs:70, src/app/main/ui/workspace/comments.cljs:126
msgid "labels.comments"
msgstr "Comments"
@@ -2081,6 +2085,14 @@ msgstr "Team Leader"
msgid "labels.team-member"
msgstr "Team member"
+#: src/app/main/ui/workspace/tokens/sidebar.cljs
+msgid "labels.themes"
+msgstr "Themes"
+
+#: src/app/main/ui/workspace/tokens/sidebar.cljs
+msgid "labels.sets"
+msgstr "Sets"
+
#: src/app/main/ui/dashboard/sidebar.cljs:992, src/app/main/ui/workspace/main_menu.cljs:118
msgid "labels.tutorials"
msgstr "Tutorials"
@@ -6359,6 +6371,114 @@ msgstr "Update"
msgid "workspace.viewport.click-to-close-path"
msgstr "Click to close the path"
+#: src/app/main/ui/workspace/tokens/form.cljs
+msgid "workspace.token.create-token"
+msgstr "Create new %s token"
+
+#: src/app/main/ui/workspace/tokens/form.cljs
+msgid "workspace.token.edit-token"
+msgstr "Edit token"
+
+#: src/app/main/ui/workspace/tokens/form.cljs
+msgid "workspace.token.resolved-value"
+msgstr "Resolved value: "
+
+#: src/app/main/ui/workspace/tokens/sidebar.cljs
+msgid "workspace.token.original-value"
+msgstr "Original value: "
+
+#: src/app/main/ui/workspace/tokens/sidebar.cljs
+msgid "workspace.token.no-themes"
+msgstr "There are no themes."
+
+#: src/app/main/ui/workspace/tokens/sidebar.cljs
+msgid "workspace.token.create-one"
+msgstr "Create one."
+
+#: src/app/main/ui/workspace/tokens/sidebar.cljs
+msgid "workspace.token.add set"
+msgstr "Add set"
+
+#: src/app/main/ui/workspace/tokens/modals/themes.cljs
+msgid "workspace.token.save-theme"
+msgstr "Save theme"
+
+#: src/app/main/ui/workspace/tokens/modals/themes.cljs
+msgid "workspace.token.create-theme-title"
+msgstr "Create theme"
+
+#: src/app/main/ui/workspace/tokens/modals/themes.cljs
+msgid "workspace.token.edit-theme-title"
+msgstr "Edit theme"
+
+#: src/app/main/ui/workspace/tokens/modals/themes.cljs
+msgid "workspace.token.delete-theme-title"
+msgstr "Delete theme"
+
+#: src/app/main/ui/workspace/tokens/modals/themes.cljs
+msgid "workspace.token.no-themes-currently"
+msgstr "You currently have no themes."
+
+#: src/app/main/ui/workspace/tokens/modals/themes.cljs
+msgid "workspace.token.create-new-theme"
+msgstr "Create your first theme now."
+
+#: src/app/main/ui/workspace/tokens/modals/themes.cljs
+msgid "workspace.token.new-theme"
+msgstr "New theme"
+
+#: src/app/main/ui/workspace/tokens/modals/themes.cljs
+msgid "workspace.token.themes"
+msgstr "Themes"
+
+#: src/app/main/ui/workspace/tokens/modals/themes.cljs
+msgid "workspace.token.theme-name"
+msgstr "Theme %s"
+
+#: src/app/main/ui/workspace/tokens/modals/themes.cljs
+msgid "workspace.token.no-sets"
+msgstr "No sets"
+
+#: src/app/main/ui/workspace/tokens/modals/themes.cljs
+msgid "workspace.token.num-sets"
+msgstr "%s sets"
+
+#: src/app/main/ui/workspace/tokens/modals/themes.cljs
+msgid "workspace.token.back-to-themes"
+msgstr "Back to theme list"
+
+#: src/app/main/ui/workspace/tokens/theme_select.cljs
+msgid "workspace.token.edit-themes"
+msgstr "Edit themes"
+
+#: src/app/main/ui/workspace/tokens/theme_select.cljs
+msgid "workspace.token.no-active-theme"
+msgstr "No theme active"
+
+#: src/app/main/ui/workspace/tokens/theme_select.cljs
+msgid "workspace.token.active-themes"
+msgstr "%s active themes"
+
+#: src/app/main/ui/workspace/tokens/sets.cljs
+msgid "workspace.token.grouping-set-alert"
+msgstr "Token Set grouping is not supported yet."
+
+#: src/app/main/ui/workspace/tokens/sets.cljs
+msgid "workspace.token.select-set"
+msgstr "Select set."
+
+#: src/app/main/ui/workspace/tokens/sets.cljs
+msgid "workspace.token.set-selection-theme"
+msgstr "Define what token sets should be used as part of this theme option:"
+
+#: src/app/main/ui/workspace/tokens/sets.cljs
+msgid "workspace.token.no-sets-yet"
+msgstr "There are no sets yet."
+
+#: src/app/main/ui/workspace/tokens/sets.cljs
+msgid "workspace.token.no-sets-create"
+msgstr "There are no sets defined yet. Create one first."
+
msgid "workspace.versions.button.save"
msgstr "Save version"
diff --git a/frontend/translations/es.po b/frontend/translations/es.po
index 62a3bf10b..82864350b 100644
--- a/frontend/translations/es.po
+++ b/frontend/translations/es.po
@@ -1613,6 +1613,10 @@ msgstr "Canva"
msgid "labels.close"
msgstr "Cerrar"
+#: src/app/main/ui/workspace/tokens/sets.cljs
+msgid "labels.collapse"
+msgstr "Colapsar"
+
#: src/app/main/ui/dashboard/comments.cljs:104, src/app/main/ui/viewer/comments.cljs:70, src/app/main/ui/workspace/comments.cljs:126
msgid "labels.comments"
msgstr "Comentarios"
@@ -2073,6 +2077,14 @@ msgstr "LÃder de equipo"
msgid "labels.team-member"
msgstr "Miembro de equipo"
+#: src/app/main/ui/workspace/tokens/sidebar.cljs
+msgid "labels.themes"
+msgstr "Temas"
+
+#: src/app/main/ui/workspace/tokens/sidebar.cljs
+msgid "labels.sets"
+msgstr "Sets"
+
#: src/app/main/ui/dashboard/sidebar.cljs:992, src/app/main/ui/workspace/main_menu.cljs:118
msgid "labels.tutorials"
msgstr "Tutoriales"
@@ -6341,6 +6353,118 @@ msgstr "Pulsar para cerrar la ruta"
msgid "errors.maximum-invitations-by-request-reached"
msgstr "Se ha alcanzado el número máximo (%s) de correos electrónicos que se pueden invitar en una sola solicitud"
+#: src/app/main/ui/workspace/tokens/form.cljs
+msgid "workspace.token.create-token"
+msgstr "Crear un token de %s"
+
+#: src/app/main/ui/workspace/tokens/form.cljs
+msgid "workspace.token.edit-token"
+msgstr "Editar token"
+
+#: src/app/main/ui/workspace/tokens/form.cljs
+msgid "workspace.token.resolved-value"
+msgstr "Valor resuelto: "
+
+#: src/app/main/ui/workspace/tokens/sidebar.cljs
+msgid "workspace.token.original-value"
+msgstr "Valor original: "
+
+#: src/app/main/ui/workspace/tokens/sidebar.cljs
+msgid "workspace.token.no-themes"
+msgstr "No hay temas."
+
+#: src/app/main/ui/workspace/tokens/sidebar.cljs
+msgid "workspace.token.create-one"
+msgstr "Crear uno."
+
+#: src/app/main/ui/workspace/tokens/sidebar.cljs
+msgid "workspace.token.add set"
+msgstr "Añadir set"
+
+#: src/app/main/ui/workspace/tokens/modals/themes.cljs
+msgid "workspace.token.save-theme"
+msgstr "Guardar tema"
+
+#: src/app/main/ui/workspace/tokens/modals/themes.cljs
+msgid "workspace.token.create-theme-title"
+msgstr "Crear tema"
+
+#: src/app/main/ui/workspace/tokens/modals/themes.cljs
+msgid "workspace.token.edit-theme-title"
+msgstr "Editar tema"
+
+#: src/app/main/ui/workspace/tokens/modals/themes.cljs
+msgid "workspace.token.delete-theme-title"
+msgstr "Borrar theme"
+
+#: src/app/main/ui/workspace/tokens/modals/themes.cljs
+msgid "workspace.token.no-themes-currently"
+msgstr "Actualmente no existen temas."
+
+#: src/app/main/ui/workspace/tokens/modals/themes.cljs
+msgid "workspace.token.create-new-theme"
+msgstr "Crea un nuevo tema ahora."
+
+#: src/app/main/ui/workspace/tokens/modals/themes.cljs
+msgid "workspace.token.new-theme"
+msgstr "Nuevo tema"
+
+#: src/app/main/ui/workspace/tokens/modals/themes.cljs
+msgid "workspace.token.themes"
+msgstr "Temas"
+
+#: src/app/main/ui/workspace/tokens/modals/themes.cljs
+msgid "workspace.token.theme-name"
+msgstr "Tema %s"
+
+#: src/app/main/ui/workspace/tokens/modals/themes.cljs
+msgid "workspace.token.no-sets"
+msgstr "No hay sets"
+
+#: src/app/main/ui/workspace/tokens/modals/themes.cljs
+msgid "workspace.token.num-sets"
+msgstr "%s sets"
+
+#: src/app/main/ui/workspace/tokens/modals/themes.cljs
+msgid "workspace.token.back-to-themes"
+msgstr "Volver al listado de temas"
+
+#: src/app/main/ui/workspace/tokens/theme_select.cljs
+msgid "workspace.token.edit-themes"
+msgstr "Editar temas"
+
+#: src/app/main/ui/workspace/tokens/theme_select.cljs
+msgid "workspace.token.no-active-theme"
+msgstr "No hay temas activos"
+
+#: src/app/main/ui/workspace/tokens/theme_select.cljs
+msgid "workspace.token.active-themes"
+msgstr "%s temas activos"
+
+#: src/app/main/ui/workspace/tokens/sets.cljs
+msgid "workspace.token.grouping-set-alert"
+msgstr "La agrupación de sets aun no está soportada."
+
+#: src/app/main/ui/workspace/tokens/sets.cljs
+msgid "workspace.token.select-set"
+msgstr "Selecciona set"
+
+#: src/app/main/ui/workspace/tokens/sets.cljs
+msgid "workspace.token.set-selection-theme"
+msgstr "Define que sets de tokens deberian formar parte de este tema:"
+
+#: src/app/main/ui/workspace/tokens/sets.cljs
+msgid "workspace.token.no-sets"
+msgstr "Aun no hay sets."
+
+#: src/app/main/ui/workspace/tokens/sets.cljs
+msgid "workspace.create-one"
+msgstr "Crea uno."
+
+#: src/app/main/ui/workspace/tokens/sets.cljs
+msgid "workspace.token.no-sets-create"
+msgstr "Aun no hay sets definidos. Crea uno primero"
+
msgid "workspace.versions.button.save"
msgstr "Guardar versión"
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index ad6ce99db..8478caa4e 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -209,6 +209,46 @@ __metadata:
languageName: node
linkType: hard
+"@bundled-es-modules/deepmerge@npm:^4.3.1":
+ version: 4.3.1
+ resolution: "@bundled-es-modules/deepmerge@npm:4.3.1"
+ dependencies:
+ deepmerge: "npm:^4.3.1"
+ checksum: 10c0/50493fb741d588aa358edc5e844cbf31493cb64aca0a5ca0d33d73f61eb9eb853f7038074429343afbe199e614a6be8400abfd31909f9e5f14a53a4cff39b894
+ languageName: node
+ linkType: hard
+
+"@bundled-es-modules/glob@npm:^10.4.2":
+ version: 10.4.2
+ resolution: "@bundled-es-modules/glob@npm:10.4.2"
+ dependencies:
+ buffer: "npm:^6.0.3"
+ events: "npm:^3.3.0"
+ glob: "npm:^10.4.2"
+ patch-package: "npm:^8.0.0"
+ path: "npm:^0.12.7"
+ stream: "npm:^0.0.3"
+ string_decoder: "npm:^1.3.0"
+ url: "npm:^0.11.3"
+ checksum: 10c0/0c61907efb170750c69c7a6953d613bcbffdefca5ced668c0579baf46e28232793fb6e2ac3b736dd937f750572ef5a17483c417060df43e4be30dc4c8567aaba
+ languageName: node
+ linkType: hard
+
+"@bundled-es-modules/memfs@npm:^4.9.4":
+ version: 4.9.4
+ resolution: "@bundled-es-modules/memfs@npm:4.9.4"
+ dependencies:
+ assert: "npm:^2.0.0"
+ buffer: "npm:^6.0.3"
+ events: "npm:^3.3.0"
+ memfs: "npm:^4.9.3"
+ path: "npm:^0.12.7"
+ stream: "npm:^0.0.3"
+ util: "npm:^0.12.5"
+ checksum: 10c0/e3548c14379183fb74aa9a94407c1cdb8587320216fb557c0af7277d2dccf23f10a2edf8726e99f878758730c0c8d71524f77e19b26660a067b01d9afa07c891
+ languageName: node
+ linkType: hard
+
"@colors/colors@npm:1.6.0, @colors/colors@npm:^1.6.0":
version: 1.6.0
resolution: "@colors/colors@npm:1.6.0"
@@ -653,6 +693,38 @@ __metadata:
languageName: node
linkType: hard
+"@jsonjoy.com/base64@npm:^1.1.1":
+ version: 1.1.2
+ resolution: "@jsonjoy.com/base64@npm:1.1.2"
+ peerDependencies:
+ tslib: 2
+ checksum: 10c0/88717945f66dc89bf58ce75624c99fe6a5c9a0c8614e26d03e406447b28abff80c69fb37dabe5aafef1862cf315071ae66e5c85f6018b437d95f8d13d235e6eb
+ languageName: node
+ linkType: hard
+
+"@jsonjoy.com/json-pack@npm:^1.0.3":
+ version: 1.1.0
+ resolution: "@jsonjoy.com/json-pack@npm:1.1.0"
+ dependencies:
+ "@jsonjoy.com/base64": "npm:^1.1.1"
+ "@jsonjoy.com/util": "npm:^1.1.2"
+ hyperdyperid: "npm:^1.2.0"
+ thingies: "npm:^1.20.0"
+ peerDependencies:
+ tslib: 2
+ checksum: 10c0/cdf5cb567a7f2e703d4966a3e3a5f7f7b54ee40a2102aa0ede5c79bcf2060c8465d82f39de8583db4cf1d8415bec8e57dfb1156ef663567b846cdea45813d9d1
+ languageName: node
+ linkType: hard
+
+"@jsonjoy.com/util@npm:^1.1.2, @jsonjoy.com/util@npm:^1.3.0":
+ version: 1.5.0
+ resolution: "@jsonjoy.com/util@npm:1.5.0"
+ peerDependencies:
+ tslib: 2
+ checksum: 10c0/0065ae12c4108d8aede01a479c8d2b5a39bce99e9a449d235befc753f57e8385d9c1115720529f26597840b7398d512898155423d9859fd638319fb0c827365d
+ languageName: node
+ linkType: hard
+
"@mdx-js/react@npm:^3.0.0":
version: 3.1.0
resolution: "@mdx-js/react@npm:3.1.0"
@@ -694,6 +766,62 @@ __metadata:
languageName: node
linkType: hard
+"@oven/bun-darwin-aarch64@npm:1.1.34":
+ version: 1.1.34
+ resolution: "@oven/bun-darwin-aarch64@npm:1.1.34"
+ conditions: os=darwin & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@oven/bun-darwin-x64-baseline@npm:1.1.34":
+ version: 1.1.34
+ resolution: "@oven/bun-darwin-x64-baseline@npm:1.1.34"
+ conditions: os=darwin & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@oven/bun-darwin-x64@npm:1.1.34":
+ version: 1.1.34
+ resolution: "@oven/bun-darwin-x64@npm:1.1.34"
+ conditions: os=darwin & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@oven/bun-linux-aarch64@npm:1.1.34":
+ version: 1.1.34
+ resolution: "@oven/bun-linux-aarch64@npm:1.1.34"
+ conditions: os=linux & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@oven/bun-linux-x64-baseline@npm:1.1.34":
+ version: 1.1.34
+ resolution: "@oven/bun-linux-x64-baseline@npm:1.1.34"
+ conditions: os=linux & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@oven/bun-linux-x64@npm:1.1.34":
+ version: 1.1.34
+ resolution: "@oven/bun-linux-x64@npm:1.1.34"
+ conditions: os=linux & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@oven/bun-windows-x64-baseline@npm:1.1.34":
+ version: 1.1.34
+ resolution: "@oven/bun-windows-x64-baseline@npm:1.1.34"
+ conditions: os=win32 & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@oven/bun-windows-x64@npm:1.1.34":
+ version: 1.1.34
+ resolution: "@oven/bun-windows-x64@npm:1.1.34"
+ conditions: os=win32 & cpu=x64
+ languageName: node
+ linkType: hard
+
"@parcel/watcher-android-arm64@npm:2.5.0":
version: 2.5.0
resolution: "@parcel/watcher-android-arm64@npm:2.5.0"
@@ -1508,6 +1636,30 @@ __metadata:
languageName: node
linkType: hard
+"@tokens-studio/sd-transforms@npm:^0.16.1":
+ version: 0.16.1
+ resolution: "@tokens-studio/sd-transforms@npm:0.16.1"
+ dependencies:
+ "@tokens-studio/types": "npm:^0.4.0"
+ color2k: "npm:^2.0.1"
+ colorjs.io: "npm:^0.4.3"
+ deepmerge: "npm:^4.3.1"
+ expr-eval-fork: "npm:^2.0.2"
+ is-mergeable-object: "npm:^1.1.1"
+ postcss-calc-ast-parser: "npm:^0.1.4"
+ peerDependencies:
+ style-dictionary: ^4.0.0-prerelease.27
+ checksum: 10c0/496a22026ffa25e3f6d8438a1fb39d67383fa55c89de9ac6759e2dce10a16268f5009e4809d03ceab38597fc02025a90eb1d32083b98a9353feded83831549c9
+ languageName: node
+ linkType: hard
+
+"@tokens-studio/types@npm:^0.4.0":
+ version: 0.4.0
+ resolution: "@tokens-studio/types@npm:0.4.0"
+ checksum: 10c0/0641385653c94704f63dc5e10699c49bdbb1e1d8cba54af31bf50c3be85056123109bb2fe5091b1ccebaa9eba4c4afce3148a3b850919ed67bc81e3294ae839c
+ languageName: node
+ linkType: hard
+
"@trysound/sax@npm:0.2.0":
version: 0.2.0
resolution: "@trysound/sax@npm:0.2.0"
@@ -1712,6 +1864,20 @@ __metadata:
languageName: node
linkType: hard
+"@yarnpkg/lockfile@npm:^1.1.0":
+ version: 1.1.0
+ resolution: "@yarnpkg/lockfile@npm:1.1.0"
+ checksum: 10c0/0bfa50a3d756623d1f3409bc23f225a1d069424dbc77c6fd2f14fb377390cd57ec703dc70286e081c564be9051ead9ba85d81d66a3e68eeb6eb506d4e0c0fbda
+ languageName: node
+ linkType: hard
+
+"@zip.js/zip.js@npm:^2.7.44":
+ version: 2.7.53
+ resolution: "@zip.js/zip.js@npm:2.7.53"
+ checksum: 10c0/883527bf09ce7c312117536c79d5f07e736d87de802a6c19e39ba2e18027499dcb9359df94dfde13c9bcf6118a20b4f26a40f9892ee82d7cac3124d6986b15c8
+ languageName: node
+ linkType: hard
+
"abbrev@npm:^2.0.0":
version: 2.0.0
resolution: "abbrev@npm:2.0.0"
@@ -1938,6 +2104,19 @@ __metadata:
languageName: node
linkType: hard
+"assert@npm:^2.0.0":
+ version: 2.1.0
+ resolution: "assert@npm:2.1.0"
+ dependencies:
+ call-bind: "npm:^1.0.2"
+ is-nan: "npm:^1.3.2"
+ object-is: "npm:^1.1.5"
+ object.assign: "npm:^4.1.4"
+ util: "npm:^0.12.5"
+ checksum: 10c0/7271a5da883c256a1fa690677bf1dd9d6aa882139f2bed1cd15da4f9e7459683e1da8e32a203d6cc6767e5e0f730c77a9532a87b896b4b0af0dd535f668775f0
+ languageName: node
+ linkType: hard
+
"assertion-error@npm:^2.0.1":
version: 2.0.1
resolution: "assertion-error@npm:2.0.1"
@@ -1975,6 +2154,13 @@ __metadata:
languageName: node
linkType: hard
+"at-least-node@npm:^1.0.0":
+ version: 1.0.0
+ resolution: "at-least-node@npm:1.0.0"
+ checksum: 10c0/4c058baf6df1bc5a1697cf182e2029c58cd99975288a13f9e70068ef5d6f4e1f1fd7c4d2c3c4912eae44797d1725be9700995736deca441b39f3e66d8dee97ef
+ languageName: node
+ linkType: hard
+
"atob@npm:^2.1.2":
version: 2.1.2
resolution: "atob@npm:2.1.2"
@@ -2289,6 +2475,43 @@ __metadata:
languageName: node
linkType: hard
+"bun@npm:^1.1.25":
+ version: 1.1.34
+ resolution: "bun@npm:1.1.34"
+ dependencies:
+ "@oven/bun-darwin-aarch64": "npm:1.1.34"
+ "@oven/bun-darwin-x64": "npm:1.1.34"
+ "@oven/bun-darwin-x64-baseline": "npm:1.1.34"
+ "@oven/bun-linux-aarch64": "npm:1.1.34"
+ "@oven/bun-linux-x64": "npm:1.1.34"
+ "@oven/bun-linux-x64-baseline": "npm:1.1.34"
+ "@oven/bun-windows-x64": "npm:1.1.34"
+ "@oven/bun-windows-x64-baseline": "npm:1.1.34"
+ dependenciesMeta:
+ "@oven/bun-darwin-aarch64":
+ optional: true
+ "@oven/bun-darwin-x64":
+ optional: true
+ "@oven/bun-darwin-x64-baseline":
+ optional: true
+ "@oven/bun-linux-aarch64":
+ optional: true
+ "@oven/bun-linux-x64":
+ optional: true
+ "@oven/bun-linux-x64-baseline":
+ optional: true
+ "@oven/bun-windows-x64":
+ optional: true
+ "@oven/bun-windows-x64-baseline":
+ optional: true
+ bin:
+ bun: bin/bun.exe
+ bunx: bin/bun.exe
+ checksum: 10c0/d7a69a3e6a7545d7c76edaf86633f23f791641732fb0f5a6378f1503d267d03a3353afcc01e735acb6981b12acc83827d73bca701f8e3f62183bb00ad7e22e9d
+ conditions: (os=darwin | os=linux | os=win32) & (cpu=arm64 | cpu=x64)
+ languageName: node
+ linkType: hard
+
"bytes@npm:3.1.2, bytes@npm:^3.0.0":
version: 3.1.2
resolution: "bytes@npm:3.1.2"
@@ -2323,7 +2546,7 @@ __metadata:
languageName: node
linkType: hard
-"call-bind@npm:^1.0.2, call-bind@npm:^1.0.5, call-bind@npm:^1.0.6, call-bind@npm:^1.0.7":
+"call-bind@npm:^1.0.0, call-bind@npm:^1.0.2, call-bind@npm:^1.0.5, call-bind@npm:^1.0.6, call-bind@npm:^1.0.7":
version: 1.0.7
resolution: "call-bind@npm:1.0.7"
dependencies:
@@ -2377,6 +2600,20 @@ __metadata:
languageName: node
linkType: hard
+"chalk@npm:^5.3.0":
+ version: 5.3.0
+ resolution: "chalk@npm:5.3.0"
+ checksum: 10c0/8297d436b2c0f95801103ff2ef67268d362021b8210daf8ddbe349695333eb3610a71122172ff3b0272f1ef2cf7cc2c41fdaa4715f52e49ffe04c56340feed09
+ languageName: node
+ linkType: hard
+
+"change-case@npm:^5.3.0":
+ version: 5.4.4
+ resolution: "change-case@npm:5.4.4"
+ checksum: 10c0/2a9c2b9c9ad6ab2491105aaf506db1a9acaf543a18967798dcce20926c6a173aa63266cb6189f3086e3c14bf7ae1f8ea4f96ecc466fcd582310efa00372f3734
+ languageName: node
+ linkType: hard
+
"check-error@npm:^2.1.1":
version: 2.1.1
resolution: "check-error@npm:2.1.1"
@@ -2419,6 +2656,13 @@ __metadata:
languageName: node
linkType: hard
+"ci-info@npm:^3.7.0":
+ version: 3.9.0
+ resolution: "ci-info@npm:3.9.0"
+ checksum: 10c0/6f0109e36e111684291d46123d491bc4e7b7a1934c3a20dea28cba89f1d4a03acd892f5f6a81ed3855c38647e285a150e3c9ba062e38943bef57fee6c1554c3a
+ languageName: node
+ linkType: hard
+
"cipher-base@npm:^1.0.0, cipher-base@npm:^1.0.1, cipher-base@npm:^1.0.3":
version: 1.0.4
resolution: "cipher-base@npm:1.0.4"
@@ -2546,6 +2790,13 @@ __metadata:
languageName: node
linkType: hard
+"color2k@npm:^2.0.1":
+ version: 2.0.3
+ resolution: "color2k@npm:2.0.3"
+ checksum: 10c0/e7c13d212c9d1abb1690e378bbc0a6fb1751e4b02e9a73ba3b2ade9d54da673834597d342791d577d1ce400ec486c7f92c5098f9fa85cd113bcfde57420a2bb9
+ languageName: node
+ linkType: hard
+
"color@npm:^3.1.3":
version: 3.2.1
resolution: "color@npm:3.2.1"
@@ -2556,6 +2807,13 @@ __metadata:
languageName: node
linkType: hard
+"colorjs.io@npm:^0.4.3":
+ version: 0.4.5
+ resolution: "colorjs.io@npm:0.4.5"
+ checksum: 10c0/4cc58d18223426bcb8caa558e7554002b62bf87bd20db06596abf5efe5ea65416266402db86b504ac5fa2c38360913dbb8e6ef7c4fa19a992fd1818d5710ef6f
+ languageName: node
+ linkType: hard
+
"colorjs.io@npm:^0.5.0":
version: 0.5.2
resolution: "colorjs.io@npm:0.5.2"
@@ -2596,6 +2854,20 @@ __metadata:
languageName: node
linkType: hard
+"commander@npm:^8.3.0":
+ version: 8.3.0
+ resolution: "commander@npm:8.3.0"
+ checksum: 10c0/8b043bb8322ea1c39664a1598a95e0495bfe4ca2fad0d84a92d7d1d8d213e2a155b441d2470c8e08de7c4a28cf2bc6e169211c49e1b21d9f7edc6ae4d9356060
+ languageName: node
+ linkType: hard
+
+"component-emitter@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "component-emitter@npm:2.0.0"
+ checksum: 10c0/65dfaf787ea49eb48f0ffec766bda7ec67e8dbeb3b406f08724dcae842e0aa274731fcccb9280b77d2b41693061731a9080b60d276020246a146544cd9900b83
+ languageName: node
+ linkType: hard
+
"compressible@npm:~2.0.18":
version: 2.0.18
resolution: "compressible@npm:2.0.18"
@@ -2795,7 +3067,7 @@ __metadata:
languageName: node
linkType: hard
-"cross-spawn@npm:^7.0.0":
+"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.3":
version: 7.0.3
resolution: "cross-spawn@npm:7.0.3"
dependencies:
@@ -3079,6 +3351,13 @@ __metadata:
languageName: node
linkType: hard
+"deepmerge@npm:^4.3.1":
+ version: 4.3.1
+ resolution: "deepmerge@npm:4.3.1"
+ checksum: 10c0/e53481aaf1aa2c4082b5342be6b6d8ad9dfe387bc92ce197a66dea08bd4265904a087e75e464f14d1347cf2ac8afe1e4c16b266e0561cc5df29382d3c5f80044
+ languageName: node
+ linkType: hard
+
"define-data-property@npm:^1.0.1, define-data-property@npm:^1.1.4":
version: 1.1.4
resolution: "define-data-property@npm:1.1.4"
@@ -3097,7 +3376,7 @@ __metadata:
languageName: node
linkType: hard
-"define-properties@npm:^1.2.0, define-properties@npm:^1.2.1":
+"define-properties@npm:^1.1.3, define-properties@npm:^1.2.0, define-properties@npm:^1.2.1":
version: 1.2.1
resolution: "define-properties@npm:1.2.1"
dependencies:
@@ -3883,6 +4162,13 @@ __metadata:
languageName: node
linkType: hard
+"expr-eval-fork@npm:^2.0.2":
+ version: 2.0.2
+ resolution: "expr-eval-fork@npm:2.0.2"
+ checksum: 10c0/ab5143fe65017d8811c155be55abd700321b8a32117635c35ce1309488f3263a251788f27f2e4a77425f58f7a64f99fd46d652c35a8c1668b22b4a8861702b75
+ languageName: node
+ linkType: hard
+
"express@npm:^4.21.1":
version: 4.21.1
resolution: "express@npm:4.21.1"
@@ -4025,6 +4311,15 @@ __metadata:
languageName: node
linkType: hard
+"find-yarn-workspace-root@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "find-yarn-workspace-root@npm:2.0.0"
+ dependencies:
+ micromatch: "npm:^4.0.2"
+ checksum: 10c0/b0d3843013fbdaf4e57140e0165889d09fa61745c9e85da2af86e54974f4cc9f1967e40f0d8fc36a79d53091f0829c651d06607d552582e53976f3cd8f4e5689
+ languageName: node
+ linkType: hard
+
"fn.name@npm:1.x.x":
version: 1.1.0
resolution: "fn.name@npm:1.1.0"
@@ -4108,8 +4403,10 @@ __metadata:
"@storybook/blocks": "npm:^8.3.6"
"@storybook/react": "npm:^8.3.6"
"@storybook/react-vite": "npm:^8.3.6"
+ "@tokens-studio/sd-transforms": "npm:^0.16.1"
"@types/node": "npm:^22.7.7"
autoprefixer: "npm:^10.4.20"
+ bun: "npm:^1.1.25"
compression: "npm:^1.7.4"
concurrently: "npm:^9.0.1"
date-fns: "npm:^4.1.0"
@@ -4131,6 +4428,7 @@ __metadata:
jsdom: "npm:^25.0.1"
jszip: "npm:^3.10.1"
lodash: "npm:^4.17.21"
+ lodash.debounce: "npm:^4.0.8"
luxon: "npm:^3.5.0"
map-stream: "npm:0.0.7"
marked: "npm:^14.1.3"
@@ -4159,8 +4457,10 @@ __metadata:
shadow-cljs: "npm:2.28.18"
source-map-support: "npm:^0.5.21"
storybook: "npm:^8.3.6"
+ style-dictionary: "npm:^4.1.4"
svg-sprite: "npm:^2.0.4"
tdigest: "npm:^0.1.2"
+ tinycolor2: "npm:^1.6.0"
typescript: "npm:^5.6.3"
ua-parser-js: "npm:2.0.0-rc.1"
vite: "npm:^5.4.9"
@@ -4172,6 +4472,18 @@ __metadata:
languageName: unknown
linkType: soft
+"fs-extra@npm:^9.0.0":
+ version: 9.1.0
+ resolution: "fs-extra@npm:9.1.0"
+ dependencies:
+ at-least-node: "npm:^1.0.0"
+ graceful-fs: "npm:^4.2.0"
+ jsonfile: "npm:^6.0.1"
+ universalify: "npm:^2.0.0"
+ checksum: 10c0/9b808bd884beff5cb940773018179a6b94a966381d005479f00adda6b44e5e3d4abf765135773d849cc27efe68c349e4a7b86acd7d3306d5932c14f3a4b17a92
+ languageName: node
+ linkType: hard
+
"fs-minipass@npm:^2.0.0":
version: 2.1.0
resolution: "fs-minipass@npm:2.1.0"
@@ -4347,7 +4659,7 @@ __metadata:
languageName: node
linkType: hard
-"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.3":
+"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.3, glob@npm:^10.4.2":
version: 10.4.5
resolution: "glob@npm:10.4.5"
dependencies:
@@ -4419,7 +4731,7 @@ __metadata:
languageName: node
linkType: hard
-"graceful-fs@npm:^4.0.0, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.2.6":
+"graceful-fs@npm:^4.0.0, graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.6":
version: 4.2.11
resolution: "graceful-fs@npm:4.2.11"
checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2
@@ -4702,6 +5014,13 @@ __metadata:
languageName: node
linkType: hard
+"hyperdyperid@npm:^1.2.0":
+ version: 1.2.0
+ resolution: "hyperdyperid@npm:1.2.0"
+ checksum: 10c0/885ba3177c7181d315a856ee9c0005ff8eb5dcb1ce9e9d61be70987895d934d84686c37c981cceeb53216d4c9c15c1cc25f1804e84cc6a74a16993c5d7fd0893
+ languageName: node
+ linkType: hard
+
"iconv-lite@npm:0.4.24":
version: 0.4.24
resolution: "iconv-lite@npm:0.4.24"
@@ -4990,6 +5309,23 @@ __metadata:
languageName: node
linkType: hard
+"is-mergeable-object@npm:^1.1.1":
+ version: 1.1.1
+ resolution: "is-mergeable-object@npm:1.1.1"
+ checksum: 10c0/ed895a17686eb88d28040e0281c507639e5a07e63ac51f033c34091c2d8679ca86775ecfe80d5f0636bc2b7c530acd731527e5a2e9c32a88f8847286451720f1
+ languageName: node
+ linkType: hard
+
+"is-nan@npm:^1.3.2":
+ version: 1.3.2
+ resolution: "is-nan@npm:1.3.2"
+ dependencies:
+ call-bind: "npm:^1.0.0"
+ define-properties: "npm:^1.1.3"
+ checksum: 10c0/8bfb286f85763f9c2e28ea32e9127702fe980ffd15fa5d63ade3be7786559e6e21355d3625dd364c769c033c5aedf0a2ed3d4025d336abf1b9241e3d9eddc5b0
+ languageName: node
+ linkType: hard
+
"is-negative-zero@npm:^2.0.3":
version: 2.0.3
resolution: "is-negative-zero@npm:2.0.3"
@@ -5013,6 +5349,13 @@ __metadata:
languageName: node
linkType: hard
+"is-plain-obj@npm:^4.1.0":
+ version: 4.1.0
+ resolution: "is-plain-obj@npm:4.1.0"
+ checksum: 10c0/32130d651d71d9564dc88ba7e6fda0e91a1010a3694648e9f4f47bb6080438140696d3e3e15c741411d712e47ac9edc1a8a9de1fe76f3487b0d90be06ac9975e
+ languageName: node
+ linkType: hard
+
"is-plain-object@npm:^2.0.4":
version: 2.0.4
resolution: "is-plain-object@npm:2.0.4"
@@ -5098,7 +5441,7 @@ __metadata:
languageName: node
linkType: hard
-"is-wsl@npm:^2.2.0":
+"is-wsl@npm:^2.1.1, is-wsl@npm:^2.2.0":
version: 2.2.0
resolution: "is-wsl@npm:2.2.0"
dependencies:
@@ -5270,6 +5613,18 @@ __metadata:
languageName: node
linkType: hard
+"json-stable-stringify@npm:^1.0.2":
+ version: 1.1.1
+ resolution: "json-stable-stringify@npm:1.1.1"
+ dependencies:
+ call-bind: "npm:^1.0.5"
+ isarray: "npm:^2.0.5"
+ jsonify: "npm:^0.0.1"
+ object-keys: "npm:^1.1.1"
+ checksum: 10c0/3801e3eeccbd030afb970f54bea690a079cfea7d9ed206a1b17ca9367f4b7772c764bf77a48f03e56b50e5f7ee7d11c52339fe20d8d7ccead003e4ca69e4cfde
+ languageName: node
+ linkType: hard
+
"json5@npm:^2.2.2, json5@npm:^2.2.3":
version: 2.2.3
resolution: "json5@npm:2.2.3"
@@ -5279,6 +5634,26 @@ __metadata:
languageName: node
linkType: hard
+"jsonfile@npm:^6.0.1":
+ version: 6.1.0
+ resolution: "jsonfile@npm:6.1.0"
+ dependencies:
+ graceful-fs: "npm:^4.1.6"
+ universalify: "npm:^2.0.0"
+ dependenciesMeta:
+ graceful-fs:
+ optional: true
+ checksum: 10c0/4f95b5e8a5622b1e9e8f33c96b7ef3158122f595998114d1e7f03985649ea99cb3cd99ce1ed1831ae94c8c8543ab45ebd044207612f31a56fd08462140e46865
+ languageName: node
+ linkType: hard
+
+"jsonify@npm:^0.0.1":
+ version: 0.0.1
+ resolution: "jsonify@npm:0.0.1"
+ checksum: 10c0/7f5499cdd59a0967ed35bda48b7cec43d850bbc8fb955cdd3a1717bb0efadbe300724d5646de765bb7a99fc1c3ab06eb80d93503c6faaf99b4ff50a3326692f6
+ languageName: node
+ linkType: hard
+
"jszip@npm:^3.10.1":
version: 3.10.1
resolution: "jszip@npm:3.10.1"
@@ -5291,6 +5666,15 @@ __metadata:
languageName: node
linkType: hard
+"klaw-sync@npm:^6.0.0":
+ version: 6.0.0
+ resolution: "klaw-sync@npm:6.0.0"
+ dependencies:
+ graceful-fs: "npm:^4.1.11"
+ checksum: 10c0/00d8e4c48d0d699b743b3b028e807295ea0b225caf6179f51029e19783a93ad8bb9bccde617d169659fbe99559d73fb35f796214de031d0023c26b906cecd70a
+ languageName: node
+ linkType: hard
+
"kuler@npm:^2.0.0":
version: 2.0.0
resolution: "kuler@npm:2.0.0"
@@ -5356,6 +5740,13 @@ __metadata:
languageName: node
linkType: hard
+"lodash.debounce@npm:^4.0.8":
+ version: 4.0.8
+ resolution: "lodash.debounce@npm:4.0.8"
+ checksum: 10c0/762998a63e095412b6099b8290903e0a8ddcb353ac6e2e0f2d7e7d03abd4275fe3c689d88960eb90b0dde4f177554d51a690f22a343932ecbc50a5d111849987
+ languageName: node
+ linkType: hard
+
"lodash.escape@npm:^4.0.1":
version: 4.0.1
resolution: "lodash.escape@npm:4.0.1"
@@ -5548,6 +5939,18 @@ __metadata:
languageName: node
linkType: hard
+"memfs@npm:^4.9.3":
+ version: 4.14.0
+ resolution: "memfs@npm:4.14.0"
+ dependencies:
+ "@jsonjoy.com/json-pack": "npm:^1.0.3"
+ "@jsonjoy.com/util": "npm:^1.3.0"
+ tree-dump: "npm:^1.0.1"
+ tslib: "npm:^2.0.0"
+ checksum: 10c0/d1de2e4b3c269f5b5f27b63f60bb8ea9ae5800843776e0bed4548f2957dcd55237ac5eab3a5ffe0d561a6be53e42c055a7bc79efc1613563b14e14c287ef3b0a
+ languageName: node
+ linkType: hard
+
"memoizee@npm:0.4.X":
version: 0.4.17
resolution: "memoizee@npm:0.4.17"
@@ -5594,7 +5997,7 @@ __metadata:
languageName: node
linkType: hard
-"micromatch@npm:^4.0.5":
+"micromatch@npm:^4.0.2, micromatch@npm:^4.0.5":
version: 4.0.8
resolution: "micromatch@npm:4.0.8"
dependencies:
@@ -6072,6 +6475,16 @@ __metadata:
languageName: node
linkType: hard
+"object-is@npm:^1.1.5":
+ version: 1.1.6
+ resolution: "object-is@npm:1.1.6"
+ dependencies:
+ call-bind: "npm:^1.0.7"
+ define-properties: "npm:^1.2.1"
+ checksum: 10c0/506af444c4dce7f8e31f34fc549e2fb8152d6b9c4a30c6e62852badd7f520b579c679af433e7a072f9d78eb7808d230dc12e1cf58da9154dfbf8813099ea0fe0
+ languageName: node
+ linkType: hard
+
"object-keys@npm:^1.1.1":
version: 1.1.1
resolution: "object-keys@npm:1.1.1"
@@ -6125,6 +6538,16 @@ __metadata:
languageName: node
linkType: hard
+"open@npm:^7.4.2":
+ version: 7.4.2
+ resolution: "open@npm:7.4.2"
+ dependencies:
+ is-docker: "npm:^2.0.0"
+ is-wsl: "npm:^2.1.1"
+ checksum: 10c0/77573a6a68f7364f3a19a4c80492712720746b63680ee304555112605ead196afe91052bd3c3d165efdf4e9d04d255e87de0d0a77acec11ef47fd5261251813f
+ languageName: node
+ linkType: hard
+
"open@npm:^8.0.4":
version: 8.4.2
resolution: "open@npm:8.4.2"
@@ -6155,6 +6578,13 @@ __metadata:
languageName: node
linkType: hard
+"os-tmpdir@npm:~1.0.2":
+ version: 1.0.2
+ resolution: "os-tmpdir@npm:1.0.2"
+ checksum: 10c0/f438450224f8e2687605a8dd318f0db694b6293c5d835ae509a69e97c8de38b6994645337e5577f5001115470414638978cc49da1cdcc25106dad8738dc69990
+ languageName: node
+ linkType: hard
+
"p-limit@npm:^3.0.2":
version: 3.1.0
resolution: "p-limit@npm:3.1.0"
@@ -6252,6 +6682,31 @@ __metadata:
languageName: node
linkType: hard
+"patch-package@npm:^8.0.0":
+ version: 8.0.0
+ resolution: "patch-package@npm:8.0.0"
+ dependencies:
+ "@yarnpkg/lockfile": "npm:^1.1.0"
+ chalk: "npm:^4.1.2"
+ ci-info: "npm:^3.7.0"
+ cross-spawn: "npm:^7.0.3"
+ find-yarn-workspace-root: "npm:^2.0.0"
+ fs-extra: "npm:^9.0.0"
+ json-stable-stringify: "npm:^1.0.2"
+ klaw-sync: "npm:^6.0.0"
+ minimist: "npm:^1.2.6"
+ open: "npm:^7.4.2"
+ rimraf: "npm:^2.6.3"
+ semver: "npm:^7.5.3"
+ slash: "npm:^2.0.0"
+ tmp: "npm:^0.0.33"
+ yaml: "npm:^2.2.2"
+ bin:
+ patch-package: index.js
+ checksum: 10c0/690eab0537e953a3fd7d32bb23f0e82f97cd448f8244c3227ed55933611a126f9476397325c06ad2c11d881a19b427a02bd1881bee78d89f1731373fc4fe0fee
+ languageName: node
+ linkType: hard
+
"path-browserify@npm:0.0.1":
version: 0.0.1
resolution: "path-browserify@npm:0.0.1"
@@ -6330,6 +6785,23 @@ __metadata:
languageName: node
linkType: hard
+"path-unified@npm:^0.1.0":
+ version: 0.1.0
+ resolution: "path-unified@npm:0.1.0"
+ checksum: 10c0/26c314221bcc0ca3ce59b67f50dffb6f37214d294fd9dfeb0219e9f12b93d8852c8525d32be9387011d902d361669a43e22ec419d522055794790222665b2de9
+ languageName: node
+ linkType: hard
+
+"path@npm:^0.12.7":
+ version: 0.12.7
+ resolution: "path@npm:0.12.7"
+ dependencies:
+ process: "npm:^0.11.1"
+ util: "npm:^0.10.3"
+ checksum: 10c0/f795ce5438a988a590c7b6dfd450ec9baa1c391a8be4c2dea48baa6e0f5b199e56cd83b8c9ebf3991b81bea58236d2c32bdafe2c17a2e70c3a2e4c69891ade59
+ languageName: node
+ linkType: hard
+
"pathe@npm:^1.1.2":
version: 1.1.2
resolution: "pathe@npm:1.1.2"
@@ -6462,6 +6934,15 @@ __metadata:
languageName: node
linkType: hard
+"postcss-calc-ast-parser@npm:^0.1.4":
+ version: 0.1.4
+ resolution: "postcss-calc-ast-parser@npm:0.1.4"
+ dependencies:
+ postcss-value-parser: "npm:^3.3.1"
+ checksum: 10c0/6ab488da4024aefe749baff2ee2cd41d1a7b84611291a6fd5d220262255c86f37687b3541696cab3e4edb1b7601634719877184ee426048ad82ed15185a5f64f
+ languageName: node
+ linkType: hard
+
"postcss-clean@npm:^1.2.2":
version: 1.2.2
resolution: "postcss-clean@npm:1.2.2"
@@ -6565,6 +7046,13 @@ __metadata:
languageName: node
linkType: hard
+"postcss-value-parser@npm:^3.3.1":
+ version: 3.3.1
+ resolution: "postcss-value-parser@npm:3.3.1"
+ checksum: 10c0/23eed98d8eeadb1f9ef1db4a2757da0f1d8e7c1dac2a38d6b35d971aab9eb3c6d8a967d0e9f435558834ffcd966afbbe875a56bcc5bcdd09e663008c106b3e47
+ languageName: node
+ linkType: hard
+
"postcss-value-parser@npm:^4.1.0, postcss-value-parser@npm:^4.2.0":
version: 4.2.0
resolution: "postcss-value-parser@npm:4.2.0"
@@ -6641,7 +7129,7 @@ __metadata:
languageName: node
linkType: hard
-"process@npm:^0.11.10":
+"process@npm:^0.11.1, process@npm:^0.11.10":
version: 0.11.10
resolution: "process@npm:0.11.10"
checksum: 10c0/40c3ce4b7e6d4b8c3355479df77aeed46f81b279818ccdc500124e6a5ab882c0cc81ff7ea16384873a95a74c4570b01b120f287abbdd4c877931460eca6084b3
@@ -7063,6 +7551,17 @@ __metadata:
languageName: node
linkType: hard
+"rimraf@npm:^2.6.3":
+ version: 2.7.1
+ resolution: "rimraf@npm:2.7.1"
+ dependencies:
+ glob: "npm:^7.1.3"
+ bin:
+ rimraf: ./bin.js
+ checksum: 10c0/4eef73d406c6940927479a3a9dee551e14a54faf54b31ef861250ac815172bade86cc6f7d64a4dc5e98b65e4b18a2e1c9ff3b68d296be0c748413f092bb0dd40
+ languageName: node
+ linkType: hard
+
"rimraf@npm:^3.0.2":
version: 3.0.2
resolution: "rimraf@npm:3.0.2"
@@ -7717,6 +8216,13 @@ __metadata:
languageName: node
linkType: hard
+"slash@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "slash@npm:2.0.0"
+ checksum: 10c0/f83dbd3cb62c41bb8fcbbc6bf5473f3234b97fa1d008f571710a9d3757a28c7169e1811cad1554ccb1cc531460b3d221c9a7b37f549398d9a30707f0a5af9193
+ languageName: node
+ linkType: hard
+
"smart-buffer@npm:^4.2.0":
version: 4.2.0
resolution: "smart-buffer@npm:4.2.0"
@@ -7930,6 +8436,15 @@ __metadata:
languageName: node
linkType: hard
+"stream@npm:^0.0.3":
+ version: 0.0.3
+ resolution: "stream@npm:0.0.3"
+ dependencies:
+ component-emitter: "npm:^2.0.0"
+ checksum: 10c0/5d262408583f3d5fed8077b33ad670320d85c6b7c0fb3ab73a9a632fbad0ee36f3c66e6feb5264cb39dbee3a619174fa886b5f69f98217666d0844f6a2f6510b
+ languageName: node
+ linkType: hard
+
"string-hash@npm:^1.1.1":
version: 1.1.3
resolution: "string-hash@npm:1.1.3"
@@ -8078,6 +8593,28 @@ __metadata:
languageName: node
linkType: hard
+"style-dictionary@npm:^4.1.4":
+ version: 4.1.4
+ resolution: "style-dictionary@npm:4.1.4"
+ dependencies:
+ "@bundled-es-modules/deepmerge": "npm:^4.3.1"
+ "@bundled-es-modules/glob": "npm:^10.4.2"
+ "@bundled-es-modules/memfs": "npm:^4.9.4"
+ "@zip.js/zip.js": "npm:^2.7.44"
+ chalk: "npm:^5.3.0"
+ change-case: "npm:^5.3.0"
+ commander: "npm:^8.3.0"
+ is-plain-obj: "npm:^4.1.0"
+ json5: "npm:^2.2.2"
+ patch-package: "npm:^8.0.0"
+ path-unified: "npm:^0.1.0"
+ tinycolor2: "npm:^1.6.0"
+ bin:
+ style-dictionary: bin/style-dictionary.js
+ checksum: 10c0/b88e2f94615bc851e2e797e685863911dbb875d312bf1571a3be6b6a9dde7e0b324d83495f153446eceefe93ec119c80e2ca032a600818dcecc72174d285e429
+ languageName: node
+ linkType: hard
+
"supports-color@npm:^5.3.0, supports-color@npm:^5.4.0, supports-color@npm:^5.5.0":
version: 5.5.0
resolution: "supports-color@npm:5.5.0"
@@ -8193,6 +8730,15 @@ __metadata:
languageName: node
linkType: hard
+"thingies@npm:^1.20.0":
+ version: 1.21.0
+ resolution: "thingies@npm:1.21.0"
+ peerDependencies:
+ tslib: ^2
+ checksum: 10c0/7570ee855aecb73185a672ecf3eb1c287a6512bf5476449388433b2d4debcf78100bc8bfd439b0edd38d2bc3bfb8341de5ce85b8557dec66d0f27b962c9a8bc1
+ languageName: node
+ linkType: hard
+
"through2@npm:^2.0.0, through2@npm:^2.0.3":
version: 2.0.5
resolution: "through2@npm:2.0.5"
@@ -8269,6 +8815,13 @@ __metadata:
languageName: node
linkType: hard
+"tinycolor2@npm:^1.6.0":
+ version: 1.6.0
+ resolution: "tinycolor2@npm:1.6.0"
+ checksum: 10c0/9aa79a36ba2c2a87cb221453465cabacd04b9e35f9694373e846fdc78b1c768110f81e581ea41440106c0f24d9a023891d0887e8075885e790ac40eb0e74a5c1
+ languageName: node
+ linkType: hard
+
"tinyexec@npm:^0.3.1":
version: 0.3.1
resolution: "tinyexec@npm:0.3.1"
@@ -8315,6 +8868,15 @@ __metadata:
languageName: node
linkType: hard
+"tmp@npm:^0.0.33":
+ version: 0.0.33
+ resolution: "tmp@npm:0.0.33"
+ dependencies:
+ os-tmpdir: "npm:~1.0.2"
+ checksum: 10c0/69863947b8c29cabad43fe0ce65cec5bb4b481d15d4b4b21e036b060b3edbf3bc7a5541de1bacb437bb3f7c4538f669752627fdf9b4aaf034cebd172ba373408
+ languageName: node
+ linkType: hard
+
"to-arraybuffer@npm:^1.0.0":
version: 1.0.1
resolution: "to-arraybuffer@npm:1.0.1"
@@ -8372,6 +8934,15 @@ __metadata:
languageName: node
linkType: hard
+"tree-dump@npm:^1.0.1":
+ version: 1.0.2
+ resolution: "tree-dump@npm:1.0.2"
+ peerDependencies:
+ tslib: 2
+ checksum: 10c0/d1d180764e9c691b28332dbd74226c6b6af361dfb1e134bb11e60e17cb11c215894adee50ffc578da5dcf546006693947be8b6665eb1269b56e2f534926f1c1f
+ languageName: node
+ linkType: hard
+
"tree-kill@npm:^1.2.2":
version: 1.2.2
resolution: "tree-kill@npm:1.2.2"
@@ -8406,7 +8977,7 @@ __metadata:
languageName: node
linkType: hard
-"tslib@npm:^2.0.1, tslib@npm:^2.1.0":
+"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.1.0":
version: 2.8.1
resolution: "tslib@npm:2.8.1"
checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62
@@ -8580,6 +9151,13 @@ __metadata:
languageName: node
linkType: hard
+"universalify@npm:^2.0.0":
+ version: 2.0.1
+ resolution: "universalify@npm:2.0.1"
+ checksum: 10c0/73e8ee3809041ca8b818efb141801a1004e3fc0002727f1531f4de613ea281b494a40909596dae4a042a4fb6cd385af5d4db2e137b1362e0e91384b828effd3a
+ languageName: node
+ linkType: hard
+
"unpipe@npm:1.0.0, unpipe@npm:~1.0.0":
version: 1.0.0
resolution: "unpipe@npm:1.0.0"
@@ -8616,7 +9194,7 @@ __metadata:
languageName: node
linkType: hard
-"url@npm:^0.11.0":
+"url@npm:^0.11.0, url@npm:^0.11.3":
version: 0.11.4
resolution: "url@npm:0.11.4"
dependencies:
@@ -8633,7 +9211,7 @@ __metadata:
languageName: node
linkType: hard
-"util@npm:^0.10.4":
+"util@npm:^0.10.3, util@npm:^0.10.4":
version: 0.10.4
resolution: "util@npm:0.10.4"
dependencies:
@@ -9154,7 +9732,7 @@ __metadata:
languageName: node
linkType: hard
-"yaml@npm:^2.4.2":
+"yaml@npm:^2.2.2, yaml@npm:^2.4.2":
version: 2.6.0
resolution: "yaml@npm:2.6.0"
bin: