From 9106617436c34b5fd74b71f7b433d45cfad673c2 Mon Sep 17 00:00:00 2001 From: Florian Schroedl Date: Mon, 18 Aug 2025 13:43:20 +0200 Subject: [PATCH] :tada: Add composite typography token --- common/src/app/common/types/token.cljc | 26 +- .../get-file-fragment-typography-tokens.json | 264 +++++++++++ .../workspace/get-file-typography-tokens.json | 400 ++++++++++++++++ frontend/playwright/ui/specs/tokens.spec.js | 101 +++- .../src/app/main/data/style_dictionary.cljs | 101 +++- .../data/workspace/tokens/application.cljs | 114 +++-- .../data/workspace/tokens/propagation.cljs | 10 +- .../tokens/management/create/form.cljs | 444 ++++++++++++------ .../tokens/management/create/form.scss | 6 + .../management/create/input_tokens_value.cljs | 47 +- .../tokens/management/create/modals.cljs | 2 +- .../ui/workspace/tokens/management/group.cljs | 2 +- .../tokens/management/token_pill.cljs | 29 +- .../tokens/logic/token_actions_test.cljs | 167 +++++++ 14 files changed, 1480 insertions(+), 233 deletions(-) create mode 100644 frontend/playwright/data/workspace/get-file-fragment-typography-tokens.json create mode 100644 frontend/playwright/data/workspace/get-file-typography-tokens.json diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index a9a9f735b9..fc2eed736f 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -48,7 +48,8 @@ :stroke-width "borderWidth" :text-case "textCase" :text-decoration "textDecoration" - :font-weight "fontWeights"}) + :font-weight "fontWeights" + :typography "typography"}) (def dtcg-token-type->token-type (set/map-invert token-type->dtcg-token-type)) @@ -180,6 +181,12 @@ (def font-weight-keys (schema-keys schema:font-weight)) +(def ^:private schema:typography + [:map + [:typography {:optional true} token-name-ref]]) + +(def typography-token-keys (schema-keys schema:typography)) + (def ^:private schema:text-decoration [:map [:text-decoration {:optional true} token-name-ref]]) @@ -192,7 +199,8 @@ font-weight-keys text-case-keys text-decoration-keys - font-weight-keys)) + font-weight-keys + typography-token-keys)) ;; TODO: Created to extract the font-size feature from the typography feature flag. ;; Delete this once the typography feature flag is removed. @@ -215,6 +223,7 @@ axis-keys rotation-keys typography-keys + typography-token-keys number-keys)) (def ^:private schema:tokens @@ -260,12 +269,13 @@ changed-sub-attr #{:m1 :m2 :m3 :m4}) - (font-size-keys shape-attr) #{shape-attr} - (letter-spacing-keys shape-attr) #{shape-attr} - (font-family-keys shape-attr) #{shape-attr} - (text-case-keys shape-attr) #{shape-attr} - (text-decoration-keys shape-attr) #{shape-attr} - (font-weight-keys shape-attr) #{shape-attr} + (font-size-keys shape-attr) #{shape-attr :typography} + (letter-spacing-keys shape-attr) #{shape-attr :typography} + (font-family-keys shape-attr) #{shape-attr :typography} + (text-case-keys shape-attr) #{shape-attr :typography} + (text-decoration-keys shape-attr) #{shape-attr :typography} + (font-weight-keys shape-attr) #{shape-attr :typography} + (border-radius-keys shape-attr) #{shape-attr} (sizing-keys shape-attr) #{shape-attr} (opacity-keys shape-attr) #{shape-attr} diff --git a/frontend/playwright/data/workspace/get-file-fragment-typography-tokens.json b/frontend/playwright/data/workspace/get-file-fragment-typography-tokens.json new file mode 100644 index 0000000000..a0de35cfac --- /dev/null +++ b/frontend/playwright/data/workspace/get-file-fragment-typography-tokens.json @@ -0,0 +1,264 @@ +{ + "~:id": "~u021b87d4-813e-8066-8006-b36537098786", + "~:file-id": "~uef9b2783-804c-8017-8006-ae6f7eab52ad", + "~:created-at": "~m1756113434655", + "~:data": { + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0.0, + "~:y": 0.0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0.0, + "~:y": 0.01 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 0, + "~:proportion": 1.0, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 0.01, + "~:height": 0.01, + "~:x1": 0, + "~:y1": 0, + "~:x2": 0.01, + "~:y2": 0.01 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": ["~u97915939-ccca-808f-8006-aeb8eee2aa47"] + } + }, + "~u97915939-ccca-808f-8006-aeb8eee2aa47": { + "~#shape": { + "~:y": 301, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:auto-width", + "~:content": { + "~:type": "root", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "unset", + "~:text-align": "left", + "~:font-id": "gfont-open-sans", + "~:font-size": "40", + "~:font-weight": "400", + "~:text-direction": "ltr", + "~:font-variant-id": "regular", + "~:weight": "400", + "~:text-decoration": "none", + "~:letter-spacing": "2", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "Open Sans", + "~:text": "Some Text" + } + ], + "~:text-transform": "unset", + "~:text-align": "left", + "~:font-id": "gfont-open-sans", + "~:key": "f7ij3", + "~:font-size": "40", + "~:font-weight": "400", + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:weight": "400", + "~:text-decoration": "none", + "~:letter-spacing": "2", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "Open Sans" + } + ] + } + ] + }, + "~:hide-in-viewer": false, + "~:name": "Some Text", + "~:width": 214, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 376, + "~:y": 301 + } + }, + { + "~#point": { + "~:x": 590, + "~:y": 301 + } + }, + { + "~#point": { + "~:x": 590, + "~:y": 349 + } + }, + { + "~#point": { + "~:x": 376, + "~:y": 349 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:opacity": 1, + "~:id": "~u97915939-ccca-808f-8006-aeb8eee2aa47", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:applied-tokens": {}, + "~:position-data": [ + { + "~#rect": { + "~:y": 352.33333444595337, + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-size": "40px", + "~:font-weight": "400", + "~:y1": -3.3333334922790527, + "~:width": 213.5729217529297, + "~:text-decoration": "none solid rgb(0, 0, 0)", + "~:letter-spacing": "2px", + "~:x": 376, + "~:x1": 0, + "~:y2": 51.33333444595337, + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:x2": 213.5729217529297, + "~:direction": "ltr", + "~:font-family": "\"Open Sans\"", + "~:height": 54.66666793823242, + "~:text": "Some Text" + } + } + ], + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:x": 376, + "~:selrect": { + "~#rect": { + "~:x": 376, + "~:y": 301, + "~:width": 214, + "~:height": 48, + "~:x1": 376, + "~:y1": 301, + "~:x2": 590, + "~:y2": 349 + } + }, + "~:flip-x": null, + "~:height": 48, + "~:flip-y": null + } + } + }, + "~:id": "~uef9b2783-804c-8017-8006-ae6f7eab52ae", + "~:name": "Page 1" + } +} diff --git a/frontend/playwright/data/workspace/get-file-typography-tokens.json b/frontend/playwright/data/workspace/get-file-typography-tokens.json new file mode 100644 index 0000000000..746aeb132d --- /dev/null +++ b/frontend/playwright/data/workspace/get-file-typography-tokens.json @@ -0,0 +1,400 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:team-id": "~ub34726bd-fd32-8122-8004-b1a4bef38917", + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "Typography", + "~:revn": 133, + "~:modified-at": "~m1756113434658", + "~:vern": 0, + "~:id": "~uef9b2783-804c-8017-8006-ae6f7eab52ad", + "~:is-shared": false, + "~:migrations": { + "~#ordered-set": [] + }, + "~:version": 67, + "~:project-id": "~u0df61468-6cbf-8067-8005-6b453ce996d0", + "~:created-at": "~m1755780585133", + "~:data": { + "~:pages": ["~uef9b2783-804c-8017-8006-ae6f7eab52ae"], + "~:pages-index": { + "~uef9b2783-804c-8017-8006-ae6f7eab52ae": { + "~#penpot/pointer": [ + "~u021b87d4-813e-8066-8006-b36537098786", + { + "~:created-at": "~m1756113434662" + } + ] + } + }, + "~:id": "~uef9b2783-804c-8017-8006-ae6f7eab52ad", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + }, + "~:tokens-lib": { + "~#penpot/tokens-lib": { + "~:sets": { + "~#ordered-map": [ + [ + "S-Typography", + { + "~#penpot/token-set": { + "~:id": "~u7239ff52-c1d9-8014-8006-ae7ad49690e3", + "~:name": "Typography", + "~:description": "", + "~:modified-at": "~m1755800261695", + "~:tokens": { + "~#ordered-map": [ + [ + "42dotSans", + { + "~#penpot/token": { + "~:id": "~u7239ff52-c1d9-8014-8006-ae7ac05000f0", + "~:name": "42dotSans", + "~:type": "~:font-family", + "~:value": ["Aboreto"], + "~:description": "", + "~:modified-at": "~m1755798229771" + } + } + ], + [ + "Full", + { + "~#penpot/token": { + "~:id": "~u7239ff52-c1d9-8014-8006-ae7ad49690e3", + "~:name": "Full", + "~:type": "~:typography", + "~:value": { + "~:font-family": ["42dot Sans"], + "~:font-size": "100", + "~:font-weight": "300", + "~:letter-spacing": "2", + "~:text-case": "uppercase", + "~:text-decoration": "underline" + }, + "~:description": "", + "~:modified-at": "~m1755800261695" + } + } + ], + [ + "FontSizeOnly", + { + "~#penpot/token": { + "~:id": "~u97915939-ccca-808f-8006-aeb2b3f4dea3", + "~:name": "FontSizeOnly", + "~:type": "~:typography", + "~:value": { + "~:font-size": "10" + }, + "~:description": "", + "~:modified-at": "~m1755798203347" + } + } + ], + [ + "Empty", + { + "~#penpot/token": { + "~:id": "~u97915939-ccca-808f-8006-aeb2b795ca5b", + "~:name": "Empty", + "~:type": "~:typography", + "~:value": {}, + "~:description": "", + "~:modified-at": "~m1755798207063" + } + } + ], + [ + "WithAtomicReference", + { + "~#penpot/token": { + "~:id": "~u97915939-ccca-808f-8006-aeb2c2049024", + "~:name": "WithAtomicReference", + "~:type": "~:typography", + "~:value": { + "~:font-size": "{Large}", + "~:font-family": ["{42dotSans}"], + "~:font-weight": "{Bold}", + "~:text-decoration": "{striked}", + "~:text-case": "{uppercase}" + }, + "~:description": "", + "~:modified-at": "~m1755800252452" + } + } + ], + [ + "Bold", + { + "~#penpot/token": { + "~:id": "~u97915939-ccca-808f-8006-aeb2d58dc828", + "~:name": "Bold", + "~:type": "~:font-weight", + "~:value": "Bold", + "~:description": "", + "~:modified-at": "~m1755798237751" + } + } + ], + [ + "Large", + { + "~#penpot/token": { + "~:id": "~u97915939-ccca-808f-8006-aeb2dfd57494", + "~:name": "Large", + "~:type": "~:font-size", + "~:value": "40", + "~:description": "", + "~:modified-at": "~m1755798248277" + } + } + ], + [ + "Small", + { + "~#penpot/token": { + "~:id": "~u97915939-ccca-808f-8006-aeb2ebc1d2e7", + "~:name": "Small", + "~:type": "~:font-size", + "~:value": "{Large} / 2", + "~:description": "", + "~:modified-at": "~m1755798260488" + } + } + ], + [ + "uppercase", + { + "~#penpot/token": { + "~:id": "~u97915939-ccca-808f-8006-aeb2f759c415", + "~:name": "uppercase", + "~:type": "~:text-case", + "~:value": "uppercase", + "~:description": "", + "~:modified-at": "~m1755798272360" + } + } + ], + [ + "striked", + { + "~#penpot/token": { + "~:id": "~u97915939-ccca-808f-8006-aeb305c7a666", + "~:name": "striked", + "~:type": "~:text-decoration", + "~:value": "strike-through", + "~:description": "", + "~:modified-at": "~m1755798287134" + } + } + ], + [ + "wide", + { + "~#penpot/token": { + "~:id": "~u97915939-ccca-808f-8006-aeb39796c280", + "~:name": "wide", + "~:type": "~:letter-spacing", + "~:value": "20", + "~:description": "", + "~:modified-at": "~m1755798436443" + } + } + ], + [ + "none", + { + "~#penpot/token": { + "~:id": "~u97915939-ccca-808f-8006-aeb857f7ff94", + "~:name": "none", + "~:type": "~:text-decoration", + "~:value": "none", + "~:description": "", + "~:modified-at": "~m1755799682015" + } + } + ] + ] + } + } + } + ], + [ + "S-Other", + { + "~#penpot/token-set": { + "~:id": "~u97915939-ccca-808f-8006-aeb3c391e0f4", + "~:name": "Other", + "~:description": "", + "~:modified-at": "~m1755798626309", + "~:tokens": { + "~#ordered-map": [ + [ + "red", + { + "~#penpot/token": { + "~:id": "~u97915939-ccca-808f-8006-aeb3f86a2d7e", + "~:name": "red", + "~:type": "~:color", + "~:value": "#AA0022", + "~:description": "", + "~:modified-at": "~m1755798535592" + } + } + ], + [ + "rounded", + { + "~#penpot/token": { + "~:id": "~u97915939-ccca-808f-8006-aeb412ca40b0", + "~:name": "rounded", + "~:type": "~:border-radius", + "~:value": "20px", + "~:description": "", + "~:modified-at": "~m1755798562602" + } + } + ], + [ + "dimensions", + { + "~#penpot/token": { + "~:id": "~u97915939-ccca-808f-8006-aeb41bd0336c", + "~:name": "dimensions", + "~:type": "~:dimensions", + "~:value": "20", + "~:description": "", + "~:modified-at": "~m1755798571840" + } + } + ], + [ + "number", + { + "~#penpot/token": { + "~:id": "~u97915939-ccca-808f-8006-aeb4244ec6e6", + "~:name": "number", + "~:type": "~:number", + "~:value": "30", + "~:description": "", + "~:modified-at": "~m1755798580539" + } + } + ], + [ + "transparent", + { + "~#penpot/token": { + "~:id": "~u97915939-ccca-808f-8006-aeb4300eff0d", + "~:name": "transparent", + "~:type": "~:opacity", + "~:value": "0.5", + "~:description": "", + "~:modified-at": "~m1755798592571" + } + } + ], + [ + "45deg", + { + "~#penpot/token": { + "~:id": "~u97915939-ccca-808f-8006-aeb43d30e201", + "~:name": "45deg", + "~:type": "~:rotation", + "~:value": "45", + "~:description": "", + "~:modified-at": "~m1755798606019" + } + } + ], + [ + "spacing", + { + "~#penpot/token": { + "~:id": "~u97915939-ccca-808f-8006-aeb44783b70f", + "~:name": "spacing", + "~:type": "~:spacing", + "~:value": "20", + "~:description": "", + "~:modified-at": "~m1755798616590" + } + } + ], + [ + "sizing", + { + "~#penpot/token": { + "~:id": "~u97915939-ccca-808f-8006-aeb44e0e6903", + "~:name": "sizing", + "~:type": "~:sizing", + "~:value": "20", + "~:description": "", + "~:modified-at": "~m1755798623289" + } + } + ] + ] + } + } + } + ] + ] + }, + "~:themes": { + "~#ordered-map": [ + [ + "", + { + "~#ordered-map": [ + [ + "__PENPOT__HIDDEN__TOKEN__THEME__", + { + "~#penpot/token-theme": { + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:name": "__PENPOT__HIDDEN__TOKEN__THEME__", + "~:group": "", + "~:description": "", + "~:is-source": false, + "~:external-id": "", + "~:modified-at": "~m1755798544595", + "~:sets": { + "~#set": ["Typography", "Other"] + } + } + } + ] + ] + } + ] + ] + }, + "~:active-themes": { + "~#set": ["/__PENPOT__HIDDEN__TOKEN__THEME__"] + } + } + } + } +} diff --git a/frontend/playwright/ui/specs/tokens.spec.js b/frontend/playwright/ui/specs/tokens.spec.js index a5b24ff3c2..b86d6a379b 100644 --- a/frontend/playwright/ui/specs/tokens.spec.js +++ b/frontend/playwright/ui/specs/tokens.spec.js @@ -35,18 +35,25 @@ const setupEmptyTokensFile = async (page) => { }; }; -const setupTokensFile = async (page) => { +const setupTokensFile = async (page, options = {}) => { + const { + file = "workspace/get-file-tokens.json", + fileFragment = "workspace/get-file-fragment-tokens.json", + flags = [], + } = options; + const workspacePage = new WorkspacePage(page); + if (flags.length) { + await workspacePage.mockConfigFlags(flags); + } + await workspacePage.setupEmptyFile(); await workspacePage.mockRPC( "get-team?id=*", "workspace/get-team-tokens.json", ); - await workspacePage.mockRPC(/get\-file\?/, "workspace/get-file-tokens.json"); - await workspacePage.mockRPC( - /get\-file\-fragment\?/, - "workspace/get-file-fragment-tokens.json", - ); + await workspacePage.mockRPC(/get\-file\?/, file); + await workspacePage.mockRPC(/get\-file\-fragment\?/, fileFragment); await workspacePage.mockRPC( "update-file?id=*", "workspace/update-file-create-rect.json", @@ -73,6 +80,15 @@ const setupTokensFile = async (page) => { }; }; +const setupTypographyTokensFile = async (page, options = {}) => { + return setupTokensFile(page, { + file: "workspace/get-file-typography-tokens.json", + fileFragment: "workspace/get-file-fragment-typography-tokens.json", + flags: ["enable-token-typography-types"], + ...options, + }); +}; + test.describe("Tokens: Tokens Tab", () => { test("Clicking tokens tab button opens tokens sidebar tab", async ({ page, @@ -531,9 +547,7 @@ test.describe("Tokens: Sets Tab", () => { const assertEmptySetsList = async (el) => { const buttons = await el.getByRole("button").allTextContents(); - const filteredButtons = buttons.filter( - (text) => text === "Create one.", - ); + const filteredButtons = buttons.filter((text) => text === "Create one."); await expect(filteredButtons.length).toEqual(2); // We assume there are no themes, so we have two "Create one" buttons. }; @@ -860,5 +874,74 @@ test.describe("Tokens: Themes modal", () => { }); await expect(inputColor).toHaveValue("000000"); }); + + test("User applies typography token to a text shape", async ({ page }) => { + const { workspacePage, tokensSidebar, tokenContextMenuForToken } = + await setupTypographyTokensFile(page); + + await page.getByRole("tab", { name: "Layers" }).click(); + + await workspacePage.layers + .getByTestId("layer-row") + .filter({ hasText: "Some Text" }) + .click(); + + const tokensTabButton = page.getByRole("tab", { name: "Tokens" }); + await tokensTabButton.click(); + + await tokensSidebar + .getByRole("button") + .filter({ hasText: "Typography" }) + .click(); + + await tokensSidebar.getByRole("button", { name: "Full" }).click(); + + const fontSizeInput = workspacePage.rightSidebar.getByRole("textbox", { + name: "Font Size", + }); + await expect(fontSizeInput).toBeVisible(); + await expect(fontSizeInput).toHaveValue("100"); + }); + + test("User edits typography token and all fields are valid", async ({ + page, + }) => { + const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } = + await setupTypographyTokensFile(page); + + await tokensSidebar + .getByRole("button") + .filter({ hasText: "Typography" }) + .click(); + + // Open edit modal for "Full" typography token + const token = tokensSidebar.getByRole("button", { name: "Full" }); + await token.click({ button: "right" }); + await page.getByText("Edit token").click(); + + // Modal opens + await expect(tokensUpdateCreateModal).toBeVisible(); + + const saveButton = tokensUpdateCreateModal.getByRole("button", { + name: /save/i, + }); + + // Invalidate incorrect values for font size + const fontSizeField = tokensUpdateCreateModal.getByLabel(/Font Size/i); + await fontSizeField.fill("invalid"); + await expect( + tokensUpdateCreateModal.getByText(/Invalid token value:/), + ).toBeVisible(); + await expect(saveButton).toBeDisabled(); + + // Allow empty fields + await fontSizeField.fill(""); + await expect(saveButton).toBeEnabled(); + + await saveButton.click(); + + // Modal should close, token should be visible (with new name) in sidebar + await expect(tokensUpdateCreateModal).not.toBeVisible(); + }); }); }); diff --git a/frontend/src/app/main/data/style_dictionary.cljs b/frontend/src/app/main/data/style_dictionary.cljs index 0d9e465592..b341d35a96 100644 --- a/frontend/src/app/main/data/style_dictionary.cljs +++ b/frontend/src/app/main/data/style_dictionary.cljs @@ -31,14 +31,21 @@ Setup transforms from tokens-studio used to parse and resolved token values." (do (sd-transforms/register sd) + (.registerTransformGroup sd #js {:name "penpot" + :transforms + ;; Rebuild sd-transforms without "ts/typography/compose/shorthand" (we need to keep a typography map) + (.concat (sd-transforms/getTransforms) + #js ["ts/color/css/hexrgba" + "ts/color/modifiers" + "color/css"])}) (.registerFormat sd #js {:name "custom/json" :format (fn [^js res] - (.-tokens (.-dictionary res)))}) + (.. res -dictionary -tokens))}) sd)) (def default-config {:platforms {:json - {:transformGroup "tokens-studio" + {:transformGroup "penpot" ;; Required: The StyleDictionary API is focused on files even when working in the browser :files [{:format "custom/json" :destination "penpot"}]}} :preprocessors ["tokens-studio"] @@ -64,7 +71,6 @@ (and (string? s) (re-matches #"^-?\d+(\.\d+)?(px|rem)$" s))) -;; TODO: After mergin "dimension-tokens" revisit this function to check if it's still (defn- parse-sd-token-number-value "Parses `value` of a number `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`." @@ -113,9 +119,9 @@ "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`. If the `value` is parseable but is out of range returns a map with `warnings`." - [value has-references?] - - (let [parsed-value (cft/parse-token-value value) + [value] + (let [missing-references? (seq (ctob/find-token-value-references value)) + parsed-value (cft/parse-token-value value) out-of-scope (not (<= 0 (:value parsed-value) 1)) references (seq (ctob/find-token-value-references value))] (cond (and parsed-value (not out-of-scope)) @@ -125,10 +131,10 @@ {:errors [(wte/error-with-value :error.style-dictionary/missing-reference references)] :references references} - (and (not has-references?) out-of-scope) + (and (not missing-references?) out-of-scope) {:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value-opacity value)]} - (and has-references? out-of-scope parsed-value) + (and missing-references? out-of-scope parsed-value) (assoc parsed-value :warnings [(wtw/warning-with-value :warning.style-dictionary/invalid-referenced-token-value-opacity value)]) :else {:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value value)]}))) @@ -137,8 +143,9 @@ "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`. If the `value` is parseable but is out of range returns a map with `warnings`." - [value has-references?] - (let [parsed-value (cft/parse-token-value value) + [value] + (let [missing-references? (seq (ctob/find-token-value-references value)) + parsed-value (cft/parse-token-value value) out-of-scope (< (:value parsed-value) 0) references (seq (ctob/find-token-value-references value))] (cond @@ -149,10 +156,10 @@ {:errors [(wte/error-with-value :error.style-dictionary/missing-reference references)] :references references} - (and (not has-references?) out-of-scope) + (and (not missing-references?) out-of-scope) {:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value-stroke-width value)]} - (and has-references? out-of-scope parsed-value) + (and missing-references? out-of-scope parsed-value) (assoc parsed-value :warnings [(wtw/warning-with-value :warning.style-dictionary/invalid-referenced-token-value-stroke-width value)]) :else {:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value value)]}))) @@ -218,6 +225,55 @@ :else {:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value-font-weight value)]}))) +(defn- parse-sd-token-font-family-value + [value] + (let [missing-references (seq (some ctob/find-token-value-references value))] + (cond + missing-references + {:errors [(wte/error-with-value :error.style-dictionary/missing-reference missing-references)] + :references missing-references} + + :else + {:value (-> (js->clj value) (flatten))}))) + +(defn parse-atomic-typography-value [token-type token-value] + (case token-type + :font-size (parse-sd-token-general-value token-value) + :font-family (parse-sd-token-font-family-value token-value) + :font-weight (parse-sd-token-font-weight-value token-value) + :letter-spacing (parse-sd-token-letter-spacing-value token-value) + :text-case (parse-sd-token-text-case-value token-value) + :text-decoration (parse-sd-token-text-decoration-value token-value) + nil)) + +(defn- parse-composite-typography-value + [value] + (let [missing-references + (when (string? value) + (seq (ctob/find-token-value-references value)))] + (cond + missing-references + {:errors [(wte/error-with-value :error.style-dictionary/missing-reference missing-references)] + :references missing-references} + + (string? value) + {:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value-typography value)]} + + :else + (let [converted (js->clj value :keywordize-keys true) + valid-typography (reduce + (fn [acc [k v]] + (let [{:keys [errors value]} (parse-atomic-typography-value k v)] + (if (seq errors) + (update acc :errors concat (map #(assoc % :typography-key k) errors)) + (assoc-in acc [:value k] (or value v))))) + {:value {}} + converted)] + valid-typography)))) + +(defn collect-typography-errors [token] + (group-by :typography-key (:errors token))) + (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 @@ -254,18 +310,15 @@ (fn [acc ^js sd-token] (let [origin-token (get-origin-token sd-token) value (.-value sd-token) - has-references? (str/includes? (:value origin-token) "{") - parsed-token-value (case (:type origin-token) - :font-family {:value (-> (js->clj value) (flatten))} - :color (parse-sd-token-color-value value) - :opacity (parse-sd-token-opacity-value value has-references?) - :stroke-width (parse-sd-token-stroke-width-value value has-references?) - :text-case (parse-sd-token-text-case-value value) - :letter-spacing (parse-sd-token-letter-spacing-value value) - :text-decoration (parse-sd-token-text-decoration-value value) - :font-weight (parse-sd-token-font-weight-value value) - :number (parse-sd-token-number-value value) - (parse-sd-token-general-value value)) + parsed-token-value (or + (parse-atomic-typography-value (:type origin-token) value) + (case (:type origin-token) + :typography (parse-composite-typography-value value) + :color (parse-sd-token-color-value value) + :opacity (parse-sd-token-opacity-value value) + :stroke-width (parse-sd-token-stroke-width-value value) + :number (parse-sd-token-number-value value) + (parse-sd-token-general-value value))) output-token (cond (:errors parsed-token-value) (merge origin-token parsed-token-value) diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index b21ef41576..122bdc1036 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -369,6 +369,33 @@ :timeout 7000}))] (generate-font-weight-text-shape-update font-variant shape-ids page-id on-mismatch))))) +(defn- apply-functions-map + "Apply map of functions `fs` to a map of values `vs` using `args`. + The keys for both must match to be applied with an non-nil value in `vs`. + Returns a vector of the resulting values. + + E.g.: `(apply-functions {:a + :b -} {:a 1 :b nil :c 10} [1 1]) => [3]`" + [fs vs args] + (map (fn [[k f]] + (when-let [v (get vs k)] + (apply f v args))) + fs)) + +(defn update-typography + ([value shape-ids attributes] (update-typography value shape-ids attributes nil)) + ([value shape-ids attributes page-id] + (when (map? value) + (rx/merge + (apply-functions-map + {:font-size update-font-size + :font-family update-font-family + :font-weight update-font-weight + :letter-spacing update-letter-spacing + :text-case update-text-case + :text-decoration update-text-decoration} + value + [shape-ids attributes page-id]))))) + ;; Events to apply / unapply tokens to shapes ------------------------------------------------------------ (defn apply-token @@ -383,42 +410,53 @@ (watch [_ state _] ;; We do not allow to apply tokens while text editor is open. (when (empty? (get state :workspace-editor-state)) - (when-let [tokens (some-> (dsh/lookup-file-data state) - (get :tokens-lib) - (ctob/get-tokens-in-active-sets))] - (->> (sd/resolve-tokens tokens) - (rx/mapcat - (fn [resolved-tokens] - (let [undo-id (js/Symbol) - objects (dsh/lookup-page-objects state) - selected-shapes (select-keys objects shape-ids) + (let [attributes-to-remove + ;; Remove atomic typography tokens when applying composite and vice-verca + (cond + (ctt/typography-token-keys (:type token)) (set/union attributes-to-remove ctt/typography-keys) + (ctt/typography-keys (:type token)) (set/union attributes-to-remove ctt/typography-token-keys) + :else attributes-to-remove)] + (when-let [tokens (some-> (dsh/lookup-file-data state) + (get :tokens-lib) + (ctob/get-tokens-in-active-sets))] + (->> (sd/resolve-tokens tokens) + (rx/mapcat + (fn [resolved-tokens] + (let [undo-id (js/Symbol) + objects (dsh/lookup-page-objects state) + selected-shapes (select-keys objects shape-ids) - shape-ids (or (->> selected-shapes - (filter (fn [[_ shape]] - (or - (and (ctsl/any-layout-immediate-child? objects shape) - (some ctt/spacing-margin-keys attributes)) - (ctt/any-appliable-attr? attributes (:type shape))))) - (keys)) - []) + shape-ids (or (->> selected-shapes + (filter (fn [[_ shape]] + (or + (and (ctsl/any-layout-immediate-child? objects shape) + (some ctt/spacing-margin-keys attributes)) + (ctt/any-appliable-attr? attributes (:type shape))))) + (keys)) + []) - resolved-value (get-in resolved-tokens [(cft/token-identifier token) :resolved-value]) - tokenized-attributes (cft/attributes-map attributes token) - type (:type token)] - (rx/of - (st/emit! (ev/event {::ev/name "apply-tokens" - :type type - :applyed-to attributes})) - (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))))))))))) + resolved-value (get-in resolved-tokens [(cft/token-identifier token) :resolved-value]) + tokenized-attributes (cft/attributes-map attributes token) + type (:type token)] + (rx/concat + (rx/of + (st/emit! (ev/event {::ev/name "apply-tokens" + :type type + :applyed-to attributes})) + (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 + (let [res (on-update-shape resolved-value shape-ids attributes)] + ;; Composed updates return observables and need to be executed differently + (if (rx/observable? res) + res + (rx/of res)))) + (rx/of (dwu/commit-undo-transaction undo-id))))))))))))) (defn apply-spacing-token "Handles edge-case for spacing token when applying token via toggle button. @@ -554,6 +592,14 @@ :fields [{:label "Font Weight" :key :font-weight}]}} + :typography + {:title "Typography" + :attributes ctt/typography-token-keys + :on-update-shape update-typography + :modal {:key :tokens/typography + :fields [{:label "Typography" + :key :typography}]}} + :text-decoration {:title "Text Decoration" :attributes ctt/text-decoration-keys diff --git a/frontend/src/app/main/data/workspace/tokens/propagation.cljs b/frontend/src/app/main/data/workspace/tokens/propagation.cljs index 21f256288f..a30cd5ca4f 100644 --- a/frontend/src/app/main/data/workspace/tokens/propagation.cljs +++ b/frontend/src/app/main/data/workspace/tokens/propagation.cljs @@ -39,6 +39,7 @@ #{:text-case} dwta/update-text-case #{:text-decoration} dwta/update-text-decoration #{:font-weight} dwta/update-font-weight + #{:typography} dwta/update-typography #{:x :y} dwta/update-shape-position #{:p1 :p2 :p3 :p4} dwta/update-layout-padding #{:m1 :m2 :m3 :m4} dwta/update-layout-item-margin @@ -161,7 +162,10 @@ (collect-shapes-update-info resolved-tokens (:objects page)) actions - (actionize-shapes-update-info page-id attrs)] + (actionize-shapes-update-info page-id attrs) + + ;; Composed updates return observables and need to be executed differently + {:keys [observable normal]} (group-by #(if (rx/observable? %) :observable :normal) actions)] (l/inf :status "PROGRESS" :hint "propagate-tokens" @@ -170,7 +174,9 @@ ::l/sync? true) (rx/merge - (rx/from actions) + (when (seq observable) (apply rx/merge observable)) + (when (seq normal) (rx/concat-all (rx/of normal))) + (->> (rx/from frame-ids) (rx/mapcat (fn [frame-id] (rx/of (dwt/clear-thumbnail file-id page-id frame-id "frame") diff --git a/frontend/src/app/main/ui/workspace/tokens/management/create/form.cljs b/frontend/src/app/main/ui/workspace/tokens/management/create/form.cljs index 4f02201f0a..bfef1b54a8 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/create/form.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/create/form.cljs @@ -21,7 +21,6 @@ [app.main.data.workspace.tokens.errors :as wte] [app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.data.workspace.tokens.propagation :as dwtp] - [app.main.data.workspace.tokens.warnings :as wtw] [app.main.fonts :as fonts] [app.main.refs :as refs] [app.main.store :as st] @@ -36,11 +35,13 @@ [app.main.ui.workspace.colorpicker.ramp :refer [ramp-selector*]] [app.main.ui.workspace.sidebar.options.menus.typography :refer [font-selector*]] [app.main.ui.workspace.tokens.management.create.input-token-color-bullet :refer [input-token-color-bullet*]] - [app.main.ui.workspace.tokens.management.create.input-tokens-value :refer [input-tokens-value*]] + [app.main.ui.workspace.tokens.management.create.input-tokens-value :refer [input-tokens-value* + token-value-hint*]] [app.util.dom :as dom] [app.util.functions :as uf] [app.util.i18n :refer [tr]] [app.util.keyboard :as k] + [app.util.object :as obj] [beicon.v2.core :as rx] [cuerdas.core :as str] [malli.core :as m] @@ -101,53 +102,127 @@ ;; Validation ------------------------------------------------------------------ -(defn validate-empty-input [value] - (if (sequential? value) - (empty? value) - (empty? (str/trim value)))) +(defn invalidate-empty-value [token-value] + (when (empty? (str/trim token-value)) + (wte/get-error-code :error.token/empty-input))) -(defn validate-self-reference? [token-name value] - (if (sequential? value) - (some #(ctob/token-value-self-reference? token-name %) value) - (ctob/token-value-self-reference? token-name value))) +(defn invalidate-token-empty-value [token] + (invalidate-empty-value (:value token))) -(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 - (validate-empty-input value) - (rx/throw {:errors [(wte/get-error-code :error.token/empty-input)]}) +(defn invalidate-self-reference [token-name token-value] + (when (ctob/token-value-self-reference? token-name token-value) + (wte/get-error-code :error.token/direct-self-reference))) - (validate-self-reference? token-name value) - (rx/throw {:errors [(wte/get-error-code :error.token/direct-self-reference)]}) +(defn invalidate-token-self-reference [token] + (invalidate-self-reference (:name token) (:value token))) - :else - (let [tokens' (cond-> tokens - ;; Remove previous token when renaming a token - (not= name-value (:name token)) (dissoc (:name token)) - :always (update token-name #(ctob/make-token (merge % {:value (cond - (= (:type token) :font-family) (ctt/split-font-family value) - :else value) - :name token-name - :type (:type token)}))))] - (->> tokens' - (sd/resolve-tokens-interactive) - (rx/mapcat - (fn [resolved-tokens] - (let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens token-name)] - (cond - resolved-value (rx/of resolved-token) - :else (rx/throw {:errors (or errors (wte/get-error-code :error/unknown-error))})))))))))) +(defn validate-resolve-token + [token prev-token tokens] + (let [token (cond-> token + ;; When creating a new token we dont have a name yet, + ;; but we still want to resolve the value to show in the form. + ;; So we use a temporary token name that hopefully doesn't clash with any of the users token names + (str/empty? (:name token)) (assoc :name "__PENPOT__TOKEN__NAME__PLACEHOLDER__")) + tokens' (cond-> tokens + ;; Remove previous token when renaming a token + (not= (:name token) (:name prev-token)) + (dissoc (:name prev-token)) + + :always + (update (:name token) #(ctob/make-token (merge % prev-token token))))] + (->> tokens' + (sd/resolve-tokens-interactive) + (rx/mapcat + (fn [resolved-tokens] + (let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))] + (cond + resolved-value (rx/of resolved-token) + :else (rx/throw {:errors (or (seq errors) + [(wte/get-error-code :error/unknown-error)])})))))))) + +(defn validate-token-with [token validators] + (if-let [error (some (fn [validate] (validate token)) validators)] + (rx/throw {:errors [error]}) + (rx/of token))) + +(def default-validators + [invalidate-token-empty-value invalidate-token-self-reference]) + +(defn default-validate-token + "Validates a token by confirming a list of `validator` predicates and resolving the token using `tokens` with StyleDictionary. + Returns rx stream of either a valid resolved token or an errors map. + + Props: + token-name, token-value, token-description: Values from the form inputs + prev-token: The existing token currently being edited + tokens: tokens map keyed by token-name + Used to look up the editing token & resolving step. + + validators: A list of predicates that will be used to do simple validation on the unresolved token map. + The validators get the token map as input and should either return: + - An errors map .e.g: {:errors []} + - nil (valid token predicate) + Mostly used to do simple checks like invalidating empy token `:name`. + Will default to `default-validators`." + [{:keys [token-name token-value token-description prev-token tokens validators] + :or {validators default-validators}}] + (let [token (-> {:name token-name + :value token-value + :description token-description} + (d/without-nils))] + (->> (rx/of token) + ;; Simple validation of the editing token + (rx/mapcat #(validate-token-with % validators)) + ;; Resolving token via StyleDictionary + (rx/mapcat #(validate-resolve-token % prev-token tokens))))) + +(defn invalidate-coll-self-reference + "Invalidate a collection of `token-vals` for a self-refernce against `token-name`.," + [token-name token-vals] + (when (some #(ctob/token-value-self-reference? token-name %) token-vals) + (wte/get-error-code :error.token/direct-self-reference))) + +(defn invalidate-font-family-token-self-reference [token] + (invalidate-coll-self-reference (:name token) (:value token))) + +(defn validate-font-family-token + [props] + (-> props + (update :token-value ctt/split-font-family) + (assoc :validators [(fn [token] + (when (empty? (:value token)) + (wte/get-error-code :error.token/empty-input))) + invalidate-font-family-token-self-reference]) + (default-validate-token))) + +(defn invalidate-typography-token-self-reference + "Invalidate token when any of the attributes in token value have a self refernce." + [token] + (let [token-name (:name token) + token-values (:value token)] + (some (fn [[k v]] + (when-let [err (case k + :font-family (invalidate-coll-self-reference token-name v) + (invalidate-self-reference token-name v))] + (assoc err :typography-key k))) + token-values))) + +(defn validate-typography-token + [props] + (-> props + (update :token-value + (fn [v] + (-> (or v {}) + (d/update-when :font-family #(if (string? %) (ctt/split-font-family %) %))))) + (assoc :validators [invalidate-typography-token-self-reference]) + (default-validate-token))) (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}}] + This function is debounced as the resolving might be an expensive calculation. + Uses a custom debouncing logic, as the resolve function is async." + [{:keys [timeout name-ref token tokens callback validate-token] + :or {timeout 160}}] (let [timeout-id-ref (mf/use-ref nil) debounced-resolver-callback (mf/use-fn @@ -160,14 +235,12 @@ (js/setTimeout (fn [] (when (not (timeout-outdated-cb?)) - (->> (validate-token-value {:value value - :name-value @name-ref - :token token - :tokens tokens}) + (->> (validate-token {:token-value value + :token-name @name-ref + :prev-token token + :tokens tokens}) (rx/filter #(not (timeout-outdated-cb?))) - (rx/subs! - callback - callback)))) + (rx/subs! callback callback)))) timeout))))] debounced-resolver-callback)) @@ -175,30 +248,35 @@ ;; Component ------------------------------------------------------------------- -(mf/defc token-value-hint - [{:keys [result]}] - (let [{:keys [errors warnings resolved-value]} result - empty-message? (nil? result) - - message (cond - empty-message? (tr "workspace.tokens.resolved-value" "-") - warnings (wtw/humanize-warnings warnings) - errors (->> (wte/humanize-errors errors) - (str/join "\n")) - :else (tr "workspace.tokens.resolved-value" (or resolved-value result))) - type (cond - empty-message? "hint" - errors "error" - warnings "warning" - :else "hint")] - [:> hint-message* - {:id "token-value-hint" - :message message - :class (stl/css-case :resolved-value (not (or empty-message? (seq warnings) (seq errors)))) - :type type}])) - (mf/defc form* - [{:keys [token token-type action selected-token-set-name transform-value on-value-resolve custom-input-token-value custom-input-token-value-props input-value-placeholder]}] + "Form component to edit or create a token of any token type. + +Callback props: +validate-token: Function to validate and resolve an editing token, see `default-validate-token`. +on-value-resolve: Will be called when a token value is resolved + Used to sync external state (like color picker) +on-get-token-value: Custom function to get the input value from the dom + (As there might be multiple inputs passed for `custom-input-token-value`) + Can also be used to manipulate the value (E.g.: Auto-prepending # for hex colors) + +Custom component props: +custom-input-token-value: Custom component for editing/displaying the token value +custom-input-token-value-props: Custom props passed to the custom-input-token-value merged with the default props" + [{:keys [token + token-type + selected-token-set-name + action + input-value-placeholder + + ;; Callbacks + validate-token + on-value-resolve + on-get-token-value + + ;; Custom component props + custom-input-token-value + custom-input-token-value-props] + :or {validate-token default-validate-token}}] (let [create? (not (instance? ctob/Token token)) token (or token {:type token-type}) token-properties (dwta/get-token-properties token) @@ -213,7 +291,10 @@ ;; Style dictionary resolver needs font families to be an array of strings (= :font-family (or (:type token) token-type)) - (update-in [(:name token) :value] ctt/split-font-family)) + (update-in [(:name token) :value] ctt/split-font-family) + + (= :typography (or (:type token) token-type)) + (d/update-in-when [(:name token) :font-family :value] ctt/split-font-family)) resolved-tokens (sd/use-resolved-tokens active-theme-tokens {:cache-atom form-token-cache-atom :interactive? true}) @@ -288,37 +369,32 @@ (mf/use-fn (mf/deps on-value-resolve) (fn [token-or-err] - (let [error? (:errors token-or-err) - warnings? (:warnings token-or-err) - v (cond - error? - (do - (when on-value-resolve (on-value-resolve nil)) - token-or-err) + (when on-value-resolve + (cond + (:errors token-or-err) (on-value-resolve nil) + :else (on-value-resolve (:resolved-value token-or-err)))) + (reset! token-resolve-result* token-or-err))) - warnings? - (:warnings {:warnings token-or-err}) + on-update-value-debounced + (use-debonced-resolve-callback + {:name-ref token-name-ref + :token token + :tokens active-theme-tokens + :callback set-resolve-value + :validate-token validate-token}) - :else - (cond-> (:resolved-value token-or-err) - on-value-resolve on-value-resolve))] - (reset! token-resolve-result* v)))) - - on-update-value-debounced (use-debonced-resolve-callback token-name-ref token active-theme-tokens set-resolve-value) + ;; Callback to update the value state via on of the inputs :on-change on-update-value (mf/use-fn - (mf/deps on-update-value-debounced transform-value) + (mf/deps on-update-value-debounced on-get-token-value) (fn [e] - (let [value (dom/get-target-val e) - value' (if (fn? transform-value) - (transform-value value) - value)] - ;; Value got updated in transform, update the dom node - (when (not= value value') - (dom/set-value! (mf/ref-val value-input-ref) value')) + (let [value (if (fn? on-get-token-value) + (on-get-token-value e (mf/ref-val value-ref)) + (dom/get-target-val e))] (mf/set-ref-val! value-ref value) (on-update-value-debounced value)))) + ;; Calback to update the value state from the outside (e.g.: color picker) on-external-update-value (mf/use-fn (mf/deps on-update-value-debounced) @@ -328,10 +404,7 @@ (on-update-value-debounced next-value))) value-error? (seq (:errors token-resolve-result)) - - valid-value-field? (and - (not value-error?) - (valid-value? token-resolve-result)) + valid-value-field? (not value-error?) ;; Description description-ref (mf/use-var (:description token)) @@ -359,7 +432,7 @@ on-submit (mf/use-fn - (mf/deps validate-name validate-descripion token active-theme-tokens) + (mf/deps create? validate-name validate-descripion token active-theme-tokens validate-token) (fn [e] (dom/prevent-default e) ;; We have to re-validate the current form values before submitting @@ -370,11 +443,7 @@ valid-name? (try (not (:errors (validate-name final-name))) (catch js/Error _ nil)) - final-value (let [value (mf/ref-val value-ref) - font-family? (= :font-family (or (:type token) token-type))] - (if font-family? - (ctt/split-font-family value) - (finalize-value value))) + value (mf/ref-val value-ref) final-description @description-ref valid-description? (if final-description (try @@ -382,22 +451,23 @@ (catch js/Error _ nil)) true)] (when (and valid-name? valid-description?) - (->> (validate-token-value {:value final-value - :name-value final-name - :token token - :tokens active-theme-tokens}) + (->> (validate-token {:token-value value + :token-name final-name + :token-description final-description + :prev-token token + :tokens active-theme-tokens}) (rx/subs! - (fn [] + (fn [valid-token] (st/emit! - (if (ctob/token? token) - (dwtl/update-token (:id token) - {:name final-name - :value final-value - :description final-description}) - + (if create? (dwtl/create-token {:name final-name :type token-type - :value final-value + :value (:value valid-token) + :description final-description}) + + (dwtl/update-token (:id token) + {:name final-name + :value (:value valid-token) :description final-description})) (dwtp/propagate-workspace-tokens) (modal/hide))))))))) @@ -496,29 +566,25 @@ (let [placeholder (or input-value-placeholder (tr "workspace.tokens.token-value-enter")) label (tr "workspace.tokens.token-value") default-value (mf/ref-val value-ref) - ref value-input-ref - error (not (nil? (:errors token-resolve-result))) - on-blur on-update-value] + ref value-input-ref] (if (fn? custom-input-token-value) [:> custom-input-token-value {:placeholder placeholder :label label :default-value default-value :input-ref ref - :error error - :on-blur on-blur :on-update-value on-update-value :on-external-update-value on-external-update-value - :custom-input-token-value-props custom-input-token-value-props}] + :custom-input-token-value-props custom-input-token-value-props + :token-resolve-result token-resolve-result}] [:> input-tokens-value* {:placeholder placeholder :label label :default-value default-value :ref ref - :error error - :on-blur on-blur - :on-change on-update-value}])) - [:& token-value-hint {:result token-resolve-result}]] + :on-blur on-update-value + :on-change on-update-value + :token-resolve-result token-resolve-result}]))] [:div {:class (stl/css :input-row)} [:> input* {:label (tr "workspace.tokens.token-description") :placeholder (tr "workspace.tokens.token-description") @@ -606,7 +672,7 @@ :on-change on-change'}]])) (mf/defc color-picker* - [{:keys [placeholder label default-value input-ref error on-blur on-update-value on-external-update-value custom-input-token-value-props]}] + [{:keys [placeholder label default-value input-ref on-blur on-update-value on-external-update-value custom-input-token-value-props token-resolve-result]}] (let [{:keys [color on-display-colorpicker]} custom-input-token-value-props color-ramp-open* (mf/use-state false) color-ramp-open? (deref color-ramp-open*) @@ -658,14 +724,14 @@ :label label :default-value default-value :ref input-ref - :error error :on-blur on-blur :on-change on-update-value :slot-start swatch}] (when color-ramp-open? [:> ramp* {:color (some-> color (tinycolor/valid-color)) - :on-change on-change'}])])) + :on-change on-change'}]) + [:> token-value-hint* {:result token-resolve-result}]])) (mf/defc color-form* [{:keys [token on-display-colorpicker] :rest props}] @@ -684,15 +750,19 @@ {:color color :on-display-colorpicker on-display-colorpicker})) - transform-value + on-get-token-value (mf/use-fn - (fn [value] - (if (tinycolor/hex-without-hash-prefix? value) - (dm/str "#" value) - value)))] + (fn [e] + (let [value (dom/get-target-val e)] + (if (tinycolor/hex-without-hash-prefix? value) + (let [hex-value (dm/str "#" value)] + (dom/set-value! (dom/get-target e) hex-value) + hex-value) + value))))] + [:> form* (mf/spread-props props {:token token - :transform-value transform-value + :on-get-token-value on-get-token-value :on-value-resolve on-value-resolve :custom-input-token-value color-picker* :custom-input-token-value-props custom-input-token-value-props})])) @@ -713,7 +783,7 @@ :full-size true}]])) (mf/defc font-picker* - [{:keys [default-value input-ref error on-blur on-update-value on-external-update-value]}] + [{:keys [default-value input-ref on-blur on-update-value on-external-update-value token-resolve-result]}] (let [font* (mf/use-state (fonts/find-font-family default-value)) font (deref font*) set-font (mf/use-fn @@ -764,11 +834,11 @@ :label (tr "workspace.tokens.token-font-family-value") :default-value default-value :ref input-ref - :error error :on-blur on-blur :on-change on-update-value' :icon "text-font-family" - :slot-end font-selector-button}] + :slot-end font-selector-button + :token-resolve-result token-resolve-result}] (when font-selector-open? [:> font-selector-wrapper* {:font font :input-ref input-ref @@ -785,7 +855,8 @@ [:> form* (mf/spread-props props {:token (when token (update token :value ctt/join-font-family)) :custom-input-token-value font-picker* - :on-value-resolve on-value-resolve})])) + :on-value-resolve on-value-resolve + :validate-token validate-font-family-token})])) (mf/defc text-case-form* [{:keys [token] :rest props}] @@ -805,11 +876,102 @@ (mf/spread-props props {:token token :input-value-placeholder (tr "workspace.tokens.font-weight-value-enter")})]) +(def ^:private typography-inputs + #(d/ordered-map + :font-size + {:label "Font Size" + :placeholder (tr "workspace.tokens.token-value-enter")} + :font-family + {:label (tr "workspace.tokens.token-font-family-value") + :placeholder (tr "workspace.tokens.token-font-family-value-enter")} + :font-weight + {:label "Font Weight" + :placeholder (tr "workspace.tokens.font-weight-value-enter")} + :letter-spacing + {:label "Letter Spacing" + :placeholder (tr "workspace.tokens.token-value-enter")} + :text-case + {:label "Text Case" + :placeholder (tr "workspace.tokens.text-case-value-enter")} + :text-decoration + {:label "Text Decoration" + :placeholder (tr "workspace.tokens.text-decoration-value-enter")})) + +(mf/defc typography-inputs* + [{:keys [default-value on-blur on-update-value token-resolve-result]}] + (let [typography-inputs (mf/use-memo typography-inputs) + errors-by-key (sd/collect-typography-errors token-resolve-result)] + [:div {:class (stl/css :nested-input-row)} + (for [[k {:keys [label placeholder]}] typography-inputs] + (let [value (get default-value k) + token-resolve-result + (-> {:resolved-value (let [v (get-in token-resolve-result [:resolved-value k])] + (when-not (str/empty? v) v)) + :errors (get errors-by-key k)} + (d/without-nils)) + + input-ref (mf/use-ref) + + on-external-update-value + (mf/use-fn + (mf/deps on-update-value) + (fn [next-value] + (let [el (mf/ref-val input-ref)] + (dom/set-value! el next-value) + (on-update-value #js {:target el + :tokenType :font-family})))) + + on-change + (mf/use-fn + ;; Passing token-type via event to prevent deep function adapting & passing of type + (fn [e] + (-> (obj/set! e "tokenType" k) + (on-update-value))))] + + [:div {:class (stl/css :input-row)} + (case k + :font-family + [:> font-picker* + {:key (str k) + :label label + :placeholder placeholder + :input-ref input-ref + :default-value (when value (ctt/join-font-family value)) + :on-blur on-blur + :on-update-value on-change + :on-external-update-value on-external-update-value + :token-resolve-result (when (seq token-resolve-result) token-resolve-result)}] + [:> input-tokens-value* + {:key (str k) + :label label + :placeholder placeholder + :default-value value + :on-blur on-blur + :on-change on-change + :token-resolve-result (when (seq token-resolve-result) token-resolve-result)}])]))])) + +(mf/defc typography-form* + [{:keys [token] :rest props}] + (let [on-get-token-value + (mf/use-callback + (fn [e prev-value] + (let [token-type (obj/get e "tokenType") + input-value (dom/get-target-val e)] + (if (empty? input-value) + (dissoc prev-value token-type) + (assoc prev-value token-type input-value)))))] + [:> form* + (mf/spread-props props {:token token + :custom-input-token-value typography-inputs* + :validate-token validate-typography-token + :on-get-token-value on-get-token-value})])) + (mf/defc form-wrapper* [{:keys [token token-type] :as props}] (let [token-type' (or (:type token) token-type)] (case token-type' :color [:> color-form* props] + :typography [:> typography-form* props] :font-family [:> font-family-form* props] :text-case [:> text-case-form* props] :text-decoration [:> text-decoration-form* props] diff --git a/frontend/src/app/main/ui/workspace/tokens/management/create/form.scss b/frontend/src/app/main/ui/workspace/tokens/management/create/form.scss index 12e40529b7..25d0d70198 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/create/form.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/create/form.scss @@ -39,6 +39,12 @@ gap: $s-4; } +.nested-input-row { + display: flex; + flex-direction: column; + gap: $s-12; +} + .warning-name-change-notification-wrapper { margin-block-start: $s-16; } diff --git a/frontend/src/app/main/ui/workspace/tokens/management/create/input_tokens_value.cljs b/frontend/src/app/main/ui/workspace/tokens/management/create/input_tokens_value.cljs index 0f53e2bf12..def747e6f8 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/create/input_tokens_value.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/create/input_tokens_value.cljs @@ -8,9 +8,14 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data.macros :as dm] + [app.main.data.workspace.tokens.errors :as wte] + [app.main.data.workspace.tokens.warnings :as wtw] + [app.main.ui.ds.controls.utilities.hint-message :refer [hint-message*]] [app.main.ui.ds.controls.utilities.input-field :refer [input-field*]] [app.main.ui.ds.controls.utilities.label :refer [label*]] [app.main.ui.ds.foundations.assets.icon :refer [icon-list]] + [app.util.i18n :refer [tr]] + [cuerdas.core :as str] [rumext.v2 :as mf])) (def ^:private schema::input-tokens-value @@ -22,14 +27,39 @@ [:error {:optional true} :boolean] [:slot-start {:optional true} [:maybe some?]] [:icon {:optional true} - [:maybe [:and :string [:fn #(contains? icon-list %)]]]]]) + [:maybe [:and :string [:fn #(contains? icon-list %)]]]] + [:token-resolve-result {:optional true} :any]]) + +(mf/defc token-value-hint* + [{:keys [result]}] + (let [{:keys [errors warnings resolved-value]} result + empty-message? (nil? result) + + message (cond + empty-message? (tr "workspace.tokens.resolved-value" "-") + warnings (->> (wtw/humanize-warnings warnings) + (str/join "\n")) + errors (->> (wte/humanize-errors errors) + (str/join "\n")) + :else (tr "workspace.tokens.resolved-value" (or resolved-value result))) + type (cond + empty-message? "hint" + errors "error" + warnings "warning" + :else "hint")] + [:> hint-message* + {:id "token-value-hint" + :message message + :class (stl/css-case :resolved-value (not (or empty-message? (seq warnings) (seq errors)))) + :type type}])) (mf/defc input-tokens-value* {::mf/props :obj ::mf/forward-ref true ::mf/schema schema::input-tokens-value} - [{:keys [class label placeholder error value icon slot-start] :rest props} ref] - (let [id (mf/use-id) + [{:keys [class label placeholder value icon slot-start token-resolve-result] :rest props} ref] + (let [error (not (nil? (:errors token-resolve-result))) + id (mf/use-id) input-ref (mf/use-ref) props (mf/spread-props props {:id id :type "text" @@ -41,7 +71,10 @@ :slot-start slot-start :icon icon :ref (or ref input-ref)})] - [:div {:class (dm/str class " " (stl/css-case :wrapper true - :input-error error))} - [:> label* {:for id} label] - [:> input-field* props]])) + [:* + [:div {:class (dm/str class " " (stl/css-case :wrapper true + :input-error error))} + [:> label* {:for id} label] + [:> input-field* props]] + (when token-resolve-result + [:> token-value-hint* {:result token-resolve-result}])])) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/create/modals.cljs b/frontend/src/app/main/ui/workspace/tokens/management/create/modals.cljs index 4871886229..1ae3ba7c34 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/create/modals.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/create/modals.cljs @@ -70,7 +70,7 @@ {::mf/wrap-props false} [{:keys [x y position token token-type action selected-token-set-name] :as _args}] (let [wrapper-style (use-viewport-position-style x y position (= token-type :color)) - modal-size-large* (mf/use-state false) + modal-size-large* (mf/use-state (= token-type :typography)) modal-size-large? (deref modal-size-large*) close-modal (mf/use-fn (fn [] diff --git a/frontend/src/app/main/ui/workspace/tokens/management/group.cljs b/frontend/src/app/main/ui/workspace/tokens/management/group.cljs index bb6f5b3b15..f014375a60 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/group.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/group.cljs @@ -33,13 +33,13 @@ :text-case "text-mixed" :text-decoration "text-underlined" :font-weight "text-font-weight" + :typography "text-typography" :opacity "percentage" :number "number" :rotation "rotation" :spacing "padding-extended" :string "text-mixed" :stroke-width "stroke-size" - :typography "text" :dimensions "expand" :sizing "expand" "add")) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs index 7a7aafa682..372d19d548 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs @@ -80,7 +80,13 @@ :sizing "Sizing" :border-radius "Border Radius" :x "X" - :y "Y"}) + :y "Y" + :font-size "Font Size" + :font-family "Font Family" + :font-weight "Font Weight" + :letter-spacing "Letter Spacing" + :text-case "Text Case" + :text-decoration "Text Decoration"}) ;; Helper functions @@ -104,12 +110,23 @@ (str/join ", " (map attribute-dictionary values)) "."))) grouped-values))) +(defn format-token-value [token-value] + (cond + (map? token-value) + (->> (map (fn [[k v]] (str "- " (category-dictionary k) ": " (format-token-value v))) token-value) + (str/join "\n") + (str "\n")) + + (sequential? token-value) + (str/join "," token-value) + + :else + (str token-value))) + (defn- generate-tooltip "Generates a tooltip for a given token" [is-viewer shape theme-token token half-applied no-valid-value ref-not-in-active-set] - (let [{:keys [name type resolved-value]} token - value (cond->> (:value token) - (= :font-family type) ctt/join-font-family) + (let [{:keys [name type resolved-value value]} token resolved-value-theme (:resolved-value theme-token) resolved-value (or resolved-value-theme resolved-value) {:keys [title] :as token-props} (dwta/get-token-properties theme-token) @@ -125,8 +142,8 @@ grouped-values (group-by dimensions-dictionary app-token-keys) base-title (dm/str "Token: " name "\n" - (tr "workspace.tokens.original-value" value) "\n" - (tr "workspace.tokens.resolved-value" resolved-value) + (tr "workspace.tokens.original-value" (format-token-value value)) "\n" + (tr "workspace.tokens.resolved-value" (format-token-value resolved-value)) (when (= (:type token) :number) (dm/str "\n" (tr "workspace.tokens.more-options"))))] diff --git a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs index 1cd6a815c3..9345f21997 100644 --- a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs +++ b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs @@ -860,6 +860,173 @@ (t/is (nil? (:fill-color-ref-id fill))) (t/is (nil? (:fill-color-ref-file fill)))))))))) +(t/deftest test-apply-typography-token + (t/testing "applies typography (composite) tokens" + (t/async + done + (let [font-size-token {:name "font-size-reference" + :value "100px" + :type :font-size} + font-family-token {:name "font-family-reference" + :value ["Arial" "sans-serif"] + :type :font-family} + typography-token {:name "typography.heading" + :value {:font-size "24px" + :font-weight "bold" + :font-family [(:font-id txt/default-text-attrs) "Arial" "sans-serif"] + :letter-spacing "2" + :text-case "uppercase" + :text-decoration "underline"} + :type :typography} + file (-> (setup-file-with-tokens) + (update-in [:data :tokens-lib] + #(-> % + (ctob/add-token-in-set "Set A" (ctob/make-token font-size-token)) + (ctob/add-token-in-set "Set A" (ctob/make-token font-family-token)) + (ctob/add-token-in-set "Set A" (ctob/make-token typography-token))))) + store (ths/setup-store file) + text-1 (cths/get-shape file :text-1) + events [(dwta/apply-token {:shape-ids [(:id text-1)] + :attributes #{:typography} + :token (toht/get-token file "typography.heading") + :on-update-shape dwta/update-typography})]] + (tohs/run-store-async + store done events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + text-1' (cths/get-shape file' :text-1) + text-1' (def text-1' text-1') + style-text-blocks (->> (:content text-1') + (txt/content->text+styles) + (remove (fn [[_ text]] (str/empty? (str/trim text)))) + (mapv (fn [[style text]] + {:styles (merge txt/default-text-attrs style) + :text-content text})) + (first) + (:styles))] + (t/is (some? (:applied-tokens text-1'))) + (t/is (= (:typography (:applied-tokens text-1')) "typography.heading")) + + (t/is (= (:font-size style-text-blocks) "24")) + (t/is (= (:font-weight style-text-blocks) "400")) + (t/is (= (:font-family style-text-blocks) "sourcesanspro")) + (t/is (= (:letter-spacing style-text-blocks) "2")) + (t/is (= (:text-transform style-text-blocks) "uppercase")) + (t/is (= (:text-decoration style-text-blocks) "underline"))))))))) + +(t/deftest test-apply-reference-typography-token + (t/testing "applies typography (composite) tokens with references" + (t/async + done + (let [font-size-token {:name "fontSize" + :value "100px" + :type :font-size} + font-family-token {:name "fontFamily" + :value ["Arial" "sans-serif"] + :type :font-family} + typography-token {:name "typography" + :value {:font-size "{fontSize}" + :font-family ["{fontFamily}"]} + :type :typography} + file (-> (setup-file-with-tokens) + (update-in [:data :tokens-lib] + #(-> % + (ctob/add-token-in-set "Set A" (ctob/make-token font-size-token)) + (ctob/add-token-in-set "Set A" (ctob/make-token font-family-token)) + (ctob/add-token-in-set "Set A" (ctob/make-token typography-token))))) + store (ths/setup-store file) + text-1 (cths/get-shape file :text-1) + events [(dwta/apply-token {:shape-ids [(:id text-1)] + :attributes #{:typography} + :token (toht/get-token file "typography") + :on-update-shape dwta/update-typography})]] + (tohs/run-store-async + store done events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + text-1' (cths/get-shape file' :text-1) + style-text-blocks (->> (:content text-1') + (txt/content->text+styles) + (remove (fn [[_ text]] (str/empty? (str/trim text)))) + (mapv (fn [[style text]] + {:styles (merge txt/default-text-attrs style) + :text-content text})) + (first) + (:styles))] + (t/is (some? (:applied-tokens text-1'))) + (t/is (= (:typography (:applied-tokens text-1')) "typography")) + + (t/is (= (:font-size style-text-blocks) "100")) + (t/is (= (:font-family style-text-blocks) "Arial"))))))))) + +(t/deftest test-unapply-atomic-tokens-on-composite-apply + (t/testing "unapplies atomic typography tokens when applying composite token" + (t/async + done + (let [font-size-token {:name "fontSize" + :value "100px" + :type :font-size} + typography-token {:name "typography" + :value {} + :type :typography} + file (-> (setup-file-with-tokens) + (update-in [:data :tokens-lib] + #(-> % + (ctob/add-token-in-set "Set A" (ctob/make-token font-size-token)) + (ctob/add-token-in-set "Set A" (ctob/make-token typography-token))))) + store (ths/setup-store file) + text-1 (cths/get-shape file :text-1) + events [(dwta/apply-token {:shape-ids [(:id text-1)] + :attributes #{:typography} + :token (toht/get-token file "fontSize")}) + (dwta/apply-token {:shape-ids [(:id text-1)] + :attributes #{:typography} + :token (toht/get-token file "typography") + :on-update-shape dwta/update-typography})]] + (tohs/run-store-async + store done events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + text-1' (cths/get-shape file' :text-1)] + (t/is (some? (:applied-tokens text-1'))) + (t/is (= (:typography (:applied-tokens text-1')) "typography")) + (t/is (nil? (:font-size (:applied-tokens text-1'))))))))))) + + +(t/deftest test-unapply-composite-tokens-on-atomic-apply + (t/testing "unapplies composite typography tokens when applying atomic token" + (t/async + done + (let [font-size-token {:name "fontSize" + :value "100px" + :type :font-size} + typography-token {:name "typography" + :value {} + :type :typography} + file (-> (setup-file-with-tokens) + (update-in [:data :tokens-lib] + #(-> % + (ctob/add-token-in-set "Set A" (ctob/make-token font-size-token)) + (ctob/add-token-in-set "Set A" (ctob/make-token typography-token))))) + store (ths/setup-store file) + text-1 (cths/get-shape file :text-1) + events [(dwta/apply-token {:shape-ids [(:id text-1)] + :attributes #{:typography} + :token (toht/get-token file "typography") + :on-update-shape dwta/update-typography}) + (dwta/apply-token {:shape-ids [(:id text-1)] + :attributes #{:font-size} + :token (toht/get-token file "fontSize") + :on-update-shape dwta/update-font-size})]] + (tohs/run-store-async + store done events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + text-1' (cths/get-shape file' :text-1)] + (t/is (some? (:applied-tokens text-1'))) + (t/is (= (:font-size (:applied-tokens text-1')) "fontSize")) + (t/is (nil? (:typography (:applied-tokens text-1'))))))))))) + (t/deftest test-detach-styles-typography (t/testing "applying any typography token to a shape with a typography style should detach the style" (t/async