diff --git a/frontend/src/app/main/data/tokenscript.cljs b/frontend/src/app/main/data/tokenscript.cljs index 22e07efc66..7f0cbee92e 100644 --- a/frontend/src/app/main/data/tokenscript.cljs +++ b/frontend/src/app/main/data/tokenscript.cljs @@ -8,9 +8,12 @@ processTokens TokenSymbol makeConfig]] + [app.common.data :as d] [app.common.logging :as l] [app.common.time :as ct] - [app.main.data.workspace.tokens.errors :as wte])) + [app.common.types.token :as cto] + [app.main.data.workspace.tokens.errors :as wte] + [cuerdas.core :as str])) (l/set-level! :debug) @@ -78,6 +81,17 @@ (declare tokenscript-symbols->penpot-unit) +(defn font-family-symbols->penpot-unit + "Converts a resolved font-family value into a vector of family names. + Each family name is the string form of its whole entry, which is a list of + word symbols when the family reached tokenscript unquoted." + [^js v] + (when (some? v) + (let [entries (.-value v)] + (if (instance? js/Array entries) + (mapv str entries) + [(str v)])))) + (defn structured-token->penpot-map "Converts structured token (record or array) to penpot map format. Structured tokens are non-primitive token types like `typography` or `box-shadow`." @@ -85,9 +99,13 @@ (if (instance? js/Array (.-value token-symbol)) (mapv tokenscript-symbols->penpot-unit (.-value token-symbol)) (let [entries (es6-iterator-seq (.entries (.-value token-symbol)))] - (into {} (map (fn [[k v :as V]] - [(keyword k) (tokenscript-symbols->penpot-unit v)]) - entries))))) + (into {} (map (fn [[k v]] + (let [k (keyword k)] + ;; The font-family member of a composite is a family list + [k (if (= :font-family k) + (font-family-symbols->penpot-unit v) + (tokenscript-symbols->penpot-unit v))]))) + entries)))) (defn tokenscript-symbols->penpot-unit [^js v] (cond @@ -99,6 +117,15 @@ (percent-number-with-unit? v) (/ (.-value v) 100) :else (.-value v))) +(defn resolved-value->penpot-unit + "Converts the resolved value of a token of the given `type`. + Font families need the type: a list of symbols is a list of families at the + top level and the words of a single family name inside an entry." + [type ^js v] + (if (= :font-family type) + (font-family-symbols->penpot-unit v) + (tokenscript-symbols->penpot-unit v))) + ;; Processors ------------------------------------------------------------------ ;; The processor resolves tokens ;; resolved/error tokens get put back into a clojure structure directly during build time @@ -130,11 +157,45 @@ :onError on-error :getResult get-result})) +(defn- quote-font-family + "Quotes a font family name so tokenscript reads it as one string instead of + parsing its words (`Red` would become a color, `2P` a number and a word). + Entries with a token reference or already quoted are kept as they are." + [family] + (cond + (or (not (string? family)) + (seq (cto/find-token-value-references family)) + (re-matches #"^(['\"]).*\1$" family)) + family + + (not (str/includes? family "\"")) + (str "\"" family "\"") + + (not (str/includes? family "'")) + (str "'" family "'") + + :else + family)) + +(defn- quote-font-families + [value] + (if (sequential? value) + (mapv quote-font-family value) + value)) + +(defn- token-value->tokenscript-value + [type value] + (case type + :font-family (quote-font-families value) + :typography (cond-> value + (map? value) (d/update-when :font-family quote-font-families)) + value)) + (defn clj->token->tokenscript-token "Convert penpot token into a format that tokenscript can handle." [{:keys [type value]}] #js {"$type" (name type) - "$value" (clj->js value)}) + "$value" (clj->js (token-value->tokenscript-value type value))}) (defn clj-tokens->tokenscript-tokens "Convert penpot map of tokens into tokenscript map structure. diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index b2d79480a4..96114a0010 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -699,7 +699,7 @@ resolved-value (get-in resolved-tokens [(:name token) :resolved-value]) resolved-value (if (contains? cf/flags :tokenscript) - (ts/tokenscript-symbols->penpot-unit resolved-value) + (ts/resolved-value->penpot-unit (:type token) resolved-value) resolved-value) tokenized-attributes (cfo/attributes-map attributes token) type (:type token)] diff --git a/frontend/src/app/main/data/workspace/tokens/propagation.cljs b/frontend/src/app/main/data/workspace/tokens/propagation.cljs index aa8a4b3733..1b525da76c 100644 --- a/frontend/src/app/main/data/workspace/tokens/propagation.cljs +++ b/frontend/src/app/main/data/workspace/tokens/propagation.cljs @@ -194,7 +194,8 @@ (when-let [tokens-tree (cfo/get-tokens-in-active-sets tokens-status tokens-lib)] (->> (if (contains? cf/flags :tokenscript) (rx/of (-> (ts/resolve-tokens tokens-tree) - (d/update-vals #(update % :resolved-value ts/tokenscript-symbols->penpot-unit)))) + (d/update-vals #(update % :resolved-value + (partial ts/resolved-value->penpot-unit (:type %)))))) (sd/resolve-tokens tokens-tree)) (rx/mapcat (fn [sd-tokens] diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs index 88e8346580..48d8586565 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs @@ -78,7 +78,7 @@ (fn [resolved-tokens] (let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token)) resolved-value (if (contains? cf/flags :tokenscript) - (ts/tokenscript-symbols->penpot-unit resolved-value) + (ts/resolved-value->penpot-unit (:type token) resolved-value) resolved-value)] (if resolved-value (rx/of {:value resolved-value}) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs index 4fb9be3e4f..428d2fee31 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs @@ -46,7 +46,8 @@ (fn [resolved-tokens] (let [resolved-token (cond-> (get resolved-tokens (:name token)) (contains? cf/flags :tokenscript) - (update :resolved-value ts/tokenscript-symbols->penpot-unit))] + (update :resolved-value + (partial ts/resolved-value->penpot-unit (:type token))))] (cond (:resolved-value resolved-token) (rx/of resolved-token) diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index 922ae739d4..63bbd90d04 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -149,13 +149,10 @@ (defn- font-families-resolved-value->js "Converts a resolved fontFamilies value (a tokenscript list symbol) into the - documented `string[]` shape rather than leaking the raw tokenscript structure." + documented `string[]` shape." [resolved-value] - (let [v (ts/tokenscript-symbols->penpot-unit resolved-value)] - (cond - (nil? v) nil - (sequential? v) (clj->js v) - :else #js [v]))) + (some-> (ts/font-family-symbols->penpot-unit resolved-value) + (clj->js))) (defn- get-resolved-value [token tokens-tree] diff --git a/frontend/test/frontend_tests/plugins/tokens_test.cljs b/frontend/test/frontend_tests/plugins/tokens_test.cljs index 99ef7f4685..e52bfa25ce 100644 --- a/frontend/test/frontend_tests/plugins/tokens_test.cljs +++ b/frontend/test/frontend_tests/plugins/tokens_test.cljs @@ -422,6 +422,71 @@ (t/is (array? result)) (t/is (= ["Inter" "Arial"] (vec result))))) +(t/deftest font-family-token-resolved-value-keeps-multi-word-families + ;; An unquoted family whose name has several words resolves to a list of word + ;; symbols, so the family name is the string form of each entry. + (let [token (ctob/make-token + {:name "font.body" + :type :font-family + :value ["Hanken Grotesk" "IBM Plex Mono"]}) + result (get-resolved-value token {(:name token) token})] + (t/is (array? result)) + (t/is (= ["Hanken Grotesk" "IBM Plex Mono"] (vec result))))) + +(t/deftest typography-token-resolved-value-keeps-multi-word-families + (let [token (ctob/make-token + {:name "type.body" + :type :typography + :value {:font-family ["Hanken Grotesk" "Arial"] + :font-size "16px"}}) + result (get-resolved-value token {(:name token) token}) + entry (aget result 0)] + (t/is (= ["Hanken Grotesk" "Arial"] (vec (aget entry "fontFamilies")))))) + +(t/deftest font-family-token-resolved-value-keeps-names-with-expression-words + ;; Words like `Red` or `Black` read as colors and `2P` as a number and a word + ;; when tokenscript parses the family name. + (let [families ["Red Hat Display" "Crimson Pro" "Archivo Black" + "Press Start 2P" "M PLUS 1p" "Crimson"] + token (ctob/make-token + {:name "font.display" + :type :font-family + :value families}) + result (get-resolved-value token {(:name token) token})] + (t/is (= families (vec result))))) + +(t/deftest font-family-token-resolved-value-mixes-references-and-names + (let [base (ctob/make-token + {:name "font.base" + :type :font-family + :value ["Red Hat Text"]}) + token (ctob/make-token + {:name "font.stack" + :type :font-family + :value ["{font.base}" "Rock 3D"]}) + result (get-resolved-value token {(:name base) base + (:name token) token})] + (t/is (= ["Red Hat Text" "Rock 3D"] (vec result))))) + +(t/deftest font-family-token-resolved-value-keeps-quoted-names + (let [token (ctob/make-token + {:name "font.quoted" + :type :font-family + :value ["\"Red Hat Mono\"" "'Black Ops One'"]}) + result (get-resolved-value token {(:name token) token})] + (t/is (= ["Red Hat Mono" "Black Ops One"] (vec result))))) + +(t/deftest typography-token-resolved-value-keeps-names-with-expression-words + (let [token (ctob/make-token + {:name "type.display" + :type :typography + :value {:font-family ["Red Hat Display" "Press Start 2P"] + :font-size "16px"}}) + result (get-resolved-value token {(:name token) token}) + entry (aget result 0)] + (t/is (= ["Red Hat Display" "Press Start 2P"] + (vec (aget entry "fontFamilies")))))) + (t/deftest token-theme-add-set-accepts-token-set-id (let [plugin-id "plugin-id" file-id (uuid/next) diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 35edd55869..e374e859f2 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -15,6 +15,7 @@ - **plugins-runtime**: Setting an individual padding/margin side (`leftPadding`, `topMargin`, …) now re-derives the padding/margin type, switching to `multiple` when the four sides stop being symmetric (so the value is actually painted) and back to `simple` once top/bottom and left/right are mirrored again. - **plugins-runtime**: Removed the premature deep-hardening of the host plugin context, which froze shared host functions (including `Function.prototype`) before SES override taming, causing `TypeError: Cannot assign to read only property 'toString'` on later host-side function extension. Related to #11001. - **plugins-runtime**: Fixed the `fontFamilies` token property mapping so `Shape.applyToken(token, ["fontFamilies"])` resolves to the canonical `:font-family` attribute and applied-token readback exposes the documented `fontFamilies` key instead of the undocumented singular `fontFamily`. Closes #11405. +- **plugins-runtime**: `TokenFontFamilies.resolvedValue` and the `fontFamilies` member of `TokenTypographyValue` now keep a family whose name has several words (`Hanken Grotesk`) as one entry, instead of splitting it into an array of its words, and keep names with words the resolver would otherwise read as colors or numbers (`Red Hat Display`, `Press Start 2P`) unchanged. ## 1.5.0 (2026-07-08) diff --git a/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts index 0b3ae9be6c..ebc31ce255 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts @@ -306,6 +306,31 @@ describe('Tokens', () => { expect(Object.keys(b.tokens)).toContain('paddingLeft'); }); + // The target is a text shape because the attribute set of a board excludes + // the font-family property, so the application is filtered out there and + // the assertions pass vacuously. + // The family comes from the bundled Google fonts catalog, available in every + // run. `Red` in its name reads as a color if the resolver parses the words. + test('applyToken binds a fontFamilies token to a text shape', async (ctx) => { + const set = activeSet(ctx, unique('set')); + const font = ctx.penpot.fonts.findByName('Red Hat Display'); + if (!font) throw new Error('Red Hat Display font not found'); + const token = set.addToken({ + type: 'fontFamilies', + name: unique('fontFamilies.'), + value: [font.fontFamily], + }); + + const t = ctx.penpot.createText('Hello Penpot'); + if (!t) throw new Error('createText returned null'); + ctx.board.appendChild(t); + + t.applyToken(token, ['fontFamilies']); + await waitFor(() => t.fontFamily === font.fontFamily); + expect(Object.keys(t.tokens)).toContain('fontFamilies'); + expect(t.fontFamily).toBe(font.fontFamily); + }); + test('duplicate and remove a token', (ctx) => { const set = activeSet(ctx, unique('set')); const token = set.addToken({ @@ -400,6 +425,39 @@ describe('Token types', () => { expect(resolved as unknown as string[]).toContain('Arial'); }); + // The resolver parses a family whose name has several words as a list of + // word symbols, so each family name must survive as a single entry. A + // single-word family takes the simpler path the test above covers. + test('fontFamilies token resolvedValue keeps multi-word families whole', (ctx) => { + const set = activeSet(ctx, unique('set')); + const token = set.addToken({ + type: 'fontFamilies', + name: unique('fontFamilies.'), + value: ['Hanken Grotesk'], + }); + // The stored value keeps the family whole, isolating resolution below. + expect(token.value).toEqual(['Hanken Grotesk']); + + const resolved = token.resolvedValue as unknown as string[]; + expect(Array.isArray(resolved)).toBe(true); + expect(resolved).toHaveLength(1); + expect(resolved[0]).toBe('Hanken Grotesk'); + }); + + // Words like `Red` or `Black` read as colors and `2P` as a number and a word + // when the resolver parses the family name. + test('fontFamilies token resolvedValue keeps names with expression words', (ctx) => { + const set = activeSet(ctx, unique('set')); + const families = ['Red Hat Display', 'Archivo Black', 'Press Start 2P']; + const token = set.addToken({ + type: 'fontFamilies', + name: unique('fontFamilies.'), + value: families, + }); + + expect(token.resolvedValue as unknown as string[]).toEqual(families); + }); + test('shadow token exposes its composite value', (ctx) => { const set = activeSet(ctx, unique('set')); const token = set.addToken({