diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 37e6268b3d..b0faa1629a 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -123,6 +123,7 @@ :token-color :token-typography-types :token-typography-composite + :token-shadow :transit-readable-response :user-feedback ;; TODO: remove this flag. diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index 41b0149e44..59cd4f8bca 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -54,6 +54,7 @@ (def token-type->dtcg-token-type {:boolean "boolean" :border-radius "borderRadius" + :shadow "shadow" :color "color" :dimensions "dimension" :font-family "fontFamilies" @@ -77,7 +78,8 @@ ;; Allow these properties to be imported with singular key names for backwards compability (assoc "fontWeight" :font-weight "fontSize" :font-size - "fontFamily" :font-family))) + "fontFamily" :font-family + "boxShadow" :shadow))) (def composite-token-type->dtcg-token-type "Custom set of conversion keys for composite typography token with `:line-height` available. @@ -115,6 +117,12 @@ (def border-radius-keys (schema-keys schema:border-radius)) +(def ^:private schema:shadow + [:map {:title "ShadowTokenAttrs"} + [:shadow {:optional true} token-name-ref]]) + +(def shadow-keys (schema-keys schema:shadow)) + (def ^:private schema:stroke-width [:map [:stroke-width {:optional true} token-name-ref]]) @@ -271,6 +279,7 @@ (def all-keys (set/union color-keys border-radius-keys + shadow-keys stroke-width-keys sizing-keys opacity-keys @@ -289,6 +298,7 @@ [:merge {:title "AppliedTokens"} schema:tokens schema:border-radius + schema:shadow schema:sizing schema:spacing schema:rotation @@ -334,6 +344,7 @@ (font-weight-keys shape-attr) #{shape-attr :typography} (border-radius-keys shape-attr) #{shape-attr} + (shadow-keys shape-attr) #{shape-attr} (sizing-keys shape-attr) #{shape-attr} (opacity-keys shape-attr) #{shape-attr} (spacing-keys shape-attr) #{shape-attr} @@ -361,6 +372,7 @@ rotation-keys sizing-keys opacity-keys + shadow-keys position-attributes)) (def rect-attributes @@ -444,6 +456,30 @@ spacing-margin-keys)] (unapply-token-id shape layout-item-attrs))) +(def tokens-by-input + "A map from input name to applicable token for that input." + {:width #{:sizing :dimensions} + :height #{:sizing :dimensions} + :max-width #{:sizing :dimensions} + :max-height #{:sizing :dimensions} + :x #{:spacing :dimensions} + :y #{:spacing :dimensions} + :rotation #{:number :rotation} + :border-radius #{:border-radius :dimensions} + :row-gap #{:spacing :dimensions} + :column-gap #{:spacing :dimensions} + :horizontal-padding #{:spacing :dimensions} + :vertical-padding #{:spacing :dimensions} + :sided-paddings #{:spacing :dimensions} + :horizontal-margin #{:spacing :dimensions} + :vertical-margin #{:spacing :dimensions} + :sided-margins #{:spacing :dimensions} + :line-height #{:line-height :number} + :font-size #{:font-size} + :letter-spacing #{:letter-spacing} + :fill #{:color} + :stroke-color #{:color}}) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; TYPOGRAPHY ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -514,26 +550,11 @@ [token-value] (string? token-value)) -(def tokens-by-input - "A map from input name to applicable token for that input." - {:width #{:sizing :dimensions} - :height #{:sizing :dimensions} - :max-width #{:sizing :dimensions} - :max-height #{:sizing :dimensions} - :x #{:spacing :dimensions} - :y #{:spacing :dimensions} - :rotation #{:number :rotation} - :border-radius #{:border-radius :dimensions} - :row-gap #{:spacing :dimensions} - :column-gap #{:spacing :dimensions} - :horizontal-padding #{:spacing :dimensions} - :vertical-padding #{:spacing :dimensions} - :sided-paddings #{:spacing :dimensions} - :horizontal-margin #{:spacing :dimensions} - :vertical-margin #{:spacing :dimensions} - :sided-margins #{:spacing :dimensions} - :line-height #{:line-height :number} - :font-size #{:font-size} - :letter-spacing #{:letter-spacing} - :fill #{:color} - :stroke-color #{:color}}) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; SHADOW +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn shadow-composite-token-reference? + "Predicate if a shadow composite token is a reference value - a string pointing to another reference token." + [token-value] + (string? token-value)) diff --git a/common/src/app/common/types/tokens_lib.cljc b/common/src/app/common/types/tokens_lib.cljc index df7aa38cf2..4b99b0e1c4 100644 --- a/common/src/app/common/types/tokens_lib.cljc +++ b/common/src/app/common/types/tokens_lib.cljc @@ -1552,6 +1552,46 @@ Will return a value that matches this schema: ;; Reference value value)) +(defn- convert-dtcg-shadow-composite + "Convert shadow token value from DTCG format to internal format." + [value] + (let [process-shadow (fn [shadow] + (if (map? shadow) + (let [legacy-shadow-type (get "type" shadow)] + (-> shadow + (set/rename-keys {"x" :offsetX + "offsetX" :offsetX + "y" :offsetY + "offsetY" :offsetY + "blur" :blur + "spread" :spread + "color" :color + "inset" :inset}) + (update :inset #(cond + (boolean? %) % + (= "true" %) true + (= "false" %) false + (= legacy-shadow-type "innerShadow") true + :else false)) + (select-keys [:offsetX :offsetY :blur :spread :color :inset]))) + shadow))] + (cond + ;; Reference value - keep as string + (string? value) + value + + ;; Array of shadows - process each + (sequential? value) + (mapv process-shadow value) + + ;; Single shadow object - wrap in vector + (map? value) + [(process-shadow value)] + + ;; Fallback - keep as is + :else + value))) + (defn- flatten-nested-tokens-json "Convert a tokens tree in the decoded json fragment into a flat map, being the keys the token paths after joining the keys with '.'." @@ -1574,6 +1614,7 @@ Will return a value that matches this schema: (case token-type :font-family (convert-dtcg-font-family token-value) :typography (convert-dtcg-typography-composite token-value) + :shadow (convert-dtcg-shadow-composite token-value) token-value)) :description (get v "$description"))) ;; Discard unknown type tokens @@ -1739,11 +1780,32 @@ Will return a value that matches this schema: {} value) value)) +(defn- shadow-token->dtcg-token + "Convert shadow token value from internal format to DTCG format." + [value] + (if (sequential? value) + (mapv (fn [shadow] + (if (map? shadow) + (-> shadow + (set/rename-keys {:offsetX "offsetX" + :offsetY "offsetY" + :blur "blur" + :spread "spread" + :color "color" + :inset "inset"}) + (select-keys ["offsetX" "offsetY" "blur" "spread" "color" "inset"])) + shadow)) + value) + value)) + (defn- token->dtcg-token [token] (cond-> {"$value" (cond-> (:value token) ;; Transform typography token values (= :typography (:type token)) - typography-token->dtcg-token) + typography-token->dtcg-token + ;; Transform shadow token values + (= :shadow (:type token)) + shadow-token->dtcg-token) "$type" (cto/token-type->dtcg-token-type (:type token))} (:description token) (assoc "$description" (:description token)))) diff --git a/common/test/common_tests/types/data/tokens-shadow-example.json b/common/test/common_tests/types/data/tokens-shadow-example.json new file mode 100644 index 0000000000..cd9e6fe240 --- /dev/null +++ b/common/test/common_tests/types/data/tokens-shadow-example.json @@ -0,0 +1,60 @@ +{ + "test": { + "shadow-single": { + "$value": { + "x": "0", + "y": "2px", + "blur": "4px", + "spread": "0", + "color": "#000" + }, + "$type": "boxShadow" + }, + "shadow-multiple": { + "$value": [ + { + "x": "0", + "y": "2px", + "blur": "4px", + "spread": "0", + "color": "#000", + "inset": true + }, + { + "x": "0", + "y": "8px", + "blur": "16px", + "spread": "0", + "color": "#000", + "inset": "true" + } + ], + "$type": "boxShadow" + }, + "shadow-ref": { + "$value": "{shadow-single}", + "$type": "boxShadow" + }, + "shadow-with-type": { + "$value": { + "x": "0", + "y": "4px", + "blur": "8px", + "spread": "0", + "color": "rgba(0,0,0,0.2)", + "type": "innerShadow" + }, + "$type": "boxShadow" + }, + "shadow-with-description": { + "$value": { + "x": "1px", + "y": "1px", + "blur": "3px", + "color": "gray" + }, + "$type": "boxShadow", + "$description": "A simple shadow token" + } + } +} diff --git a/common/test/common_tests/types/tokens_lib_test.cljc b/common/test/common_tests/types/tokens_lib_test.cljc index f036c821df..9bce6cb959 100644 --- a/common/test/common_tests/types/tokens_lib_test.cljc +++ b/common/test/common_tests/types/tokens_lib_test.cljc @@ -1362,9 +1362,7 @@ {:name "button.primary.background" :type :color :value "{accent.default}" - :description ""}))) - (t/testing "invalid tokens got discarded" - (t/is (nil? (ctob/get-token-by-name lib "theme" "boxShadow.default"))))))) + :description ""})))))) #?(:clj (t/deftest parse-multi-set-dtcg-json @@ -1392,9 +1390,7 @@ {:name "button.primary.background" :type :color :value "{accent.default}" - :description ""}))) - (t/testing "invalid tokens got discarded" - (t/is (nil? (ctob/get-token-by-name lib "theme" "boxShadow.default"))))))) + :description ""})))))) #?(:clj (t/deftest parse-multi-set-dtcg-json-default-team @@ -1893,3 +1889,130 @@ (t/is (some? imported-single)) (t/is (= (:type imported-single) (:type original-single))) (t/is (= (:value imported-single) (:value original-single)))))))) + +#?(:clj + (t/deftest parse-shadow-tokens + (let [json (-> (slurp "test/common_tests/types/data/tokens-shadow-example.json") + (json/decode {:key-fn identity})) + lib (ctob/parse-decoded-json json "shadow-test")] + + (t/testing "single shadow token" + (let [token (ctob/get-token-by-name lib "shadow-test" "test.shadow-single")] + (t/is (some? token)) + (t/is (= :shadow (:type token))) + (t/is (= [{:offsetX "0", :offsetY "2px", :blur "4px", :spread "0", :color "#000", :inset false}] + (:value token))))) + + (t/testing "multiple shadow token" + (let [token (ctob/get-token-by-name lib "shadow-test" "test.shadow-multiple")] + (t/is (some? token)) + (t/is (= :shadow (:type token))) + (t/is (= [{:offsetX "0", :offsetY "2px", :blur "4px", :spread "0", :color "#000", :inset true} + {:offsetX "0", :offsetY "8px", :blur "16px", :spread "0", :color "#000", :inset true}] + (:value token))))) + + (t/testing "shadow token with reference" + (let [token (ctob/get-token-by-name lib "shadow-test" "test.shadow-ref")] + (t/is (some? token)) + (t/is (= :shadow (:type token))) + (t/is (= "{shadow-single}" (:value token))))) + + (t/testing "shadow token with type" + (let [token (ctob/get-token-by-name lib "shadow-test" "test.shadow-with-type")] + (t/is (some? token)) + (t/is (= :shadow (:type token))) + (t/is (= [{:offsetX "0", :offsetY "4px", :blur "8px", :spread "0", :color "rgba(0,0,0,0.2)", :inset false}] + (:value token))))) + + (t/testing "shadow token with description" + (let [token (ctob/get-token-by-name lib "shadow-test" "test.shadow-with-description")] + (t/is (some? token)) + (t/is (= :shadow (:type token))) + (t/is (= "A simple shadow token" (:description token)))))))) + +#?(:clj + (t/deftest export-shadow-tokens + (let [tokens-lib (-> (ctob/make-tokens-lib) + (ctob/add-set + (ctob/make-token-set + :name "shadow-set" + :tokens {"shadow.single" + (ctob/make-token + {:name "shadow.single" + :type :shadow + :value [{:offsetX "0" :offsetY "2px" :blur "4px" :spread "0" :color "#0000001A"}] + :description "A single shadow"}) + "shadow.multiple" + (ctob/make-token + {:name "shadow.multiple" + :type :shadow + :value [{:offsetX "0" :offsetY "2px" :blur "4px" :spread "0" :color "#0000001A"} + {:offsetX "0" :offsetY "8px" :blur "16px" :spread "0" :color "#0000001A"}]}) + "shadow.ref" + (ctob/make-token + {:name "shadow.ref" + :type :shadow + :value "{shadow.single}"}) + "shadow.empty" + (ctob/make-token + {:name "shadow.empty" + :type :shadow + :value {}})}))) + result (ctob/export-dtcg-json tokens-lib) + shadow-set (get result "shadow-set")] + + (t/testing "single shadow token export" + (let [single-token (get-in shadow-set ["shadow" "single"])] + (t/is (= "shadow" (get single-token "$type"))) + (t/is (= [{"offsetX" "0" "offsetY" "2px" "blur" "4px" "spread" "0" "color" "#0000001A"}] (get single-token "$value"))) + (t/is (= "A single shadow" (get single-token "$description"))))) + + (t/testing "multiple shadow token export" + (let [multiple-token (get-in shadow-set ["shadow" "multiple"])] + (t/is (= "shadow" (get multiple-token "$type"))) + (t/is (= [{"offsetX" "0" "offsetY" "2px" "blur" "4px" "spread" "0" "color" "#0000001A"} + {"offsetX" "0" "offsetY" "8px" "blur" "16px" "spread" "0" "color" "#0000001A"}] + (get multiple-token "$value"))))) + + (t/testing "reference shadow token export" + (let [ref-token (get-in shadow-set ["shadow" "ref"])] + (t/is (= "shadow" (get ref-token "$type"))) + (t/is (= "{shadow.single}" (get ref-token "$value"))))) + + (t/testing "empty shadow token export" + (let [empty-token (get-in shadow-set ["shadow" "empty"])] + (t/is (= "shadow" (get empty-token "$type"))) + (t/is (= {} (get empty-token "$value")))))))) + +#?(:clj + (t/deftest shadow-token-round-trip + (let [original-lib (-> (ctob/make-tokens-lib) + (ctob/add-set + (ctob/make-token-set + :name "test-set" + :tokens {"shadow.test" + (ctob/make-token + {:name "shadow.test" + :type :shadow + :value [{:offsetX "1" :offsetY "1" :blur "1" :spread "1" :color "red" :inset true}] + :description "Round trip test"}) + "shadow.ref" + (ctob/make-token + {:name "shadow.ref" + :type :shadow + :value "{shadow.test}"})}))) + exported (ctob/export-dtcg-json original-lib) + imported-lib (ctob/parse-decoded-json exported "")] + + (t/testing "round trip preserves shadow tokens" + (let [original-token (ctob/get-token-by-name original-lib "test-set" "shadow.test") + imported-token (ctob/get-token-by-name imported-lib "test-set" "shadow.test")] + (t/is (some? imported-token)) + (t/is (= (:type original-token) (:type imported-token))) + (t/is (= (:value original-token) (:value imported-token))) + (t/is (= (:description original-token) (:description imported-token)))) + (let [original-ref (ctob/get-token-by-name original-lib "test-set" "shadow.ref") + imported-ref (ctob/get-token-by-name imported-lib "test-set" "shadow.ref")] + (t/is (some? imported-ref)) + (t/is (= (:type original-ref) (:type imported-ref))) + (t/is (= (:value imported-ref) (:value original-ref)))))))) diff --git a/frontend/playwright/ui/specs/tokens.spec.js b/frontend/playwright/ui/specs/tokens.spec.js index 077f46b9f0..842b5a5ff1 100644 --- a/frontend/playwright/ui/specs/tokens.spec.js +++ b/frontend/playwright/ui/specs/tokens.spec.js @@ -7,8 +7,14 @@ test.beforeEach(async ({ page }) => { await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-tokens.json"); }); -const setupEmptyTokensFile = async (page) => { +const setupEmptyTokensFile = async (page, options = {}) => { + const { flags = [] } = options; + const workspacePage = new WorkspacePage(page); + if (flags.length > 0) { + await workspacePage.mockConfigFlags(flags); + } + await workspacePage.setupEmptyFile(); await workspacePage.mockRPC( "get-team?id=*", @@ -1155,6 +1161,228 @@ test.describe("Tokens: Themes modal", () => { name: newTokenTitle, }); await expect(newToken).toBeVisible(); + }); + + test("User adds shadow token with multiple shadows and applies it to shape", async ({ + page, + }) => { + const { tokensUpdateCreateModal, tokensSidebar, workspacePage, tokenContextMenuForToken } = + await setupTokensFile(page, { flags: ["enable-token-shadow"] }); + + const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); + + await test.step("Stage 1: Basic open", async () => { + // User adds shadow via the sidebar + await tokensTabPanel + .getByRole("button", { name: "Add Token: Shadow" }) + .click(); + + await expect(tokensUpdateCreateModal).toBeVisible(); + + const nameField = tokensUpdateCreateModal.getByLabel("Name"); + await nameField.fill("shadow.primary"); + + // User adds first shadow with a color from the color ramp + const firstShadowFields = tokensUpdateCreateModal.getByTestId( + "shadow-input-fields-0", + ); + await expect(firstShadowFields).toBeVisible(); + + // Fill in the shadow values + const offsetXInput = firstShadowFields.getByLabel("X"); + const offsetYInput = firstShadowFields.getByLabel("Y"); + const blurInput = firstShadowFields.getByLabel("Blur"); + const spreadInput = firstShadowFields.getByLabel("Spread"); + + await offsetXInput.fill("2"); + await offsetYInput.fill("2"); + await blurInput.fill("4"); + await spreadInput.fill("0"); + + // Add color using the color picker + const colorBullet = firstShadowFields.getByTestId( + "token-form-color-bullet", + ); + await colorBullet.click(); + + // Click on the color ramp to select a color + const valueSaturationSelector = tokensUpdateCreateModal.getByTestId( + "value-saturation-selector", + ); + await expect(valueSaturationSelector).toBeVisible(); + await valueSaturationSelector.click({ position: { x: 50, y: 50 } }); + + // Verify that a color value was set + const colorInput = firstShadowFields.getByLabel("Color"); + const firstColorValue = await colorInput.inputValue(); + await expect(firstColorValue).toMatch(/^rgb(.*)$/); + + // Wait for validation to complete + await expect(tokensUpdateCreateModal.getByText(/Resolved value:/).first()).toBeVisible(); + + // Save button should be enabled + const submitButton = tokensUpdateCreateModal.getByRole("button", { + name: "Save", + }); + await expect(submitButton).toBeEnabled(); + }); + + await test.step("Stage 2: Shadow adding/removing works", async () => { + const firstShadowFields = tokensUpdateCreateModal.getByTestId( + "shadow-input-fields-0", + ); + const colorInput = firstShadowFields.getByLabel("Color"); + const firstColorValue = await colorInput.inputValue(); + + // User adds a second shadow + const addButton = firstShadowFields.getByTestId("shadow-add-button-0"); + await addButton.click(); + + const secondShadowFields = tokensUpdateCreateModal.getByTestId( + "shadow-input-fields-1", + ); + await expect(secondShadowFields).toBeVisible(); + + // User adds a third shadow + const addButton2 = secondShadowFields.getByTestId("shadow-add-button-1"); + await addButton2.click(); + + const thirdShadowFields = tokensUpdateCreateModal.getByTestId( + "shadow-input-fields-2", + ); + await expect(thirdShadowFields).toBeVisible(); + + // User adds values for the third shadow + const thirdOffsetXInput = thirdShadowFields.getByLabel("X"); + const thirdOffsetYInput = thirdShadowFields.getByLabel("Y"); + const thirdBlurInput = thirdShadowFields.getByLabel("Blur"); + const thirdSpreadInput = thirdShadowFields.getByLabel("Spread"); + const thirdColorInput = thirdShadowFields.getByLabel("Color"); + + await thirdOffsetXInput.fill("10"); + await thirdOffsetYInput.fill("10"); + await thirdBlurInput.fill("20"); + await thirdSpreadInput.fill("5"); + await thirdColorInput.fill("#FF0000"); + + // User removes the 2nd shadow + const removeButton2 = secondShadowFields.getByTestId("shadow-remove-button-1"); + await removeButton2.click(); + + // Verify second shadow is removed + await expect(secondShadowFields.getByTestId("shadow-add-button-3")).not.toBeVisible(); + + // Verify that the first shadow kept its values + const firstOffsetXValue = await firstShadowFields.getByLabel("X").inputValue(); + const firstOffsetYValue = await firstShadowFields.getByLabel("Y").inputValue(); + const firstBlurValue = await firstShadowFields.getByLabel("Blur").inputValue(); + const firstSpreadValue = await firstShadowFields.getByLabel("Spread").inputValue(); + const firstColorValueAfter = await firstShadowFields.getByLabel("Color").inputValue(); + + await expect(firstOffsetXValue).toBe("2"); + await expect(firstOffsetYValue).toBe("2"); + await expect(firstBlurValue).toBe("4"); + await expect(firstSpreadValue).toBe("0"); + await expect(firstColorValueAfter).toBe(firstColorValue); + + // Verify that the third shadow (now second) kept its values + // After removing index 1, the third shadow becomes the second shadow at index 1 + const newSecondShadowFields = tokensUpdateCreateModal.getByTestId( + "shadow-input-fields-1", + ); + await expect(newSecondShadowFields).toBeVisible(); + + const secondOffsetXValue = await newSecondShadowFields.getByLabel("X").inputValue(); + const secondOffsetYValue = await newSecondShadowFields.getByLabel("Y").inputValue(); + const secondBlurValue = await newSecondShadowFields.getByLabel("Blur").inputValue(); + const secondSpreadValue = await newSecondShadowFields.getByLabel("Spread").inputValue(); + const secondColorValue = await newSecondShadowFields.getByLabel("Color").inputValue(); + + await expect(secondOffsetXValue).toBe("10"); + await expect(secondOffsetYValue).toBe("10"); + await expect(secondBlurValue).toBe("20"); + await expect(secondSpreadValue).toBe("5"); + await expect(secondColorValue).toBe("#FF0000"); + }); + + await test.step("Stage 3: Restore when switching tabs works", async () => { + const firstShadowFields = tokensUpdateCreateModal.getByTestId( + "shadow-input-fields-0", + ); + const newSecondShadowFields = tokensUpdateCreateModal.getByTestId( + "shadow-input-fields-1", + ); + const colorInput = firstShadowFields.getByLabel("Color"); + const firstColorValue = await colorInput.inputValue(); + + // Switch to reference tab + const referenceTabButton = tokensUpdateCreateModal.getByTestId("reference-opt"); + await referenceTabButton.click(); + + // Verify we're in reference mode - the composite fields should not be visible + await expect(firstShadowFields).not.toBeVisible(); + + // Switch back to composite tab + const compositeTabButton = tokensUpdateCreateModal.getByTestId("composite-opt"); + await compositeTabButton.click(); + + // Verify that shadows are restored + await expect(firstShadowFields).toBeVisible(); + await expect(newSecondShadowFields).toBeVisible(); + + // Verify first shadow values are still there + const restoredFirstOffsetX = await firstShadowFields.getByLabel("X").inputValue(); + const restoredFirstOffsetY = await firstShadowFields.getByLabel("Y").inputValue(); + const restoredFirstBlur = await firstShadowFields.getByLabel("Blur").inputValue(); + const restoredFirstSpread = await firstShadowFields.getByLabel("Spread").inputValue(); + const restoredFirstColor = await firstShadowFields.getByLabel("Color").inputValue(); + + await expect(restoredFirstOffsetX).toBe("2"); + await expect(restoredFirstOffsetY).toBe("2"); + await expect(restoredFirstBlur).toBe("4"); + await expect(restoredFirstSpread).toBe("0"); + await expect(restoredFirstColor).toBe(firstColorValue); + + // Verify second shadow values are still there + const restoredSecondOffsetX = await newSecondShadowFields.getByLabel("X").inputValue(); + const restoredSecondOffsetY = await newSecondShadowFields.getByLabel("Y").inputValue(); + const restoredSecondBlur = await newSecondShadowFields.getByLabel("Blur").inputValue(); + const restoredSecondSpread = await newSecondShadowFields.getByLabel("Spread").inputValue(); + const restoredSecondColor = await newSecondShadowFields.getByLabel("Color").inputValue(); + + await expect(restoredSecondOffsetX).toBe("10"); + await expect(restoredSecondOffsetY).toBe("10"); + await expect(restoredSecondBlur).toBe("20"); + await expect(restoredSecondSpread).toBe("5"); + await expect(restoredSecondColor).toBe("#FF0000"); + }); + + await test.step("Stage 4: Layer application works", async () => { + // Save the token + const submitButton = tokensUpdateCreateModal.getByRole("button", { + name: "Save", + }); + await submitButton.click(); + await expect(tokensUpdateCreateModal).not.toBeVisible(); + + // Verify token appears in sidebar + const shadowToken = tokensSidebar.getByRole("button", { + name: "shadow.primary", + }); + await expect(shadowToken).toBeEnabled(); + + // Apply the shadow + await workspacePage.clickLayers(); + await workspacePage.clickLeafLayer("Button"); + + const shadowSection = workspacePage.rightSidebar.getByText("Drop shadow"); + await expect(shadowSection).toHaveCount(0); + + await page.getByRole("tab", { name: "Tokens" }).click(); + await shadowToken.click(); + + await expect(shadowSection).toHaveCount(2); + }); }); }); }); diff --git a/frontend/src/app/main/data/style_dictionary.cljs b/frontend/src/app/main/data/style_dictionary.cljs index 1262b1d134..5005dedeed 100644 --- a/frontend/src/app/main/data/style_dictionary.cljs +++ b/frontend/src/app/main/data/style_dictionary.cljs @@ -324,6 +324,106 @@ (defn collect-typography-errors [token] (group-by :typography-key (:errors token))) +(defn- parse-sd-token-shadow-inset + [value] + (let [references (seq (cto/find-token-value-references value))] + (cond + (boolean? value) + {:value value} + + references + {:errors [(wte/error-with-value :error.style-dictionary/missing-reference references)] + :references references} + + :else + {:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value-shadow-type value)]}))) + +(defn- parse-sd-token-shadow-blur + "Parses shadow blur value (non-negative number)." + [value] + (let [parsed (parse-sd-token-general-value value) + valid? (and (:value parsed) (>= (:value parsed) 0))] + (cond + valid? + parsed + + :else + {:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value-shadow-blur value)]}))) + +(defn- parse-sd-token-shadow-spread + "Parses shadow spread value (non-negative number)." + [value] + (let [parsed (parse-sd-token-general-value value) + valid? (and (:value parsed) (>= (:value parsed) 0))] + (cond + valid? + parsed + + :else + {:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value-shadow-spread value)]}))) + +(defn- parse-single-shadow + "Parses a single shadow map with properties: x, y, blur, spread, color, type." + [shadow-map shadow-index] + (let [add-keyed-errors (fn [shadow-result k errors] + (update shadow-result :errors concat + (map #(assoc % :shadow-key k :shadow-index shadow-index) errors))) + parsers {:offsetX parse-sd-token-general-value + :offsetY parse-sd-token-general-value + :blur parse-sd-token-shadow-blur + :spread parse-sd-token-shadow-spread + :color parse-sd-token-color-value + :inset parse-sd-token-shadow-inset} + valid-shadow (reduce + (fn [acc [k v]] + (if-let [parser (get parsers k)] + (let [{:keys [errors value]} (parser v)] + (if (seq errors) + (add-keyed-errors acc k errors) + (assoc-in acc [:value k] (or value v)))) + acc)) + {:value {}} + shadow-map)] + valid-shadow)) + +(defn- parse-sd-token-shadow-value + "Parses shadow value and validates it." + [value] + (cond + ;; Reference value (string) + (string? value) {:value value} + + ;; Empty value + (nil? value) {:errors [(wte/get-error-code :error.token/empty-input)]} + + ;; Invalid value + (not (js/Array.isArray value)) {:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value value)]} + + ;; Array of shadows + :else + (let [converted (js->clj value :keywordize-keys true) + ;; Parse each shadow with its index + parsed-shadows (map-indexed + (fn [idx shadow-map] + (parse-single-shadow shadow-map idx)) + converted) + + ;; Collect all errors from all shadows + all-errors (mapcat :errors parsed-shadows) + + ;; Collect all values from shadows that have values + all-values (into [] (keep :value parsed-shadows))] + + (if (seq all-errors) + {:errors all-errors + :value all-values} + {:value all-values})))) + +(defn collect-shadow-errors [token shadow-index] + (group-by :shadow-key + (filter #(= (:shadow-index %) shadow-index) + (: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 @@ -364,6 +464,7 @@ (parse-atomic-typography-value (:type origin-token) value) (case (:type origin-token) :typography (parse-composite-typography-value value) + :shadow (parse-sd-token-shadow-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) diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index 29113b8f39..c9d75d0957 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -149,6 +149,36 @@ :ignore-touched true :changed-sub-attr [:stroke-color]})))) +(defn value->shadow + "Transform a token shadow value into penpot shadow data structure" + [value] + (mapv (fn [{:keys [offsetX offsetY blur spread color inset]}] + {:id (random-uuid) + :hidden false + :offset-x offsetX + :offset-y offsetY + :blur blur + :color (value->color color) + :spread spread + :style + (case inset + true :inner-shadow + :drop-shadow)}) + value)) + +(defn update-shadow + ([value shape-ids attributes] + (update-shadow value shape-ids attributes nil)) + ([value shape-ids _attributes page-id] + (when (sequential? value) + (let [shadows (value->shadow value)] + (dwsh/update-shapes shape-ids + #(assoc % :shadow shadows) + {:reg-objects? true + :ignore-touched true + :page-id page-id + :attrs [:shadow]}))))) + (defn update-fill-stroke ([value shape-ids attributes] (update-fill-stroke value shape-ids attributes nil)) @@ -643,6 +673,14 @@ :fields [{:label "Border Radius" :key :border-radius}]}} + :shadow + {:title "Shadow" + :attributes ctt/shadow-keys + :on-update-shape update-shadow + :modal {:key :tokens/shadow + :fields [{:label "Shadow" + :key :shadow}]}} + :color {:title "Color" :attributes #{:fill} diff --git a/frontend/src/app/main/data/workspace/tokens/errors.cljs b/frontend/src/app/main/data/workspace/tokens/errors.cljs index c63c78381a..0be73b58a8 100644 --- a/frontend/src/app/main/data/workspace/tokens/errors.cljs +++ b/frontend/src/app/main/data/workspace/tokens/errors.cljs @@ -96,6 +96,18 @@ {:error/code :error.style-dictionary/composite-line-height-needs-font-size :error/fn #(tr "workspace.tokens.composite-line-height-needs-font-size" %)} + :error.style-dictionary/invalid-token-value-shadow-type + {:error/code :error.style-dictionary/invalid-token-value-shadow-type + :error/fn #(tr "workspace.tokens.invalid-shadow-type-token-value" %)} + + :error.style-dictionary/invalid-token-value-shadow-blur + {:error/code :error.style-dictionary/invalid-token-value-shadow-blur + :error/fn #(tr "workspace.tokens.shadow-blur-range")} + + :error.style-dictionary/invalid-token-value-shadow-spread + {:error/code :error.style-dictionary/invalid-token-value-shadow-spread + :error/fn #(tr "workspace.tokens.shadow-spread-range")} + :error/unknown {:error/code :error/unknown :error/fn #(tr "labels.unknown-error")}}) diff --git a/frontend/src/app/main/data/workspace/tokens/format.cljs b/frontend/src/app/main/data/workspace/tokens/format.cljs index e9ce1e84ae..b832ebb365 100644 --- a/frontend/src/app/main/data/workspace/tokens/format.cljs +++ b/frontend/src/app/main/data/workspace/tokens/format.cljs @@ -15,7 +15,13 @@ :line-height "Line Height" :letter-spacing "Letter Spacing" :text-case "Text Case" - :text-decoration "Text Decoration"}) + :text-decoration "Text Decoration" + :offsetX "X" + :offsetY "Y" + :blur "Blur" + :spread "Spread" + :color "Color" + :inset "Inner Shadow"}) (defn format-token-value "Converts token value of any shape to a string." @@ -26,6 +32,9 @@ (str/join "\n") (str "\n")) + (and (sequential? token-value) (every? map? token-value)) + (str/join "\n" (map format-token-value token-value)) + (sequential? token-value) (str/join ", " token-value) diff --git a/frontend/src/app/main/data/workspace/tokens/propagation.cljs b/frontend/src/app/main/data/workspace/tokens/propagation.cljs index a21bbd518d..1b7924dbef 100644 --- a/frontend/src/app/main/data/workspace/tokens/propagation.cljs +++ b/frontend/src/app/main/data/workspace/tokens/propagation.cljs @@ -63,6 +63,7 @@ ctt/text-case-keys dwta/update-text-case ctt/text-decoration-keys dwta/update-text-decoration ctt/typography-token-keys dwta/update-typography + ctt/shadow-keys dwta/update-shadow #{:line-height} dwta/update-line-height ;; Layout diff --git a/frontend/src/app/main/ui/workspace/tokens/management.cljs b/frontend/src/app/main/ui/workspace/tokens/management.cljs index f844f62c93..91a6bc768b 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management.cljs @@ -34,7 +34,9 @@ (let [token-units? (contains? cf/flags :token-units) token-typography-composite-types? (contains? cf/flags :token-typography-composite) token-typography-types? (contains? cf/flags :token-typography-types) + token-shadow? (contains? cf/flags :token-shadow) all-types (cond-> dwta/token-properties + (not token-shadow?) (dissoc :shadow) (not token-units?) (dissoc :number) (not token-typography-composite-types?) (remove-keys ctt/typography-token-keys) (not token-typography-types?) (remove-keys ctt/ff-typography-keys)) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs index a16617b0fb..1d361f2fd3 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs @@ -279,7 +279,8 @@ :r3 "Bottom Right"} :hint (tr "workspace.tokens.radius") :on-update-shape-all dwta/update-shape-radius-all - :on-update-shape update-shape-radius-for-corners})] + :on-update-shape update-shape-radius-for-corners}) + shadow (partial generic-attribute-actions #{:shadow} "Shadow")] {:border-radius border-radius :color (fn [context-data] (concat @@ -303,6 +304,7 @@ :text-decoration text-decoration :font-weight font-weight :typography typography + :shadow shadow :dimensions (fn [context-data] (-> (concat (when (seq (sizing-attribute-actions context-data)) [{:title "Sizing" :submenu :sizing}]) 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 75302b50dd..5a351d4d4d 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 @@ -214,6 +214,24 @@ (when (empty? (:value token)) (wte/get-error-code :error.token/empty-input))) +(defn check-shadow-token-self-reference + "Check token when any of the attributes in a shadow's value have a self-reference." + [token] + (let [token-name (:name token) + shadow-values (:value token)] + (some (fn [[shadow-idx shadow-map]] + (some (fn [[k v]] + (when-let [err (check-self-reference token-name v)] + (assoc err :shadow-key k :shadow-index shadow-idx))) + shadow-map)) + (d/enumerate shadow-values)))) + +(defn check-empty-shadow-token [token] + (when (or (empty? (:value token)) + (some (fn [shadow] (not-every? #(contains? shadow %) [:offsetX :offsetY :blur :spread :color])) + (:value token))) + (wte/get-error-code :error.token/empty-input))) + (defn validate-typography-token [{:keys [token-value] :as props}] (cond @@ -232,6 +250,27 @@ check-typography-token-self-reference]) (default-validate-token)))) +(defn validate-shadow-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/shadow-composite-token-reference? token-value) (default-validate-token props) + ;; Validate composite token + :else + (-> props + (update :token-value (fn [value] + (->> (or value []) + (mapv (fn [shadow] + (d/update-when shadow :inset #(cond + (boolean? %) % + (= "true" %) true + :else false))))))) + (assoc :validators [check-empty-shadow-token + check-shadow-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. @@ -690,6 +729,13 @@ (on-external-update-value (get @backup-state-ref next-tab)) (set-active-tab next-tab)))) + update-composite-value + (mf/use-fn + (fn [f] + (clear-resolve-value) + (swap! backup-state-ref f) + (on-external-update-value (get @backup-state-ref :composite)))) + ;; Store updated value in backup-state-ref on-update-value' (mf/use-fn @@ -724,7 +770,8 @@ :is-reference-fn is-reference-fn})] [:> composite-tab (mf/spread-props props {:default-value default-value - :on-update-value on-update-value'})])]])) + :on-update-value on-update-value' + :update-composite-value update-composite-value})])]])) (mf/defc composite-form* "Wrapper around form* that manages composite/reference tab state. @@ -828,7 +875,8 @@ (fn [] (let [open? (not color-ramp-open?)] (reset! color-ramp-open* open?) - (on-display-colorpicker open?)))) + (when on-display-colorpicker + (on-display-colorpicker open?))))) swatch (mf/html @@ -913,6 +961,278 @@ :custom-input-token-value color-picker* :custom-input-token-value-props custom-input-token-value-props})])) +(mf/defc shadow-color-picker-wrapper* + "Wrapper for color-picker* that passes shadow color state from parent. + Similar to color-form* but receives color state from shadow-value-inputs*." + [{:keys [placeholder label default-value input-ref on-update-value on-external-update-value token-resolve-result shadow-color]}] + (let [;; Use the color state passed from parent (shadow-value-inputs*) + resolved-color (get token-resolve-result :resolved-value) + color (or shadow-color resolved-color default-value "") + + custom-input-token-value-props + (mf/use-memo + (mf/deps color) + (fn [] + {:color color}))] + + [:> color-picker* + {:placeholder placeholder + :label label + :default-value default-value + :input-ref input-ref + :on-update-value on-update-value + :on-external-update-value on-external-update-value + :custom-input-token-value-props custom-input-token-value-props + :token-resolve-result token-resolve-result}])) + +(def ^:private shadow-inputs + #(d/ordered-map + :offsetX + {:label (tr "workspace.tokens.shadow-x") + :placeholder (tr "workspace.tokens.shadow-x")} + :offsetY + {:label (tr "workspace.tokens.shadow-y") + :placeholder (tr "workspace.tokens.shadow-y")} + :blur + {:label (tr "workspace.tokens.shadow-blur") + :placeholder (tr "workspace.tokens.shadow-blur")} + :spread + {:label (tr "workspace.tokens.shadow-spread") + :placeholder (tr "workspace.tokens.shadow-spread")} + :color + {:label (tr "workspace.tokens.shadow-color") + :placeholder (tr "workspace.tokens.shadow-color")} + :inset + {:label (tr "workspace.tokens.shadow-inset") + :placeholder (tr "workspace.tokens.shadow-inset")})) + +(mf/defc inset-type-select* + [{:keys [default-value shadow-idx label on-change]}] + (let [selected* (mf/use-state (or (str default-value) "false")) + selected (deref selected*) + + on-change + (mf/use-fn + (mf/deps on-change selected shadow-idx) + (fn [value e] + (obj/set! e "tokenValue" (if (= "true" value) true false)) + (on-change e) + (reset! selected* (str value))))] + [:div {:class (stl/css :input-row)} + [:div {:class (stl/css :inset-label)} label] + [:& radio-buttons {:selected selected + :on-change on-change + :name (str "inset-select-" shadow-idx)} + [:& radio-button {:value "false" + :title "false" + :icon "❌" + :id (str "inset-default-" shadow-idx)}] + [:& radio-button {:value "true" + :title "true" + :icon "✅" + :id (str "inset-false-" shadow-idx)}]]])) + +(mf/defc shadow-input* + [{:keys [default-value label placeholder shadow-idx input-type on-update-value on-external-update-value token-resolve-result errors-by-key shadow-color]}] + (let [color-input-ref (mf/use-ref) + + on-change + (mf/use-fn + (mf/deps shadow-idx input-type on-update-value) + (fn [e] + (-> (obj/set! e "tokenTypeAtIndex" [shadow-idx input-type]) + (on-update-value)))) + + on-external-update-value' + (mf/use-fn + (mf/deps shadow-idx input-type on-external-update-value) + (fn [v] + (on-external-update-value [shadow-idx input-type] v))) + + resolved (get-in token-resolve-result [:resolved-value shadow-idx input-type]) + + errors (get errors-by-key input-type) + + should-show? (or (some? resolved) (seq errors)) + + token-prop (when should-show? + (d/without-nils + {:resolved-value resolved + :errors errors}))] + (case input-type + :inset + [:> inset-type-select* + {:default-value default-value + :shadow-idx shadow-idx + :label label + :on-change on-change}] + :color + [:> shadow-color-picker-wrapper* + {:placeholder placeholder + :label label + :default-value default-value + :input-ref color-input-ref + :on-update-value on-change + :on-external-update-value on-external-update-value' + :token-resolve-result token-prop + :shadow-color shadow-color + :data-testid (str "shadow-color-input-" shadow-idx)}] + [:div {:class (stl/css :input-row) + :data-testid (str "shadow-" (name input-type) "-input-" shadow-idx)} + [:> input-token* + {:label label + :placeholder placeholder + :default-value default-value + :on-change on-change + :token-resolve-result token-prop}]]))) + +(mf/defc shadow-input-fields* + [{:keys [shadow shadow-idx on-remove-shadow on-add-shadow is-remove-disabled on-update-value token-resolve-result errors-by-key on-external-update-value shadow-color] :as props}] + (let [on-remove-shadow + (mf/use-fn + (mf/deps shadow-idx on-remove-shadow) + #(on-remove-shadow shadow-idx))] + [:div {:data-testid (str "shadow-input-fields-" shadow-idx)} + [:> icon-button* {:icon i/add + :type "button" + :on-click on-add-shadow + :data-testid (str "shadow-add-button-" shadow-idx) + :aria-label (tr "workspace.tokens.shadow-add-shadow")}] + [:> icon-button* {:variant "ghost" + :type "button" + :icon i/remove + :on-click on-remove-shadow + :disabled is-remove-disabled + :data-testid (str "shadow-remove-button-" shadow-idx) + :aria-label (tr "workspace.tokens.shadow-remove-shadow")}] + (for [[input-type {:keys [label placeholder]}] (shadow-inputs)] + [:> shadow-input* + {:key (str input-type shadow-idx) + :input-type input-type + :label label + :placeholder placeholder + :shadow-idx shadow-idx + :default-value (get shadow input-type) + :on-update-value on-update-value + :token-resolve-result token-resolve-result + :errors-by-key errors-by-key + :on-external-update-value on-external-update-value + :shadow-color shadow-color}])])) + +(mf/defc shadow-value-inputs* + [{:keys [default-value on-update-value token-resolve-result update-composite-value] :as props}] + (let [shadows* (mf/use-state (or default-value [{}])) + shadows (deref shadows*) + shadows-count (count shadows) + composite-token? (not (cto/typography-composite-token-reference? (:value token-resolve-result))) + + ;; Maintain a map of color states for each shadow to prevent reset on add/remove + shadow-colors* (mf/use-state {}) + shadow-colors (deref shadow-colors*) + + ;; Initialize color states for each shadow index + _ (mf/use-effect + (mf/deps shadows) + (fn [] + (doseq [[idx shadow] (d/enumerate shadows)] + (when-not (contains? shadow-colors idx) + (let [resolved-color (get-in token-resolve-result [:resolved-value idx :color]) + initial-color (or resolved-color (get shadow :color) "")] + (swap! shadow-colors* assoc idx initial-color)))))) + + ;; Define on-external-update-value here where we have access to on-update-value + on-external-update-value + (mf/use-callback + (mf/deps on-update-value shadow-colors*) + (fn [token-type-at-index value] + (let [[idx token-type] token-type-at-index + e (js-obj)] + ;; Update shadow color state if this is a color update + (when (= token-type :color) + (swap! shadow-colors* assoc idx value)) + (obj/set! e "tokenTypeAtIndex" token-type-at-index) + (obj/set! e "target" #js {:value value}) + (on-update-value e)))) + + on-add-shadow + (mf/use-fn + (mf/deps shadows update-composite-value) + (fn [] + (update-composite-value + (fn [state] + (let [new-state (update state :composite (fnil conj []) {})] + (reset! shadows* (:composite new-state)) + new-state))))) + + on-remove-shadow + (mf/use-fn + (mf/deps shadows update-composite-value) + (fn [idx] + (update-composite-value + (fn [state] + (let [new-state (update state :composite d/remove-at-index idx)] + (reset! shadows* (:composite new-state)) + new-state)))))] + [:div {:class (stl/css :nested-input-row)} + (for [[shadow-idx shadow] (d/enumerate shadows) + :let [is-remove-disabled (= shadows-count 1) + key (str shadows-count shadow-idx) + errors-by-key (when composite-token? + (sd/collect-shadow-errors token-resolve-result shadow-idx))]] + [:div {:key key + :class (stl/css :nested-input-row)} + [:> shadow-input-fields* + {:is-remove-disabled is-remove-disabled + :shadow-idx shadow-idx + :on-add-shadow on-add-shadow + :on-remove-shadow on-remove-shadow + :shadow shadow + :on-update-value on-update-value + :token-resolve-result token-resolve-result + :errors-by-key errors-by-key + :on-external-update-value on-external-update-value + :shadow-color (get shadow-colors shadow-idx "")}]])])) + +(mf/defc shadow-form* + [{:keys [token] :rest props}] + (let [on-get-token-value + (mf/use-callback + (fn [e prev-composite-value] + (let [prev-composite-value (or prev-composite-value []) + [idx token-type :as token-type-at-index] (obj/get e "tokenTypeAtIndex") + input-value (case token-type + :inset (obj/get e "tokenValue") + (dom/get-target-val e)) + reference-value-input? (not token-type-at-index)] + (cond + reference-value-input? input-value + (and (string? input-value) (empty? input-value)) (update prev-composite-value idx dissoc token-type) + :else (assoc-in prev-composite-value token-type-at-index input-value))))) + + update-composite-backup-value + (mf/use-callback + (fn [prev-composite-value e] + (let [[idx token-type :as token-type-at-index] (obj/get e "tokenTypeAtIndex") + token-value (case token-type + :inset (obj/get e "tokenValue") + (dom/get-target-val e)) + valid? (case token-type + :inset (boolean? token-value) + (seq token-value))] + (if valid? + (assoc-in (or prev-composite-value []) token-type-at-index token-value) + ;; Remove empty values so they don't retrigger validation when switching tabs + (update prev-composite-value idx dissoc token-type)))))] + [:> composite-form* + (mf/spread-props props {:token token + :composite-tab shadow-value-inputs* + :reference-icon i/text-typography + :is-reference-fn cto/typography-composite-token-reference? + :title (tr "workspace.tokens.shadow-title") + :validate-token validate-shadow-token + :on-get-token-value on-get-token-value + :update-composite-backup-value update-composite-backup-value})])) + (mf/defc font-selector-wrapper* [{:keys [font input-ref on-select-font on-close-font-selector]}] (let [current-font* (mf/use-state (or font @@ -1158,8 +1478,9 @@ (case token-type' :color [:> color-form* props] :typography [:> typography-form* props] + :shadow [:> shadow-form* props] :font-family [:> font-family-form* props] :text-case [:> text-case-form* props] :text-decoration [:> text-decoration-form* props] :font-weight [:> font-weight-form* props] - [:> form* props]))) \ No newline at end of file + [:> form* props]))) 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 18c5ba0d57..7a706976ed 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 @@ -124,7 +124,7 @@ (mf/defc box-shadow-modal {::mf/register modal/components - ::mf/register-as :tokens/box-shadow} + ::mf/register-as :tokens/shadow} [properties] [:& token-update-create-modal properties]) 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 b30678fd4f..9ef62e7379 100644 --- a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs +++ b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs @@ -469,6 +469,44 @@ (t/is (= (:stroke-width (:applied-tokens rect-without-stroke')) (:name token-target'))) (t/is (= (get-in rect-without-stroke' [:strokes 0 :stroke-width]) 10)))))))))) +(t/deftest test-apply-shadow + (t/testing "applies shadow token and updates the shapes with shadow" + (t/async + done + (let [shadow-token {:name "shadow.sm" + :value [{:offsetX 10 + :offsetY 10 + :blur 10 + :spread 10 + :color "rgba(0,0,0,0.5)" + :inset false}] + :type :shadow} + file (-> (setup-file-with-tokens) + (update-in [:data :tokens-lib] + #(ctob/add-token % (cthi/id :set-a) + (ctob/make-token shadow-token)))) + store (ths/setup-store file) + rect-1 (cths/get-shape file :rect-1) + events [(dwta/apply-token {:shape-ids [(:id rect-1)] + :attributes #{:shadow} + :token (toht/get-token file "shadow.sm") + :on-update-shape dwta/update-shadow})]] + (tohs/run-store-async + store done events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + token-target' (toht/get-token file' "shadow.sm") + rect-1' (cths/get-shape file' :rect-1) + shadow (first (:shadow rect-1'))] + (t/testing "token got applied to rect with shadow and shape shadow got updated" + (t/is (= (:shadow (:applied-tokens rect-1')) (:name token-target'))) + (t/is (= (:offset-x shadow) 10)) + (t/is (= (:offset-y shadow) 10)) + (t/is (= (:blur shadow) 10)) + (t/is (= (:spread shadow) 10)) + (t/is (= (get-in shadow [:color :color]) "#000000")) + (t/is (= (get-in shadow [:color :opacity]) 0.5)))))))))) + (t/deftest test-apply-font-size (t/testing "applies font-size token and updates the text font-size" (t/async diff --git a/frontend/translations/en.po b/frontend/translations/en.po index c6c97f8ca7..7a87ec9aa1 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -1,3 +1,50 @@ +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:1135 +msgid "workspace.tokens.shadow-title" +msgstr "Shadows" + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:943 +msgid "workspace.tokens.shadow-x" +msgstr "X" + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:946 +msgid "workspace.tokens.shadow-y" +msgstr "Y" + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:949 +msgid "workspace.tokens.shadow-blur" +msgstr "Blur" + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:952 +msgid "workspace.tokens.shadow-spread" +msgstr "Spread" + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:955 +msgid "workspace.tokens.shadow-color" +msgstr "Color" + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:958 +msgid "workspace.tokens.shadow-inset" +msgstr "Inset" + +#: src/app/main/data/workspace/tokens/errors.cljs:109 +msgid "workspace.tokens.shadow-spread-range" +msgstr "Shadow spread must be greater than or equal to 0." + +#: src/app/main/data/workspace/tokens/errors.cljs:105 +msgid "workspace.tokens.shadow-blur-range" +msgstr "Shadow blur must be greater than or equal to 0." + +#: src/app/main/data/workspace/tokens/errors.cljs:101 +msgid "workspace.tokens.invalid-shadow-type-token-value" +msgstr "Invalid shadow type: only 'innerShadow' or 'dropShadow' are accepted" + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:1037 +msgid "workspace.tokens.shadow-add-shadow" +msgstr "Add Shadow" + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:1044 +msgid "workspace.tokens.shadow-remove-shadow" +msgstr "Remove Shadow" msgid "" msgstr "" "PO-Revision-Date: 2025-06-09 09:13+0000\n" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index a55026d4dd..3d99429851 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -1,3 +1,50 @@ +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:1135 +msgid "workspace.tokens.shadow-title" +msgstr "Sombras" + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:943 +msgid "workspace.tokens.shadow-x" +msgstr "X" + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:946 +msgid "workspace.tokens.shadow-y" +msgstr "Y" + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:949 +msgid "workspace.tokens.shadow-blur" +msgstr "Blur" + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:952 +msgid "workspace.tokens.shadow-spread" +msgstr "Spread" + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:955 +msgid "workspace.tokens.shadow-color" +msgstr "Color" + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:958 +msgid "workspace.tokens.shadow-inset" +msgstr "Interior" + +#: src/app/main/data/workspace/tokens/errors.cljs:109 +msgid "workspace.tokens.shadow-spread-range" +msgstr "La extensión (spread) de la sombra debe ser mayor o igual a 0." + +#: src/app/main/data/workspace/tokens/errors.cljs:105 +msgid "workspace.tokens.shadow-blur-range" +msgstr "El desenfoque (blur) de la sombra debe ser mayor o igual a 0." + +#: src/app/main/data/workspace/tokens/errors.cljs:101 +msgid "workspace.tokens.invalid-shadow-type-token-value" +msgstr "Tipo de sombra no válida: solo se aceptan 'innerShadow' o 'dropShadow'" + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:1037 +msgid "workspace.tokens.shadow-add-shadow" +msgstr "Añadir Sombra" + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:1044 +msgid "workspace.tokens.shadow-remove-shadow" +msgstr "Eliminar Sombra" msgid "" msgstr "" "PO-Revision-Date: 2025-10-07 16:35+0000\n"