diff --git a/.gitignore b/.gitignore index ec067552ff..47840dca52 100644 --- a/.gitignore +++ b/.gitignore @@ -97,6 +97,7 @@ opencode.json /.idea *.iml /.claude +/CLAUDE.md /.playwright-mcp /.devenv/mcp/ /opencode.json diff --git a/common/src/app/common/files/tokens.cljc b/common/src/app/common/files/tokens.cljc index 95ff5da4df..9232037bcf 100644 --- a/common/src/app/common/files/tokens.cljc +++ b/common/src/app/common/files/tokens.cljc @@ -166,7 +166,6 @@ (not (ctob/token-name-path-exists? token-name tokens-tree))) new-tokens))))]]) (defn find-refs [value] - (prn value) (cond (string? value) (cto/find-token-value-references value) diff --git a/frontend/src/app/main/data/style_dictionary.cljs b/frontend/src/app/main/data/style_dictionary.cljs index ba1f2dd67f..320231793f 100644 --- a/frontend/src/app/main/data/style_dictionary.cljs +++ b/frontend/src/app/main/data/style_dictionary.cljs @@ -584,11 +584,38 @@ (into {}))] (merge resolved dropped))) +(defn- valid-token-value? + [[_ token]] + (some? (:value token))) + +(def ^:private xform-invalid-value-tokens + (comp + (remove valid-token-value?) + (map (fn [[k token]] + [k (assoc token :errors [(wte/get-error-code :error.token/empty-input)])])))) + +(defn- merge-invalid-value-tokens + "Tokens with a `nil` value (e.g. a composite typography token saved with + no fields filled in) must never reach StyleDictionary: some of its + preprocessors (`@tokens-studio/sd-transforms`'s font-styles preprocessor, + in particular) assume a typography token's value is never null and throw + an uncaught exception when it is, taking down token resolution for the + whole file. + + `tokens` is the full, unfiltered token map; `resolved` only contains the + valid subset that was actually sent to StyleDictionary. Tag the invalid + ones with the same \"empty value\" error the token forms already use + instead of ever letting them reach the resolver." + [tokens resolved] + (into resolved xform-invalid-value-tokens tokens)) + (defn resolve-tokens [tokens] - (let [tokens-tree (ctob/tokens-tree tokens)] - (->> (resolve-tokens-tree tokens-tree #(get tokens (sd-token-name %))) - (rx/map #(merge-name-collisions tokens %))))) + (let [valid-tokens (into {} (filter valid-token-value?) tokens) + tokens-tree (ctob/tokens-tree valid-tokens)] + (->> (resolve-tokens-tree tokens-tree #(get valid-tokens (sd-token-name %))) + (rx/map #(merge-name-collisions valid-tokens %)) + (rx/map #(merge-invalid-value-tokens tokens %))))) (defn resolve-tokens-interactive "Interactive check of resolving tokens. @@ -610,15 +637,18 @@ computation we can restore any token, even clashing ones with the same :name path by just looking up that :id in the ids map." [tokens] - (let [{:keys [tokens-tree ids]} (ctob/backtrace-tokens-tree tokens)] - (->> (resolve-tokens-tree tokens-tree #(get ids (sd-token-uuid %))) - (rx/map #(merge-name-collisions tokens %))))) + (let [valid-tokens (into {} (filter valid-token-value?) tokens) + {:keys [tokens-tree ids]} (ctob/backtrace-tokens-tree valid-tokens)] + (->> (resolve-tokens-tree tokens-tree #(get ids (sd-token-uuid %))) + (rx/map #(merge-name-collisions valid-tokens %)) + (rx/map #(merge-invalid-value-tokens tokens %))))) (defn resolve-tokens-with-verbose-errors [tokens] - (resolve-tokens-tree - (ctob/tokens-tree tokens) - #(get tokens (sd-token-name %)) - (StyleDictionary. (assoc default-config :log {:verbosity "verbose"})))) + (let [valid-tokens (into {} (filter valid-token-value?) tokens)] + (resolve-tokens-tree + (ctob/tokens-tree valid-tokens) + #(get valid-tokens (sd-token-name %)) + (StyleDictionary. (assoc default-config :log {:verbosity "verbose"}))))) ;; === Hooks diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs index d671e7294c..0f8c03b819 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs @@ -276,7 +276,7 @@ (seq strokes) [:> h/sortable-container* {} (for [[index value] (d/enumerate (:strokes values []))] - [:> stroke-row* {:key (dm/str "stroke-" index "-" (hash applied-tokens)) + [:> stroke-row* {:key (dm/str "stroke-" index) :index index :stroke value :title (tr "workspace.options.stroke-color") diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs index 62b9a63410..b053c130e3 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs @@ -18,7 +18,6 @@ [app.main.ui.workspace.tokens.management.forms.generic-form :as generic] [app.main.ui.workspace.tokens.management.forms.validators :refer [check-coll-self-reference check-self-reference default-validate-token]] [app.util.i18n :refer [tr]] - [beicon.v2.core :as rx] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -43,11 +42,14 @@ (defn- validate-typography-token [{:keys [token-value] :as props}] (cond - ;; Entering form without a value - show no error just resolve nil - (nil? token-value) (rx/of nil) ;; Validate refrence string (cto/composite-token-reference? token-value) (default-validate-token props) - ;; Validate composite token + ;; Validate composite token. `token-value` may be nil when the form is + ;; submitted without any composite field filled in — normalize it to `{}` + ;; so `check-empty-typography-token` catches it and rejects the submit, + ;; instead of silently saving a token with a `nil` value (which later + ;; crashes token resolution: the tokens-studio StyleDictionary + ;; preprocessor assumes a typography token's value is never null). :else (-> props (update :token-value diff --git a/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs b/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs index 220dd1f36b..60222f6bae 100644 --- a/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs +++ b/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs @@ -122,6 +122,43 @@ (get-in resolved-tokens ["typography.bad" :errors 0 :error/code]))) (done)))))))) +;; Regression: a token with a `nil` value (e.g. a composite typography +;; token saved via the workspace form with no fields filled in) must never +;; reach StyleDictionary — its `tokens-studio` preprocessor assumes a +;; typography token's value is never null and throws an uncaught exception +;; on it, which used to take down resolution for every other token in the +;; file. It should be tagged with an empty-input error instead. +(t/deftest resolve-tokens-nil-value-test + (t/async + done + (let [tokens (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set :id (cthi/new-id! :core-set) + :name "core")) + (ctob/add-token (cthi/id :core-set) + (ctob/make-token {:name "typography.empty" + :value nil + :type :typography})) + (ctob/add-token (cthi/id :core-set) + (ctob/make-token {:name "borderRadius.sm" + :value "12px" + :type :border-radius})) + (ctob/get-all-tokens-map))] + (->> (sd/resolve-tokens tokens) + (rx/subs! + (fn [resolved-tokens] + (t/testing "the nil-value token is tagged with an error instead of crashing" + (t/is (contains? resolved-tokens "typography.empty")) + (t/is (nil? (get-in resolved-tokens ["typography.empty" :resolved-value]))) + (t/is (= :error.token/empty-input + (get-in resolved-tokens ["typography.empty" :errors 0 :error/code])))) + (t/testing "other tokens still resolve normally" + (t/is (= 12 (get-in resolved-tokens ["borderRadius.sm" :resolved-value]))))) + (fn [err] + (t/do-report {:type :error :message "Stream error" :actual err}) + (done)) + (fn [] + (done))))))) + (t/deftest resolve-tokens-interactive-test (t/async done