🎉 Add tokens to the canvas (#11923)

* ✨ Show color tokens toggle on canvas background picker

* 🐛 Apply token click on canvas background with no shape selected

* ✨ Apply and persist color tokens on canvas background

* 🌐 Translate canvas background section label
This commit is contained in:
Eva Marco 2026-09-28 08:53:42 +02:00 committed by GitHub
parent c44484a1e5
commit 7e3f779d8c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 140 additions and 6 deletions

View File

@ -264,6 +264,7 @@
;; All props are optional, background can be nil because is the ;; All props are optional, background can be nil because is the
;; way to remove already set background ;; way to remove already set background
[:background {:optional true} [:maybe ctc/schema:hex-color]] [:background {:optional true} [:maybe ctc/schema:hex-color]]
[:background-token {:optional true} [:maybe cto/schema:token-name]]
[:name {:optional true} :string] [:name {:optional true} :string]
;; Pixel grid display controls — nil removes the per-page override ;; Pixel grid display controls — nil removes the per-page override
;; and falls back to the default hardcoded grid color/opacity. ;; and falls back to the default hardcoded grid color/opacity.
@ -878,6 +879,7 @@
(fn [page] (fn [page]
(let [name (get params :name) (let [name (get params :name)
bg (get params :background :not-found) bg (get params :background :not-found)
bg-token (get params :background-token :not-found)
grid-color (get params :pixel-grid-color :not-found) grid-color (get params :pixel-grid-color :not-found)
grid-op (get params :pixel-grid-opacity :not-found)] grid-op (get params :pixel-grid-opacity :not-found)]
(cond-> page (cond-> page
@ -890,6 +892,12 @@
(nil? bg) (nil? bg)
(dissoc :background) (dissoc :background)
(string? bg-token)
(assoc :background-token bg-token)
(and (not= bg-token :not-found) (nil? bg-token))
(dissoc :background-token)
(string? grid-color) (string? grid-color)
(assoc :pixel-grid-color grid-color) (assoc :pixel-grid-color grid-color)

View File

@ -242,7 +242,7 @@
(let [page (::page (meta changes))] (let [page (::page (meta changes))]
(mod-page changes page options))) (mod-page changes page options)))
([changes page {:keys [name background pixel-grid-color pixel-grid-opacity]}] ([changes page {:keys [name background pixel-grid-color pixel-grid-opacity] :as options}]
(let [change {:type :mod-page :id (:id page)} (let [change {:type :mod-page :id (:id page)}
redo (cond-> change redo (cond-> change
(some? name) (some? name)
@ -251,6 +251,9 @@
(some? background) (some? background)
(assoc :background background) (assoc :background background)
(contains? options :background-token)
(assoc :background-token (:background-token options))
(some? pixel-grid-color) (some? pixel-grid-color)
(assoc :pixel-grid-color pixel-grid-color) (assoc :pixel-grid-color pixel-grid-color)
@ -264,6 +267,9 @@
(some? background) (some? background)
(assoc :background (:background page)) (assoc :background (:background page))
(contains? options :background-token)
(assoc :background-token (:background-token page))
(some? pixel-grid-color) (some? pixel-grid-color)
(assoc :pixel-grid-color (:pixel-grid-color page)) (assoc :pixel-grid-color (:pixel-grid-color page))

View File

@ -14,6 +14,7 @@
[app.common.types.grid :as ctg] [app.common.types.grid :as ctg]
[app.common.types.plugins :as ctpg] [app.common.types.plugins :as ctpg]
[app.common.types.shape :as cts] [app.common.types.shape :as cts]
[app.common.types.token :as cto]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[cuerdas.core :as str])) [cuerdas.core :as str]))
@ -60,6 +61,8 @@
[:guides {:optional true} schema:guides] [:guides {:optional true} schema:guides]
[:plugin-data {:optional true} ctpg/schema:plugin-data] [:plugin-data {:optional true} ctpg/schema:plugin-data]
[:background {:optional true} ctc/schema:hex-color] [:background {:optional true} ctc/schema:hex-color]
;; Name of the color token applied to the background, if any.
[:background-token {:optional true} [:maybe cto/schema:token-name]]
;; Per-page pixel grid color. Falls back to a hardcoded default when ;; Per-page pixel grid color. Falls back to a hardcoded default when
;; unset so existing files render identically to before. ;; unset so existing files render identically to before.
[:pixel-grid-color {:optional true} ctc/schema:hex-color] [:pixel-grid-color {:optional true} ctc/schema:hex-color]

View File

@ -605,6 +605,7 @@
:dimensions [:dimensions] :dimensions [:dimensions]
:fill [:color] :fill [:color]
:stroke-color [:color] :stroke-color [:color]
:canvas [:color]
:typography [:typography] :typography [:typography]
:number [:number] :number [:number]
:sizing [:sizing :dimensions] :sizing [:sizing :dimensions]

View File

@ -331,6 +331,32 @@
res (ch/process-changes data [chg])] res (ch/process-changes data [chg])]
(t/is (= res data)))))) (t/is (= res data))))))
(t/deftest mod-page
(let [file-id (uuid/custom 2 2)
page-id (uuid/custom 1 1)
data (make-file-data file-id page-id)]
(t/testing "sets background and background-token together"
(let [chg {:type :mod-page
:id page-id
:background "#ffffff"
:background-token "brand.bg"}
res (ch/process-changes data [chg])
page (get-in res [:pages-index page-id])]
(t/is (= "#ffffff" (:background page)))
(t/is (= "brand.bg" (:background-token page)))))
(t/testing "clears background-token while leaving background untouched"
(let [data' (-> data
(assoc-in [:pages-index page-id :background] "#ffffff")
(assoc-in [:pages-index page-id :background-token] "brand.bg"))
chg {:type :mod-page
:id page-id
:background-token nil}
res (ch/process-changes data' [chg])
page (get-in res [:pages-index page-id])]
(t/is (= "#ffffff" (:background page)))
(t/is (not (contains? page :background-token)))))))
(t/deftest del-obj (t/deftest del-obj
(let [file-id (uuid/custom 2 2) (let [file-id (uuid/custom 2 2)

View File

@ -40,6 +40,32 @@ test.describe("Tokens: Apply token", () => {
).toBeVisible(); ).toBeVisible();
}); });
test("User applies color token to the canvas background", async ({
page,
}) => {
await setupTokensFileRender(page);
// No shape is selected, so the right sidebar shows the page/canvas
// options by default.
const canvasSection = page.getByRole("region", {
name: "Canvas background section",
});
await expect(canvasSection).toBeVisible();
// Open the color picker popover from the canvas background swatch.
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: "black" }).click();
// The applied token badge replaces the plain color swatch/input in
// the canvas background row.
await expect(canvasSection.getByText("black")).toBeVisible();
});
test("User applies border-radius token to a shape from sidebar", async ({ test("User applies border-radius token to a shape from sidebar", async ({
page, page,
}) => { }) => {

View File

@ -1372,7 +1372,41 @@
page (dsh/lookup-page state page-id) page (dsh/lookup-page state page-id)
changes (-> (pcb/empty-changes it) changes (-> (pcb/empty-changes it)
(pcb/with-page page) (pcb/with-page page)
(pcb/mod-page {:background (:color color)}))] (pcb/mod-page {:background (:color color)
:background-token nil}))]
(rx/of (dch/commit-changes changes)))))))
(defn apply-canvas-color-token
"Sets the canvas background to the resolved value of `token` and
remembers it as the applied token, so the background badge shows it."
([color token-name]
(apply-canvas-color-token nil color token-name))
([page-id color token-name]
(ptk/reify ::apply-canvas-color-token
ptk/WatchEvent
(watch [it state _]
(let [page-id (or page-id (:current-page-id state))
page (dsh/lookup-page state page-id)
changes (-> (pcb/empty-changes it)
(pcb/with-page page)
(pcb/mod-page {:background color
:background-token token-name}))]
(rx/of (dch/commit-changes changes)))))))
(defn detach-canvas-color-token
"Removes the applied-token link from the canvas background, keeping
the current background color as a plain value."
([]
(detach-canvas-color-token nil))
([page-id]
(ptk/reify ::detach-canvas-color-token
ptk/WatchEvent
(watch [it state _]
(let [page-id (or page-id (:current-page-id state))
page (dsh/lookup-page state page-id)
changes (-> (pcb/empty-changes it)
(pcb/with-page page)
(pcb/mod-page {:background-token nil}))]
(rx/of (dch/commit-changes changes))))))) (rx/of (dch/commit-changes changes)))))))
(defn change-pixel-grid-color (defn change-pixel-grid-color

View File

@ -376,7 +376,8 @@
:icon i/hsva :icon i/hsva
:id "hsva"}]) :id "hsva"}])
show-tokens? (contains? #{:fill :stroke-color :color-selection} color-origin)] show-tokens? (or (contains? #{:fill :stroke-color :color-selection} color-origin)
(and (= color-origin :canvas) (seq combined-tokens)))]
;; Initialize colorpicker state ;; Initialize colorpicker state
(mf/with-effect [] (mf/with-effect []

View File

@ -139,10 +139,10 @@
on-token-pill-click on-token-pill-click
(mf/use-fn (mf/use-fn
(mf/deps selected-shapes) (mf/deps selected-shapes color-origin)
(fn [event token] (fn [event token]
(dom/stop-propagation event) (dom/stop-propagation event)
(when (seq selected-shapes) (when (or (= color-origin :canvas) (seq selected-shapes))
(on-token-change event token)))) (on-token-change event token))))
create-token-on-set create-token-on-set

View File

@ -11,6 +11,7 @@
[app.common.data :as d] [app.common.data :as d]
[app.common.types.color :as clr] [app.common.types.color :as clr]
[app.main.data.workspace :as dw] [app.main.data.workspace :as dw]
[app.main.data.workspace.tokens.application :as dwta]
[app.main.data.workspace.undo :as dwu] [app.main.data.workspace.undo :as dwu]
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.store :as st] [app.main.store :as st]
@ -24,6 +25,10 @@
(-> (l/key :background) (-> (l/key :background)
(l/derived refs/workspace-page))) (l/derived refs/workspace-page)))
(def ^:private ref:background-token
(-> (l/key :background-token)
(l/derived refs/workspace-page)))
(def ^:private ref:pixel-grid-color (def ^:private ref:pixel-grid-color
(-> (l/key :pixel-grid-color) (-> (l/key :pixel-grid-color)
(l/derived refs/workspace-page))) (l/derived refs/workspace-page)))
@ -36,6 +41,7 @@
{::mf/wrap [mf/memo]} {::mf/wrap [mf/memo]}
[] []
(let [background (mf/deref ref:background-color) (let [background (mf/deref ref:background-color)
background-token (mf/deref ref:background-token)
grid-color (mf/deref ref:pixel-grid-color) grid-color (mf/deref ref:pixel-grid-color)
grid-alpha (mf/deref ref:pixel-grid-opacity) grid-alpha (mf/deref ref:pixel-grid-opacity)
@ -43,6 +49,17 @@
on-open (mf/use-fn #(st/emit! (dwu/start-undo-transaction :options))) on-open (mf/use-fn #(st/emit! (dwu/start-undo-transaction :options)))
on-close (mf/use-fn #(st/emit! (dwu/commit-undo-transaction :options))) on-close (mf/use-fn #(st/emit! (dwu/commit-undo-transaction :options)))
on-token-change
(mf/use-fn
(fn [_event token]
(when-let [color (dwta/value->color (:resolved-value token))]
(st/emit! (dw/apply-canvas-color-token (:color color) (:name token))))))
on-detach-token
(mf/use-fn
(fn [_token-name]
(st/emit! (dw/detach-canvas-color-token))))
on-grid-change on-grid-change
(mf/use-fn #(st/emit! (dw/change-pixel-grid-color %))) (mf/use-fn #(st/emit! (dw/change-pixel-grid-color %)))
@ -54,7 +71,8 @@
{:color (d/nilv grid-color clr/default-pixel-grid-color) {:color (d/nilv grid-color clr/default-pixel-grid-color)
:opacity (d/nilv grid-alpha clr/default-pixel-grid-opacity)})] :opacity (d/nilv grid-alpha clr/default-pixel-grid-opacity)})]
[:* [:div {:class (stl/css :element-set)} [:* [:section {:class (stl/css :element-set)
:aria-label (tr "workspace.options.canvas-background-section")}
[:div {:class (stl/css :element-title)} [:div {:class (stl/css :element-title)}
[:> title-bar* {:collapsable false [:> title-bar* {:collapsable false
:title (tr "workspace.options.canvas-background") :title (tr "workspace.options.canvas-background")
@ -68,6 +86,9 @@
:title (tr "workspace.options.canvas-background") :title (tr "workspace.options.canvas-background")
:color color :color color
:on-change on-change :on-change on-change
:on-token-change on-token-change
:on-detach-token on-detach-token
:applied-token background-token
:origin :canvas :origin :canvas
:on-open on-open :on-open on-open
:on-close on-close}]]] :on-close on-close}]]]

View File

@ -6955,6 +6955,10 @@ msgstr "Cancel"
msgid "workspace.options.canvas-background" msgid "workspace.options.canvas-background"
msgstr "Canvas background" msgstr "Canvas background"
#: src/app/main/ui/workspace/sidebar/options/page.cljs:75
msgid "workspace.options.canvas-background-section"
msgstr "Canvas background section"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:722 #: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:722
msgid "workspace.options.clip-content" msgid "workspace.options.clip-content"
msgstr "Clip content" msgstr "Clip content"

View File

@ -7021,6 +7021,10 @@ msgstr "Cancelar"
msgid "workspace.options.canvas-background" msgid "workspace.options.canvas-background"
msgstr "Color de fondo" msgstr "Color de fondo"
#: src/app/main/ui/workspace/sidebar/options/page.cljs:75
msgid "workspace.options.canvas-background-section"
msgstr "Sección de fondo de lienzo"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:722 #: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:722
msgid "workspace.options.clip-content" msgid "workspace.options.clip-content"
msgstr "Truncar contenido" msgstr "Truncar contenido"