🐛 Fix token propagation on canvas color (#11950)

* 🐛 Update canvas background when its color token changes

Token propagation only walked the shapes of each page, so a canvas
background linked to a color token kept its old value after switching
the active set or editing the token. Propagation now also updates the
background of every page whose `:background-token` resolves to a new
color, inside the same undo transaction.

AI-assisted-by: claude-opus-5-5

* 🐛 Select the dragged token set by id instead of by path

Starting a drag on an unselected token set stored its path as
`:selected-token-set-id`. The sidebar then crashed on the
`(uuid? force-set-id)` assert of `get-tokens-in-active-sets-force`.
This could happen when toggling a set checkbox with a slight mouse
move.

AI-assisted-by: claude-opus-5-5

* 🎉 Add playwright test
This commit is contained in:
Eva Marco 2026-09-28 18:15:43 +02:00 committed by GitHub
parent 534b1a6702
commit f311c7ab05
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 129 additions and 3 deletions

View File

@ -66,6 +66,74 @@ test.describe("Tokens: Apply token", () => {
await expect(canvasSection.getByText("black")).toBeVisible();
});
test("Canvas background follows its color token when the active set changes", async ({
page,
}) => {
const { tokenThemesSetsSidebar, tokensUpdateCreateModal } =
await setupTokensFileRender(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
// Creates a set holding a "canvas-bg" color token with `value`.
const createSetWithCanvasToken = async (setName, value) => {
await createSet(tokenThemesSetsSidebar, setName);
const setButton = tokenThemesSetsSidebar.getByRole("button", {
name: setName,
});
await setButton.click();
await tokensTabPanel
.getByRole("button", { name: "Add Token: Color" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
await tokensUpdateCreateModal.getByLabel("Name").fill("canvas-bg");
await tokensUpdateCreateModal.getByLabel("Value").fill(value);
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await expect(submitButton).toBeEnabled();
await submitButton.click();
await expect(tokensUpdateCreateModal).not.toBeVisible();
return setButton;
};
const firstSet = await createSetWithCanvasToken("first-bg", "#ff0000");
const secondSet = await createSetWithCanvasToken("second-bg", "#00ff00");
await firstSet.getByRole("checkbox").click();
// No shape is selected, so the right sidebar shows the canvas options.
const canvasSection = page.getByRole("region", {
name: "Canvas background section",
});
await canvasSection.getByRole("button").first().click();
const colorPicker = page.getByTestId("colorpicker");
await expect(colorPicker).toBeVisible();
await colorPicker.getByTestId("opt-token-color").click();
await colorPicker.getByRole("button", { name: "canvas-bg" }).click();
await page.keyboard.press("Escape");
await expect(colorPicker).not.toBeVisible();
const canvas = page.getByTestId("canvas-wasm-shapes");
await expect(canvas).toHaveCSS("background-color", "rgb(255, 0, 0)");
await firstSet.getByRole("checkbox").click();
await secondSet.getByRole("checkbox").click();
await expect(canvas).toHaveCSS("background-color", "rgb(0, 255, 0)");
await expect(canvasSection.getByText("canvas-bg")).toBeVisible();
await firstSet.getByRole("checkbox").click();
await expect(canvas).toHaveCSS("background-color", "rgb(0, 255, 0)");
await secondSet.getByRole("checkbox").click();
await expect(canvas).toHaveCSS("background-color", "rgb(255, 0, 0)");
await expect(canvasSection.getByText("canvas-bg")).toBeVisible();
});
test("User applies border-radius token to a shape from sidebar", async ({
page,
}) => {

View File

@ -7,12 +7,14 @@
(ns app.main.data.workspace.tokens.propagation
(:require
[app.common.data :as d]
[app.common.files.changes-builder :as pcb]
[app.common.files.helpers :as cfh]
[app.common.files.tokens :as cfo]
[app.common.logging :as l]
[app.common.time :as ct]
[app.common.types.token :as ctt]
[app.config :as cf]
[app.main.data.changes :as dch]
[app.main.data.helpers :as dsh]
[app.main.data.style-dictionary :as sd]
[app.main.data.tokenscript :as ts]
@ -131,8 +133,27 @@
update-infos)))
shapes-update-info))
(defn- update-canvas-background
"Set the background of `page-id` to the resolved value of its linked
color token, when that value differs from the current background."
[page-id resolved-tokens]
(ptk/reify ::update-canvas-background
ptk/WatchEvent
(watch [it state _]
(let [page (dsh/lookup-page state page-id)
token-name (:background-token page)
color (some-> (get-in resolved-tokens [token-name :resolved-value])
(dwta/value->color)
(:color))]
(when (and (some? color) (not= color (:background page)))
(rx/of (dch/commit-changes
(-> (pcb/empty-changes it)
(pcb/with-page page)
(pcb/mod-page {:background color})))))))))
(defn propagate-tokens
"Propagate tokens values to all shapes where they are applied"
"Propagate tokens values to all shapes and page backgrounds where they
are applied"
[state resolved-tokens]
(let [file-id (get state :current-file-id)
current-page-id (get state :current-page-id)
@ -168,6 +189,9 @@
(when (seq observable) (apply rx/merge observable))
(when (seq normal) (rx/concat-all (rx/of normal)))
(when (:background-token page)
(rx/of (update-canvas-background page-id resolved-tokens)))
(->> (rx/from frame-ids)
(rx/mapcat (fn [frame-id]
(rx/of (dwt/clear-thumbnail file-id page-id frame-id "frame")

View File

@ -260,10 +260,10 @@
on-drag
(mf/use-fn
(mf/deps path)
(mf/deps id is-selected on-select)
(fn [_]
(when-not is-selected
(on-select path))))
(on-select id))))
on-drop
(mf/use-fn

View File

@ -308,6 +308,40 @@
(t/is (= (get-in rect-2' [:strokes 0 :stroke-color]) "#ff0000"))
(t/is (= (get-in rect-2' [:strokes 0 :stroke-opacity]) 0.5))))))))))
(t/deftest test-switch-set-updates-canvas-color
(t/testing "switching the active set updates a canvas background linked to a color token"
(t/async
done
(let [file (ctht/sample-file-with-tokens
:lib-fn #(-> %
(ctob/add-set (ctob/make-token-set :id (cthi/new-id! :set-a)
:name "Set A"))
(ctob/add-set (ctob/make-token-set :id (cthi/new-id! :set-b)
:name "Set B"))
(ctob/add-token (cthi/id :set-a)
(ctob/make-token {:name "canvas.bg"
:value "#ff0000"
:type :color}))
(ctob/add-token (cthi/id :set-b)
(ctob/make-token {:name "canvas.bg"
:value "#00ff00"
:type :color})))
;; Both sets active: "Set B" comes last, so its value wins.
:status-fn #(ctos/set-tokens-status % #{} #{(cthi/id :set-a)
(cthi/id :set-b)}))
page-id (cthf/current-page-id file)
file (update-in file [:data :pages-index page-id]
assoc :background "#00ff00" :background-token "canvas.bg")
store (ths/setup-store file)
events [(dwtl/set-enabled-token-set (cthi/id :set-b) false)]]
(tohs/run-store-async
store done events
(fn [new-state]
(let [file' (ths/get-file-from-state new-state)
page' (get-in file' [:data :pages-index page-id])]
(t/is (= "#ff0000" (:background page')))
(t/is (= "canvas.bg" (:background-token page'))))))))))
(t/deftest test-apply-dimensions
(t/testing "applies dimensions token and updates the shapes width and height"
(t/async