🐛 Update canvas and keep local sets when switching tokens source (#12442)

Setting a library as tokens source did not update shapes that use tokens,
because set-tokens-source never ran the token propagation that every other
token operation runs. Run it after the commit.

Going back to the local tokens, or unlinking the library that was the
source, left no local set active: the file has a single tokens status, and
switching to a library replaced it with the library's one. Keep the status
of the file's own tokens lib aside in a new optional :own-tokens-status
attribute while an external library is the source, and restore it when the
file is its own source again. Exporting without libraries and absorbing a
deleted source library restore it as well.

Closes #12430

AI-assisted-by: claude-opus-5-5
This commit is contained in:
Eva Marco 2026-10-26 15:23:05 +01:00 committed by GitHub
parent 6f185c624f
commit 86e01d56a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 314 additions and 30 deletions

View File

@ -683,7 +683,8 @@
"Make the file its own tokens source when its tokens source is not one
of the given library-ids, as happens when the library was deleted. The
tokens lib of the source is copied while the library still exists, even
if deleted; otherwise the tokens source and status are removed."
if deleted; otherwise the tokens source is removed and the file gets back
the tokens status kept aside for its own tokens lib, if any."
[cfg data library-ids]
(let [source-id (cfo/get-tokens-source data)]
(if (and (some? source-id)
@ -692,7 +693,7 @@
:include-deleted? true
:throw-if-not-exists? false)]
(cfo/absorb-tokens-lib data (:data library))
(dissoc data :tokens-source :tokens-status))
(cfo/detach-tokens-source data))
data)))
(defn embed-assets

View File

@ -407,6 +407,12 @@
[:theme-ids [:set ::sm/uuid]]
[:set-ids [:set ::sm/uuid]]]]
[:set-own-tokens-status
[:map {:title "SetOwnTokensStatus"}
[:type [:= :set-own-tokens-status]]
[:theme-ids [:maybe [:set ::sm/uuid]]]
[:set-ids [:maybe [:set ::sm/uuid]]]]]
[:rename-token-set-group
[:map {:title "RenameTokenSetGroup"}
[:type [:= :rename-token-set-group]]
@ -1067,6 +1073,13 @@
(-> (cfo/ensure-tokens-lib data)
(cfo/update-tokens-status ctos/set-tokens-status theme-ids set-ids)))
(defmethod process-change :set-own-tokens-status
[data {:keys [theme-ids set-ids]}]
(cfo/set-own-tokens-status data
(when (or (some? theme-ids) (some? set-ids))
(ctos/make-tokens-status :active-theme-ids theme-ids
:active-set-ids set-ids))))
(defmethod process-change :rename-token-set-group
[data {:keys [set-group-path set-group-fname]}]
(-> (cfo/ensure-tokens-lib data)

View File

@ -1077,6 +1077,24 @@
(update :undo-changes conj {:type :set-tokens-status :theme-ids prev-theme-ids :set-ids prev-set-ids})
(apply-changes-local)))))
(defn set-own-tokens-status
"Keep aside the tokens status of the file's own tokens-lib, or remove it
when `tokens-status` is nil."
[changes tokens-status]
(assert-library! changes)
(assert (or (ctos/tokens-status? tokens-status)
(nil? tokens-status)))
(let [library-data (::library-data (meta changes))
prev-tokens-status (cfo/get-own-tokens-status library-data)
->change (fn [tokens-status]
{:type :set-own-tokens-status
:theme-ids (some-> tokens-status ctos/get-active-theme-ids)
:set-ids (some-> tokens-status ctos/get-active-set-ids)})]
(-> changes
(update :redo-changes conj (->change tokens-status))
(update :undo-changes conj (->change prev-tokens-status))
(apply-changes-local))))
(defn rename-token-set-group
[changes set-group-path set-group-fname]
(let [undo-path (ctob/replace-last-path-name set-group-path set-group-fname)

View File

@ -549,6 +549,37 @@
[file-data f & args]
(d/update-when file-data :tokens-status #(apply f % args)))
(defn get-own-tokens-status
"The tokens status of the file's own tokens-lib, kept aside while the
tokens source is an external library."
[file-data]
(:own-tokens-status file-data))
(defn set-own-tokens-status
"Keep aside the tokens status of the file's own tokens-lib. Setting it to
nil removes the attribute."
[file-data tokens-status]
(assert (or (nil? tokens-status) (ctos/tokens-status? tokens-status))
"expected nil or valid tokens-status")
(if (nil? tokens-status)
(dissoc file-data :own-tokens-status)
(assoc file-data :own-tokens-status tokens-status)))
(defn detach-tokens-source
"Make the file its own tokens source again, restoring the tokens status
kept aside for its own tokens-lib. If none was kept, the tokens status
is removed."
[file-data]
(let [own-tokens-status (get-own-tokens-status file-data)]
(if (some? own-tokens-status)
(-> file-data
(set-tokens-source nil)
(set-own-tokens-status nil)
(assoc :tokens-status own-tokens-status))
(-> file-data
(set-tokens-source nil)
(dissoc :tokens-status)))))
(defn absorb-tokens-lib
"Copy the tokens-lib of the library into the file, replacing the
previous one if any, and unset the tokens-source, so the file becomes
@ -558,19 +589,26 @@
one of the library is copied along with the tokens-lib.
If the library has no tokens-lib, only the tokens-source is unset, and
the file keeps its own tokens-lib and tokens-status. If there is no
library-data, there is nothing to absorb and the file is returned
unchanged."
the file keeps its own tokens-lib, with the tokens status kept aside for
it if any. If there is no library-data, there is nothing to absorb and
the file is returned unchanged."
[file-data library-data]
(if (nil? library-data)
file-data
(let [tokens-lib (get-tokens-lib library-data)]
(if (nil? tokens-lib)
(set-tokens-source file-data nil)
(let [own-tokens-status (get-own-tokens-status file-data)]
(cond-> (-> file-data
(set-tokens-source nil)
(set-own-tokens-status nil))
(some? own-tokens-status)
(assoc :tokens-status own-tokens-status)))
(let [tokens-status (or (get-tokens-status file-data)
(get-tokens-status library-data))]
(cond-> (-> file-data
(set-tokens-source nil)
;; The kept status refers to the replaced tokens-lib
(set-own-tokens-status nil)
(assoc :tokens-lib tokens-lib))
(some? tokens-status)
(assoc :tokens-status tokens-status)))))))

View File

@ -16,36 +16,58 @@
;; Tokens source
(defn- own-tokens-status
"The current tokens status of `file-data`, keeping only the themes and sets
that exist in its own tokens lib."
[file-data]
(let [tokens-status (cfo/get-tokens-status file-data)
tokens-lib (cfo/get-tokens-lib file-data)]
"`tokens-status` keeping only the themes and sets that exist in the own
tokens lib of `file-data`."
[file-data tokens-status]
(let [tokens-lib (cfo/get-tokens-lib file-data)]
(if (and tokens-status tokens-lib)
(cfo/sync-tokens-status-with-lib tokens-status tokens-lib)
(ctos/make-tokens-status))))
(defn generate-set-tokens-source
"Create changes for setting the tokens source of a file to `library`,
copying the library's tokens status. If the library is the file itself,
the themes and sets that don't exist in its own tokens lib are deactivated.
If the library is nil, the tokens source is removed and the tokens status
is cleared."
copying the library's tokens status.
While an external library is the source, the tokens status of the file's
own tokens lib is kept aside, and it is restored when the file goes back
to being its own source (the library is the file itself, or nil). With
nothing kept aside, going back to the file itself deactivates the themes
and sets that don't exist in its own tokens lib, and a nil library clears
the tokens status."
[changes library]
(let [library-id (:id library)
file-data (pcb/get-library-data changes)
library-tokens-status (cond
(nil? library)
(ctos/make-tokens-status)
(let [file-data (pcb/get-library-data changes)
file-id (:id file-data)
library-id (:id library)
to-own? (or (nil? library) (= library-id file-id))
from-own? (cfo/effective-tokens-source? file-data file-id)
current-status (cfo/get-tokens-status file-data)
kept-status (cfo/get-own-tokens-status file-data)
(= library-id (:id file-data))
(own-tokens-status file-data)
tokens-status
(cond
(and to-own? (some? kept-status))
(own-tokens-status file-data kept-status)
:else
(-> library ctf/file-data cfo/get-tokens-status))]
(-> changes
(pcb/set-tokens-source library-id)
(pcb/set-tokens-status library-tokens-status))))
(nil? library)
(ctos/make-tokens-status)
to-own?
(own-tokens-status file-data current-status)
:else
(-> library ctf/file-data cfo/get-tokens-status))
kept-status'
(cond
to-own? nil
from-own? (own-tokens-status file-data current-status)
:else kept-status)]
(cond-> (-> changes
(pcb/set-tokens-source library-id)
(pcb/set-tokens-status tokens-status))
(not (identical? kept-status kept-status'))
(pcb/set-own-tokens-status kept-status'))))
;; Tokens lib

View File

@ -30,6 +30,10 @@
[file]
(-> file (ctf/file-data) (cfo/get-tokens-status)))
(defn get-own-tokens-status
[file]
(-> file (ctf/file-data) (cfo/get-own-tokens-status)))
(defn add-tokens-lib
"Ensure the file has a tokens-lib and a tokens-status in its data, creating empty ones if not"
[file]

View File

@ -90,7 +90,8 @@
[:plugin-data {:optional true} schema:plugin-data]
[:tokens-source {:optional true} ::sm/uuid] ;; The tokens-lib may be in this file or in an external library
[:tokens-lib {:optional true} ctob/schema:tokens-lib]
[:tokens-status {:optional true} ctos/schema:tokens-status]])
[:tokens-status {:optional true} ctos/schema:tokens-status]
[:own-tokens-status {:optional true} ctos/schema:tokens-status]]) ;; Kept aside while the tokens source is an external library
(def schema:file-metadata
[:map {:title "Metadata"}
@ -1242,7 +1243,7 @@
(cond-> (update-in file [:data :pages-index] detach-pages)
(not (cfo/effective-tokens-source? (:data file) file-id))
(update :data dissoc :tokens-source :tokens-status))))
(update :data cfo/detach-tokens-source))))
;; Base font size

View File

@ -115,6 +115,54 @@
(t/is (= (ctos/get-active-set-ids file-tokens-status)
(ctos/get-active-set-ids undo-tokens-status)))))
(t/testing "the file's own tokens status is kept aside while a library is the source, and restored when going back"
(let [own-set-id (uuid/next)
lib-set-id (uuid/next)
lib2-set-id (uuid/next)
library-file (tht/sample-file-with-tokens
:file-id :library
:lib-fn #(ctob/add-set % (ctob/make-token-set :id lib-set-id :name "library set"))
:status-fn #(ctos/set-tokens-status % #{} #{lib-set-id}))
library2-file (tht/sample-file-with-tokens
:file-id :library2
:lib-fn #(ctob/add-set % (ctob/make-token-set :id lib2-set-id :name "library2 set"))
:status-fn #(ctos/set-tokens-status % #{} #{lib2-set-id}))
file (tht/sample-file-with-tokens
:file-id :file1
:lib-fn #(ctob/add-set % (ctob/make-token-set :id own-set-id :name "own set"))
:status-fn #(ctos/set-tokens-status % #{} #{own-set-id}))
set-source (fn [file library]
(let [changes (-> (pcb/empty-changes)
(pcb/with-library-data (ctf/file-data file))
(clt/generate-set-tokens-source library))]
[(thf/apply-changes file changes) changes]))
active-sets #(some-> % ctos/get-active-set-ids)
[to-library to-library-changes] (set-source file library-file)
[to-library2 _] (set-source to-library library2-file)
[back-to-own back-changes] (set-source to-library2 to-library2)
undo-to-library (thf/apply-undo-changes to-library to-library-changes)
undo-back-to-own (thf/apply-undo-changes back-to-own back-changes)]
;; A library as source: its status is used and the own status is kept aside
(t/is (= #{lib-set-id} (active-sets (tht/get-tokens-status to-library))))
(t/is (= #{own-set-id} (active-sets (tht/get-own-tokens-status to-library))))
;; Another library as source: the own status is still kept aside
(t/is (= #{lib2-set-id} (active-sets (tht/get-tokens-status to-library2))))
(t/is (= #{own-set-id} (active-sets (tht/get-own-tokens-status to-library2))))
;; Back to the file itself: the own status is restored, nothing is kept aside
(t/is (nil? (tht/get-tokens-source back-to-own)))
(t/is (= #{own-set-id} (active-sets (tht/get-tokens-status back-to-own))))
(t/is (nil? (tht/get-own-tokens-status back-to-own)))
;; Undo works both ways
(t/is (= #{own-set-id} (active-sets (tht/get-tokens-status undo-to-library))))
(t/is (nil? (tht/get-own-tokens-status undo-to-library)))
(t/is (= #{lib2-set-id} (active-sets (tht/get-tokens-status undo-back-to-own))))
(t/is (= #{own-set-id} (active-sets (tht/get-own-tokens-status undo-back-to-own))))))
(t/testing "if the library is nil, the tokens source is cleared and the tokens status is reset to empty"
(let [set-id (uuid/next)
file (tht/sample-file-with-tokens

View File

@ -510,7 +510,8 @@
(pcb/with-library-data data)
(clo/generate-set-tokens-source library)
(pcb/set-tokens-source library-id))]
(rx/of (dch/commit-changes changes))))))
(rx/of (dch/commit-changes changes)
(dwtp/propagate-workspace-tokens))))))
(defn delete-token-set
[id]

View File

@ -97,6 +97,7 @@
[frontend-tests.tokens.logic.token-actions-test]
[frontend-tests.tokens.logic.token-data-test]
[frontend-tests.tokens.logic.token-remapping-test]
[frontend-tests.tokens.logic.tokens-source-test]
[frontend-tests.tokens.logic.tokens-status-test]
[frontend-tests.tokens.style-dictionary-test]
[frontend-tests.tokens.token-errors-test]
@ -246,6 +247,7 @@
'frontend-tests.tokens.style-dictionary-test
'frontend-tests.tokens.token-errors-test
'frontend-tests.tokens.token-path-test
'frontend-tests.tokens.logic.tokens-source-test
'frontend-tests.tokens.logic.tokens-status-test
'frontend-tests.tokens.workspace-tokens-remap-test
'frontend-tests.ui.check-updates-test

View File

@ -0,0 +1,136 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.tokens.logic.tokens-source-test
(:require
[app.common.test-helpers.compositions :as ctho]
[app.common.test-helpers.ids-map :as cthi]
[app.common.test-helpers.shapes :as cths]
[app.common.test-helpers.tokens :as ctht]
[app.common.types.tokens-lib :as ctob]
[app.common.types.tokens-status :as ctos]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.tokens.library-edit :as dwtl]
[app.main.repo :as rp]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.async :as async]
[frontend-tests.helpers.mock :as mock]
[frontend-tests.helpers.pages :as thp]
[frontend-tests.helpers.state :as ths]
[frontend-tests.helpers.wasm :as thw]
[potok.v2.core :as ptk]))
(t/use-fixtures :each
{:before (fn []
(thp/reset-idmap!)
(thw/setup-wasm-mocks!))
:after thw/teardown-wasm-mocks!})
(defn- setup-library
"A library whose active set defines `red` as green."
[]
(ctht/sample-file-with-tokens
:file-id :library
:lib-fn #(-> %
(ctob/add-set (ctob/make-token-set :id (cthi/new-id! :library-set)
:name "library-set"))
(ctob/add-token (cthi/id :library-set)
(ctob/make-token :id (cthi/new-id! :library-red)
:name "red"
:type :color
:value "#00ff00")))
:status-fn #(ctos/set-tokens-status % #{} #{(cthi/id :library-set)})))
(defn- setup-file
"A file whose own active set defines `red` as dark red, applied to the
fill of a rectangle."
[]
(-> (ctht/sample-file-with-tokens
:file-id :local
:lib-fn #(-> %
(ctob/add-set (ctob/make-token-set :id (cthi/new-id! :local-set)
:name "local-set"))
(ctob/add-token (cthi/id :local-set)
(ctob/make-token :id (cthi/new-id! :local-red)
:name "red"
:type :color
:value "rgb(123, 34, 34)")))
:status-fn #(ctos/set-tokens-status % #{} #{(cthi/id :local-set)}))
(ctho/add-rect :rect1)
(ctht/apply-token-to-shape :rect1 "red" [:fill] [:fill] "#7b2222")))
(defn- fill-color
[store]
(let [file (ths/get-file-from-state @store)]
(-> (cths/get-shape file :rect1) :fills first :fill-color)))
(defn- active-set-ids
[store]
(-> (ths/get-file-from-state @store) ctht/get-tokens-status ctos/get-active-set-ids))
(defn- tokens-source
[store]
(-> (ths/get-file-from-state @store) ctht/get-tokens-source))
(defn- ^:async use-library-tokens
"Set `library` as tokens source and wait for the canvas to use it."
[store library]
(ptk/emit! store (dwtl/set-tokens-source (:id library)))
(await (async/wait-for #(= "#00ff00" (fill-color store))
"the fill uses the library value of `red`"
{:max-ticks 200})))
(t/deftest ^:async switching-tokens-source-updates-the-canvas
(let [library (setup-library)
file (setup-file)
store (ths/setup-store file {:libraries [library]})]
(t/is (= "#7b2222" (fill-color store)))
(ptk/emit! store (dwtl/set-tokens-source (:id library)))
(await (async/wait-for #(= "#00ff00" (fill-color store))
"the fill uses the library value of `red`"
{:max-ticks 200}))
(t/is (= "#00ff00" (fill-color store))
"Setting the library as tokens source resolves `red` from the library")
(ptk/emit! store (dwtl/set-tokens-source (:id file)))
(await (async/settle))
(t/is (contains? (active-set-ids store) (cthi/id :local-set))
"Going back to the local tokens keeps the local set active")
(await (async/wait-for #(= "#7b2222" (fill-color store))
"the fill uses the local value of `red` again"
{:max-ticks 200}))
(t/is (= "#7b2222" (fill-color store))
"Going back to the local tokens resolves `red` from the file again")
(rx/dispose! store)))
(t/deftest ^:async unlinking-the-tokens-source-library-restores-the-local-tokens
(let [library (setup-library)
file (setup-file)
store (ths/setup-store file {:libraries [library]})]
(await (use-library-tokens store library))
(await
(mock/with-mocks*
{rp/cmd! (mock/stub (fn [_ _] (rx/observe-on :async (rx/of []))))}
;; The same events the libraries dialog emits on unlink
(ptk/emit! store
(dwl/unlink-file-from-library (:id file) (:id library))
(dwtl/set-tokens-source (:id file)))
(await (async/wait-for #(= "#7b2222" (fill-color store))
"the fill uses the local value of `red` again"
{:max-ticks 200}))))
(t/is (nil? (tokens-source store)))
(t/is (contains? (active-set-ids store) (cthi/id :local-set))
"Unlinking the library keeps the local set active")
(t/is (= "#7b2222" (fill-color store)))
(rx/dispose! store)))