From b82679deafcb7ca806357f26c78ed5e17faab989 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 31 Oct 2024 15:57:35 +0100 Subject: [PATCH 1/4] :tada: Merge tokens-studio --- .circleci/config.yml | 6 - backend/scripts/repl | 3 +- backend/scripts/start-dev | 3 +- common/src/app/common/data.cljc | 52 + common/src/app/common/features.cljc | 5 +- common/src/app/common/files/changes.cljc | 182 ++- .../src/app/common/files/changes_builder.cljc | 111 ++ common/src/app/common/fressian.clj | 8 +- common/src/app/common/time.cljc | 7 + common/src/app/common/types/file.cljc | 4 +- common/src/app/common/types/shape.cljc | 2 + common/src/app/common/types/shape/attrs.cljc | 4 + common/src/app/common/types/token.cljc | 151 +++ common/src/app/common/types/token_theme.cljc | 25 + common/src/app/common/types/tokens_lib.cljc | 947 ++++++++++++++ common/src/app/common/types/tokens_list.cljc | 49 + .../app/common/types/tokens_theme_list.cljc | 79 ++ .../types/data/tokens-multi-set-example.json | 803 ++++++++++++ .../common_tests/types/tokens_lib_test.cljc | 1142 +++++++++++++++++ frontend/package.json | 9 +- .../resources/images/icons/arrow-down.svg | 3 + .../resources/images/icons/arrow-left.svg | 3 + .../resources/images/icons/arrow-right.svg | 3 + frontend/resources/images/icons/arrow-up.svg | 3 + frontend/shadow-cljs.edn | 73 +- frontend/src/app/main/data/tokens.cljs | 352 +++++ .../src/app/main/data/workspace/layout.cljs | 6 +- frontend/src/app/main/refs.cljs | 64 + frontend/src/app/main/ui/comments.cljs | 6 +- .../app/main/ui/components/tab_container.cljs | 2 + frontend/src/app/main/ui/context.cljs | 1 + .../src/app/main/ui/ds/buttons/_buttons.scss | 18 + .../app/main/ui/ds/buttons/icon_button.cljs | 6 +- .../app/main/ui/ds/buttons/icon_button.scss | 4 + .../ui/ds/buttons/icon_button.stories.jsx | 8 +- .../main/ui/ds/foundations/assets/icon.cljs | 4 + .../ui/ds/foundations/typography/heading.cljs | 22 +- frontend/src/app/main/ui/workspace.cljs | 27 +- .../app/main/ui/workspace/colorpicker.cljs | 37 +- .../src/app/main/ui/workspace/sidebar.cljs | 15 +- .../ui/workspace/sidebar/assets/common.cljs | 4 +- .../ui/workspace/sidebar/assets/groups.cljs | 10 +- .../sidebar/options/menus/measures.cljs | 121 +- .../sidebar/options/menus/measures.scss | 2 + .../app/main/ui/workspace/tokens/changes.cljs | 183 +++ .../app/main/ui/workspace/tokens/common.cljs | 131 ++ .../app/main/ui/workspace/tokens/common.scss | 115 ++ .../ui/workspace/tokens/context_menu.cljs | 336 +++++ .../ui/workspace/tokens/context_menu.scss | 103 ++ .../app/main/ui/workspace/tokens/core.cljs | 34 + .../ui/workspace/tokens/editable_select.cljs | 301 +++++ .../ui/workspace/tokens/editable_select.scss | 155 +++ .../app/main/ui/workspace/tokens/errors.cljs | 63 + .../app/main/ui/workspace/tokens/form.cljs | 431 +++++++ .../app/main/ui/workspace/tokens/form.scss | 85 ++ .../app/main/ui/workspace/tokens/modals.cljs | 146 +++ .../app/main/ui/workspace/tokens/modals.scss | 24 + .../ui/workspace/tokens/modals/themes.cljs | 369 ++++++ .../ui/workspace/tokens/modals/themes.scss | 198 +++ .../app/main/ui/workspace/tokens/sets.cljs | 270 ++++ .../app/main/ui/workspace/tokens/sets.scss | 125 ++ .../ui/workspace/tokens/sets_context.cljs | 47 + .../workspace/tokens/sets_context_menu.cljs | 65 + .../workspace/tokens/sets_context_menu.scss | 46 + .../app/main/ui/workspace/tokens/sidebar.cljs | 360 ++++++ .../app/main/ui/workspace/tokens/sidebar.scss | 196 +++ .../ui/workspace/tokens/style_dictionary.cljs | 261 ++++ .../ui/workspace/tokens/theme_select.cljs | 117 ++ .../ui/workspace/tokens/theme_select.scss | 124 ++ .../main/ui/workspace/tokens/tinycolor.cljs | 27 + .../app/main/ui/workspace/tokens/token.cljs | 142 ++ .../main/ui/workspace/tokens/token_set.cljs | 56 + .../main/ui/workspace/tokens/token_types.cljs | 88 ++ .../app/main/ui/workspace/tokens/update.cljs | 135 ++ frontend/src/app/util/functions.cljs | 6 +- frontend/test/frontend_tests/runner.cljs | 30 +- .../frontend_tests/tokens/helpers/state.cljs | 74 ++ .../frontend_tests/tokens/helpers/tokens.cljs | 26 + .../tokens/logic/token_actions_test.cljs | 407 ++++++ .../tokens/style_dictionary_test.cljs | 115 ++ .../tokens/token_form_test.cljs | 26 + .../frontend_tests/tokens/token_test.cljs | 100 ++ frontend/translations/en.po | 120 ++ frontend/translations/es.po | 124 ++ frontend/yarn.lock | 602 ++++++++- 85 files changed, 10581 insertions(+), 168 deletions(-) create mode 100644 common/src/app/common/types/token.cljc create mode 100644 common/src/app/common/types/token_theme.cljc create mode 100644 common/src/app/common/types/tokens_lib.cljc create mode 100644 common/src/app/common/types/tokens_list.cljc create mode 100644 common/src/app/common/types/tokens_theme_list.cljc create mode 100644 common/test/common_tests/types/data/tokens-multi-set-example.json create mode 100644 common/test/common_tests/types/tokens_lib_test.cljc create mode 100644 frontend/resources/images/icons/arrow-down.svg create mode 100644 frontend/resources/images/icons/arrow-left.svg create mode 100644 frontend/resources/images/icons/arrow-right.svg create mode 100644 frontend/resources/images/icons/arrow-up.svg create mode 100644 frontend/src/app/main/data/tokens.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/changes.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/common.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/common.scss create mode 100644 frontend/src/app/main/ui/workspace/tokens/context_menu.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/context_menu.scss create mode 100644 frontend/src/app/main/ui/workspace/tokens/core.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/editable_select.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/editable_select.scss create mode 100644 frontend/src/app/main/ui/workspace/tokens/errors.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/form.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/form.scss create mode 100644 frontend/src/app/main/ui/workspace/tokens/modals.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/modals.scss create mode 100644 frontend/src/app/main/ui/workspace/tokens/modals/themes.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/modals/themes.scss create mode 100644 frontend/src/app/main/ui/workspace/tokens/sets.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/sets.scss create mode 100644 frontend/src/app/main/ui/workspace/tokens/sets_context.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/sets_context_menu.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/sets_context_menu.scss create mode 100644 frontend/src/app/main/ui/workspace/tokens/sidebar.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/sidebar.scss create mode 100644 frontend/src/app/main/ui/workspace/tokens/style_dictionary.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/theme_select.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/theme_select.scss create mode 100644 frontend/src/app/main/ui/workspace/tokens/tinycolor.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/token.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/token_set.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/token_types.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/update.cljs create mode 100644 frontend/test/frontend_tests/tokens/helpers/state.cljs create mode 100644 frontend/test/frontend_tests/tokens/helpers/tokens.cljs create mode 100644 frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs create mode 100644 frontend/test/frontend_tests/tokens/style_dictionary_test.cljs create mode 100644 frontend/test/frontend_tests/tokens/token_form_test.cljs create mode 100644 frontend/test/frontend_tests/tokens/token_test.cljs 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/backend/scripts/repl b/backend/scripts/repl index eec5ba5aa..ab026e5fd 100755 --- a/backend/scripts/repl +++ b/backend/scripts/repl @@ -30,7 +30,8 @@ export PENPOT_FLAGS="\ enable-access-tokens \ enable-tiered-file-data-storage \ enable-file-validation \ - enable-file-schema-validation"; + enable-file-schema-validation \ + disable-feature-design-tokens"; # Default deletion delay for devenv export PENPOT_DELETION_DELAY="24h" diff --git a/backend/scripts/start-dev b/backend/scripts/start-dev index 65ccbc9c1..bb9e23aca 100755 --- a/backend/scripts/start-dev +++ b/backend/scripts/start-dev @@ -22,7 +22,8 @@ export PENPOT_FLAGS="\ enable-access-tokens \ enable-tiered-file-data-storage \ enable-file-validation \ - enable-file-schema-validation"; + enable-file-schema-validation \ + disable-feature-design-tokens"; export OPTIONS=" -A:jmx-remote -A:dev \ 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/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..26a02f5ea 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,6 +101,9 @@ assets-tab (mf/html [:& assets-toolbox {:size (- size 58)}]) + tokens-tab + (mf/html [:& tokens-sidebar-tab]) + tabs (if ^boolean mode-inspect? #js [#js {:label (tr "workspace.sidebar.layers") @@ -107,7 +114,11 @@ :content layers-tab} #js {:label (tr "workspace.toolbar.assets") :id "assets" - :content assets-tab}])] + :content assets-tab} + (when design-tokens? + #js {:label "Tokens" + :id "tokens" + :content tokens-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..0dc52d062 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] @@ -24,6 +26,10 @@ [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]] [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]] @@ -96,6 +102,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 +243,20 @@ (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)] + (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 +302,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 +327,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 +456,34 @@ :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)}]] + [:& 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)}]] + [:& 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 +540,16 @@ [:div {:class (stl/css :radius-1) :title (tr "workspace.options.radius")} [:span {:class (stl/css :icon)} i/corner-radius] - [:> numeric-input* + [:& editable-select {:placeholder (if (= :multiple (:rx values)) (tr "settings.multiple") "--") - :ref radius-input-ref - :min 0 + :class (stl/css :token-select) :on-change on-radius-1-change - :class (stl/css :numeric-input) - :value (:rx values)}]] + :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 8a26dcf90..3f9f6b16b 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" @@ -6355,6 +6367,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 893b3a263..29529bc1b 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" @@ -6336,6 +6348,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: From 99fcd3556ea167e09031d4c7094005beaf6b8c53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Moya?= Date: Tue, 5 Nov 2024 13:21:30 +0100 Subject: [PATCH 2/4] :wrench: Disable tokens in dev env by default --- backend/scripts/repl | 3 +-- backend/scripts/start-dev | 3 +-- frontend/src/app/main/features.cljs | 4 +++- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/scripts/repl b/backend/scripts/repl index ab026e5fd..eec5ba5aa 100755 --- a/backend/scripts/repl +++ b/backend/scripts/repl @@ -30,8 +30,7 @@ export PENPOT_FLAGS="\ enable-access-tokens \ enable-tiered-file-data-storage \ enable-file-validation \ - enable-file-schema-validation \ - disable-feature-design-tokens"; + enable-file-schema-validation"; # Default deletion delay for devenv export PENPOT_DELETION_DELAY="24h" diff --git a/backend/scripts/start-dev b/backend/scripts/start-dev index bb9e23aca..65ccbc9c1 100755 --- a/backend/scripts/start-dev +++ b/backend/scripts/start-dev @@ -22,8 +22,7 @@ export PENPOT_FLAGS="\ enable-access-tokens \ enable-tiered-file-data-storage \ enable-file-validation \ - enable-file-schema-validation \ - disable-feature-design-tokens"; + enable-file-schema-validation"; export OPTIONS=" -A:jmx-remote -A:dev \ 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)))) From e641e93fd583d99464b81e9f458132fb5ddf7d43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Moya?= Date: Tue, 5 Nov 2024 15:08:09 +0100 Subject: [PATCH 3/4] :bug: Fix sidebar tabs when there are no design tokens --- .../src/app/main/ui/workspace/sidebar.cljs | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar.cljs b/frontend/src/app/main/ui/workspace/sidebar.cljs index 26a02f5ea..6886108a0 100644 --- a/frontend/src/app/main/ui/workspace/sidebar.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar.cljs @@ -102,23 +102,30 @@ (mf/html [:& assets-toolbox {:size (- size 58)}]) tokens-tab - (mf/html [:& tokens-sidebar-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} - (when design-tokens? + (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})])] + :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 From 3393963363a5872d127ce21851b0e1cbfed6cf81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Moya?= Date: Tue, 5 Nov 2024 16:01:40 +0100 Subject: [PATCH 4/4] :wrench: Apply feature flag to measures inputs --- .../sidebar/options/menus/measures.cljs | 120 +++++++++++------- 1 file changed, 74 insertions(+), 46 deletions(-) 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 0dc52d062..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 @@ -24,6 +24,7 @@ [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] @@ -90,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) @@ -245,18 +248,19 @@ (fn [value attr] (let [token-value (wtc/maybe-resolve-token-value value) undo-id (js/Symbol)] - (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))))) - - + (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 @@ -456,34 +460,50 @@ :disabled disabled-width-sizing?) :title (tr "workspace.options.width")} [:span {:class (stl/css :icon-text)} "W"] - [:& 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}}]] + (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"] - [:& 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}}]] + (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) @@ -540,16 +560,24 @@ [:div {:class (stl/css :radius-1) :title (tr "workspace.options.radius")} [:span {:class (stl/css :icon)} i/corner-radius] - [:& 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}}]] + (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)