Merge pull request #168 from tokens-studio/style-dictionary

References & Expressions in Tokens
This commit is contained in:
Florian Schrödl 2024-06-19 10:10:43 +02:00 committed by GitHub
commit 83515250da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1341 additions and 42 deletions

View file

@ -26,6 +26,7 @@
[app.main.ui.icons :as i]
[app.main.ui.workspace.tokens.core :as wtc]
[app.main.ui.workspace.tokens.editable-select :refer [editable-select]]
[app.main.ui.workspace.tokens.style-dictionary :as sd]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[clojure.set :refer [rename-keys union]]
@ -98,7 +99,8 @@
selection-parents-ref (mf/use-memo (mf/deps ids) #(refs/parents-by-ids ids))
selection-parents (mf/deref selection-parents-ref)
tokens (mf/deref refs/workspace-tokens)
tokens (-> (mf/deref refs/workspace-tokens)
(sd/use-resolved-tokens))
tokens-by-type (mf/use-memo (mf/deps tokens) #(wtc/group-tokens-by-type tokens))
border-radius-tokens (:border-radius tokens-by-type)

View file

@ -36,12 +36,12 @@
{::mf/wrap-props false}
[{:keys [label input-props auto-complete?]}]
(let [input-props (cond-> input-props
:always camel-keys
;; Disable auto-complete on form fields for proprietary password managers
;; https://github.com/orgs/tokens-studio/projects/69/views/11?pane=issue&itemId=63724204
(not auto-complete?) (assoc "data-1p-ignore" true
"data-lpignore" true
:auto-complete "off")
:always camel-keys)]
:auto-complete "off"))]
[:label {:class (stl/css :labeled-input)}
[:span {:class (stl/css :label)} label]
[:& :input input-props]]))

View file

@ -14,7 +14,9 @@
[app.main.data.workspace.changes :as dch]
[app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.transforms :as dwt]
[app.main.store :as st]))
[app.main.store :as st]
[app.main.ui.workspace.tokens.style-dictionary :as sd]
[promesa.core :as p]))
;; Helpers ---------------------------------------------------------------------
@ -32,10 +34,10 @@
[token shapes token-attributes]
(some #(token-applied? token % token-attributes) shapes))
(defn resolve-token-value [{:keys [value] :as token}]
(if-let [int-or-double (d/parse-double value)]
int-or-double
(throw (ex-info (str "Implement token value resolve for " value) token))))
(defn resolve-token-value [{:keys [value resolved-value] :as token}]
(or
resolved-value
(d/parse-double value)))
(defn maybe-resolve-token-value [{:keys [value] :as token}]
(when value (resolve-token-value token)))
@ -77,13 +79,15 @@
shape-ids (->> selected-shapes
(eduction
(remove #(tokens-applied? token % attributes))
(map :id)))
token-value (resolve-token-value token)]
(doseq [shape selected-shapes]
(st/emit! (on-apply {:token-id (:id token)
:shape-id (:id shape)
:attributes attributes}))
(on-update-shape token-value shape-ids attributes))))
(map :id)))]
(p/let [sd-tokens (sd/resolve-workspace-tokens+ {:debug? true})]
(let [resolved-token (get sd-tokens (:id token))
resolved-token-value (resolve-token-value resolved-token)]
(doseq [shape selected-shapes]
(st/emit! (on-apply {:token-id (:id token)
:shape-id (:id shape)
:attributes attributes}))
(on-update-shape resolved-token-value shape-ids attributes))))))
(defn update-shape-radius [value shape-ids]
(st/emit!

View file

@ -0,0 +1,580 @@
{
"core": {
"dimension": {
"scale": {
"value": "2",
"type": "dimension"
},
"xs": {
"value": "4",
"type": "dimension"
},
"sm": {
"value": "{dimension.xs} * {dimension.scale}",
"type": "dimension"
},
"md": {
"value": "{dimension.sm} * {dimension.scale}",
"type": "dimension"
},
"lg": {
"value": "{dimension.md} * {dimension.scale}",
"type": "dimension"
},
"xl": {
"value": "{dimension.lg} * {dimension.scale}",
"type": "dimension"
}
},
"spacing": {
"xs": {
"value": "{dimension.xs}",
"type": "spacing"
},
"sm": {
"value": "{dimension.sm}",
"type": "spacing"
},
"md": {
"value": "{dimension.md}",
"type": "spacing"
},
"lg": {
"value": "{dimension.lg}",
"type": "spacing"
},
"xl": {
"value": "{dimension.xl}",
"type": "spacing"
},
"multi-value": {
"value": "{dimension.sm} {dimension.xl}",
"type": "spacing",
"description": "You can have multiple values in a single spacing token. Read more on these: https://docs.tokens.studio/available-tokens/spacing-tokens#multi-value-spacing-tokens"
}
},
"borderRadius": {
"sm": {
"value": "4",
"type": "borderRadius"
},
"lg": {
"value": "8",
"type": "borderRadius"
},
"xl": {
"value": "16",
"type": "borderRadius"
},
"multi-value": {
"value": "{borderRadius.sm} {borderRadius.lg}",
"type": "borderRadius",
"description": "You can have multiple values in a single radius token. Read more on these: https://docs.tokens.studio/available-tokens/border-radius-tokens#single--multiple-values"
}
},
"colors": {
"black": {
"value": "#000000",
"type": "color"
},
"white": {
"value": "#ffffff",
"type": "color"
},
"gray": {
"100": {
"value": "#f7fafc",
"type": "color"
},
"200": {
"value": "#edf2f7",
"type": "color"
},
"300": {
"value": "#e2e8f0",
"type": "color"
},
"400": {
"value": "#cbd5e0",
"type": "color"
},
"500": {
"value": "#a0aec0",
"type": "color"
},
"600": {
"value": "#718096",
"type": "color"
},
"700": {
"value": "#4a5568",
"type": "color"
},
"800": {
"value": "#2d3748",
"type": "color"
},
"900": {
"value": "#1a202c",
"type": "color"
}
},
"red": {
"100": {
"value": "#fff5f5",
"type": "color"
},
"200": {
"value": "#fed7d7",
"type": "color"
},
"300": {
"value": "#feb2b2",
"type": "color"
},
"400": {
"value": "#fc8181",
"type": "color"
},
"500": {
"value": "#f56565",
"type": "color"
},
"600": {
"value": "#e53e3e",
"type": "color"
},
"700": {
"value": "#c53030",
"type": "color"
},
"800": {
"value": "#9b2c2c",
"type": "color"
},
"900": {
"value": "#742a2a",
"type": "color"
}
},
"orange": {
"100": {
"value": "#fffaf0",
"type": "color"
},
"200": {
"value": "#feebc8",
"type": "color"
},
"300": {
"value": "#fbd38d",
"type": "color"
},
"400": {
"value": "#f6ad55",
"type": "color"
},
"500": {
"value": "#ed8936",
"type": "color"
},
"600": {
"value": "#dd6b20",
"type": "color"
},
"700": {
"value": "#c05621",
"type": "color"
},
"800": {
"value": "#9c4221",
"type": "color"
},
"900": {
"value": "#7b341e",
"type": "color"
}
},
"yellow": {
"100": {
"value": "#fffff0",
"type": "color"
},
"200": {
"value": "#fefcbf",
"type": "color"
},
"300": {
"value": "#faf089",
"type": "color"
},
"400": {
"value": "#f6e05e",
"type": "color"
},
"500": {
"value": "#ecc94b",
"type": "color"
},
"600": {
"value": "#d69e2e",
"type": "color"
},
"700": {
"value": "#b7791f",
"type": "color"
},
"800": {
"value": "#975a16",
"type": "color"
},
"900": {
"value": "#744210",
"type": "color"
}
},
"green": {
"100": {
"value": "#f0fff4",
"type": "color"
},
"200": {
"value": "#c6f6d5",
"type": "color"
},
"300": {
"value": "#9ae6b4",
"type": "color"
},
"400": {
"value": "#68d391",
"type": "color"
},
"500": {
"value": "#48bb78",
"type": "color"
},
"600": {
"value": "#38a169",
"type": "color"
},
"700": {
"value": "#2f855a",
"type": "color"
},
"800": {
"value": "#276749",
"type": "color"
},
"900": {
"value": "#22543d",
"type": "color"
}
},
"teal": {
"100": {
"value": "#e6fffa",
"type": "color"
},
"200": {
"value": "#b2f5ea",
"type": "color"
},
"300": {
"value": "#81e6d9",
"type": "color"
},
"400": {
"value": "#4fd1c5",
"type": "color"
},
"500": {
"value": "#38b2ac",
"type": "color"
},
"600": {
"value": "#319795",
"type": "color"
},
"700": {
"value": "#2c7a7b",
"type": "color"
},
"800": {
"value": "#285e61",
"type": "color"
},
"900": {
"value": "#234e52",
"type": "color"
}
},
"blue": {
"100": {
"value": "#ebf8ff",
"type": "color"
},
"200": {
"value": "#bee3f8",
"type": "color"
},
"300": {
"value": "#90cdf4",
"type": "color"
},
"400": {
"value": "#63b3ed",
"type": "color"
},
"500": {
"value": "#4299e1",
"type": "color"
},
"600": {
"value": "#3182ce",
"type": "color"
},
"700": {
"value": "#2b6cb0",
"type": "color"
},
"800": {
"value": "#2c5282",
"type": "color"
},
"900": {
"value": "#2a4365",
"type": "color"
}
},
"indigo": {
"100": {
"value": "#ebf4ff",
"type": "color"
},
"200": {
"value": "#c3dafe",
"type": "color"
},
"300": {
"value": "#a3bffa",
"type": "color"
},
"400": {
"value": "#7f9cf5",
"type": "color"
},
"500": {
"value": "#667eea",
"type": "color"
},
"600": {
"value": "#5a67d8",
"type": "color"
},
"700": {
"value": "#4c51bf",
"type": "color"
},
"800": {
"value": "#434190",
"type": "color"
},
"900": {
"value": "#3c366b",
"type": "color"
}
},
"purple": {
"100": {
"value": "#faf5ff",
"type": "color"
},
"200": {
"value": "#e9d8fd",
"type": "color"
},
"300": {
"value": "#d6bcfa",
"type": "color"
},
"400": {
"value": "#b794f4",
"type": "color"
},
"500": {
"value": "#9f7aea",
"type": "color"
},
"600": {
"value": "#805ad5",
"type": "color"
},
"700": {
"value": "#6b46c1",
"type": "color"
},
"800": {
"value": "#553c9a",
"type": "color"
},
"900": {
"value": "#44337a",
"type": "color"
}
},
"pink": {
"100": {
"value": "#fff5f7",
"type": "color"
},
"200": {
"value": "#fed7e2",
"type": "color"
},
"300": {
"value": "#fbb6ce",
"type": "color"
},
"400": {
"value": "#f687b3",
"type": "color"
},
"500": {
"value": "#ed64a6",
"type": "color"
},
"600": {
"value": "#d53f8c",
"type": "color"
},
"700": {
"value": "#b83280",
"type": "color"
},
"800": {
"value": "#97266d",
"type": "color"
},
"900": {
"value": "#702459",
"type": "color"
}
}
},
"opacity": {
"low": {
"value": "10%",
"type": "opacity"
},
"md": {
"value": "50%",
"type": "opacity"
},
"high": {
"value": "90%",
"type": "opacity"
}
},
"fontFamilies": {
"heading": {
"value": "Inter",
"type": "fontFamilies"
},
"body": {
"value": "Roboto",
"type": "fontFamilies"
}
},
"lineHeights": {
"heading": {
"value": "110%",
"type": "lineHeights"
},
"body": {
"value": "140%",
"type": "lineHeights"
}
},
"letterSpacing": {
"default": {
"value": "0",
"type": "letterSpacing"
},
"increased": {
"value": "150%",
"type": "letterSpacing"
},
"decreased": {
"value": "-5%",
"type": "letterSpacing"
}
},
"paragraphSpacing": {
"h1": {
"value": "32",
"type": "paragraphSpacing"
},
"h2": {
"value": "26",
"type": "paragraphSpacing"
}
},
"fontWeights": {
"headingRegular": {
"value": "Regular",
"type": "fontWeights"
},
"headingBold": {
"value": "Bold",
"type": "fontWeights"
},
"bodyRegular": {
"value": "Regular",
"type": "fontWeights"
},
"bodyBold": {
"value": "Bold",
"type": "fontWeights"
}
},
"fontSizes": {
"h1": {
"value": "{fontSizes.h2} * 1.25",
"type": "fontSizes"
},
"h2": {
"value": "{fontSizes.h3} * 1.25",
"type": "fontSizes"
},
"h3": {
"value": "{fontSizes.h4} * 1.25",
"type": "fontSizes"
},
"h4": {
"value": "{fontSizes.h5} * 1.25",
"type": "fontSizes"
},
"h5": {
"value": "{fontSizes.h6} * 1.25",
"type": "fontSizes"
},
"h6": {
"value": "{fontSizes.body} * 1",
"type": "fontSizes"
},
"body": {
"value": "16",
"type": "fontSizes"
},
"sm": {
"value": "{fontSizes.body} * 0.85",
"type": "fontSizes"
},
"xs": {
"value": "{fontSizes.body} * 0.65",
"type": "fontSizes"
}
}
}
}

View file

@ -16,6 +16,7 @@
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.components.numeric-input :refer [numeric-input*]]
[app.main.ui.icons :as i]
[app.main.ui.workspace.tokens.core :as wtc]
[app.util.dom :as dom]
[app.util.globals :as globals]
[app.util.keyboard :as kbd]
@ -92,7 +93,9 @@
(cond
(= :separator item) [:li {:class (stl/css :separator)
:key (dm/str element-id "-" index)}]
:else (let [{:keys [value label selected?]} item
;; Remove items with missing references
(seq (:errors item)) nil
:else (let [{:keys [label selected? errors]} item
highlighted? (= highlighted index)]
[:li
{:key (str element-id "-" index)
@ -100,9 +103,10 @@
:is-selected selected?
:is-highlighted highlighted?)
:data-label label
:disabled (seq errors)
:on-click #(on-select item)}
[:span {:class (stl/css :label)} label]
[:span {:class (stl/css :value)} value]
[:span {:class (stl/css :value)} (wtc/resolve-token-value item)]
[:span {:class (stl/css :check-icon)} i/tick]])))]]]))
(mf/defc editable-select
@ -255,7 +259,7 @@
(when-let [{:keys [label value]} token]
[:div {:title (str label ": " value)
:class (stl/css :token-pill)}
value])
(wtc/resolve-token-value token)])
(cond
token [:& :input (merge input-props
{:value (or (:token-value state) "")

View file

@ -61,9 +61,8 @@
state (mf/use-state initial-fields)
on-update-state-field (fn [idx e]
(->> (dom/get-target-val e)
(assoc-in @state [idx :value])
(reset! state)))
(let [value (dom/get-target-val e)]
(swap! state assoc-in [idx :value] value)))
on-submit (fn [e]
(dom/prevent-default e)

View file

@ -15,9 +15,12 @@
[app.main.ui.icons :as i]
[app.main.ui.workspace.sidebar.assets.common :as cmm]
[app.main.ui.workspace.tokens.core :as wtc]
[app.main.ui.workspace.tokens.style-dictionary :as sd]
[app.util.dom :as dom]
[cuerdas.core :as str]
[okulary.core :as l]
[rumext.v2 :as mf]))
[rumext.v2 :as mf]
[shadow.resource]))
(def lens:token-type-open-status
(l/derived (l/in [:workspace-tokens :open-status]) st/state))
@ -25,16 +28,20 @@
(mf/defc token-pill
{::mf/wrap-props false}
[{:keys [on-click token highlighted? on-context-menu]}]
(let [{:keys [name value]} token
resolved-value (try
(wtc/resolve-token-value token)
(catch js/Error _ nil))]
[:div {:class (stl/css-case :token-pill true
:token-pill-highlighted highlighted?
:token-pill-invalid (not resolved-value))
:title (str (if resolved-value "Token value: " "Invalid token value: ") value)
:on-click on-click
:on-context-menu on-context-menu}
(let [{:keys [name value resolved-value errors]} token
errors? (seq errors)]
[:button {:class (stl/css-case :token-pill true
:token-pill-highlighted highlighted?
:token-pill-invalid errors?)
:title (cond
errors? (sd/humanize-errors token)
:else (->> [(str "Token: " name)
(str "Original value: " value)
(str "Resolved value: " resolved-value)]
(str/join "\n")))
:on-click on-click
:on-context-menu on-context-menu
:disabled errors?}
name]))
(mf/defc token-section-icon
@ -137,7 +144,8 @@
selected (mf/deref refs/selected-shapes)
selected-shapes (into [] (keep (d/getf objects)) selected)
tokens (mf/deref refs/workspace-tokens)
tokens (-> (mf/deref refs/workspace-tokens)
(sd/use-resolved-tokens))
token-groups (mf/with-memo [tokens]
(sorted-token-groups tokens))]
[:article

View file

@ -30,6 +30,7 @@
}
&.token-pill-invalid {
background-color: var(--button-secondary-background-color-rest);
color: var(--status-color-error-500);
opacity: 0.8;
}

View file

@ -0,0 +1,165 @@
(ns app.main.ui.workspace.tokens.style-dictionary
(:require
["@tokens-studio/sd-transforms" :as sd-transforms]
["style-dictionary$default" :as sd]
[app.common.data :as d]
[app.main.refs :as refs]
[cuerdas.core :as str]
[promesa.core :as p]
[rumext.v2 :as mf]
[shadow.resource]))
(def StyleDictionary
"The global StyleDictionary instance used as an external library for now,
as the package would need webpack to be bundled,
because shadow-cljs doesn't support some of the more modern bundler features."
(do
(sd-transforms/registerTransforms sd)
(.registerFormat sd #js {:name "custom/json"
:format (fn [res]
(.-tokens (.-dictionary res)))})
sd))
;; Functions -------------------------------------------------------------------
(defn tokens->style-dictionary+
"Resolves references and math expressions using StyleDictionary.
Returns a promise with the resolved dictionary."
[tokens {:keys [debug?]}]
(let [data (cond-> {:tokens tokens
:platforms {:json {:transformGroup "tokens-studio"
:files [{:format "custom/json"
:destination "fake-filename"}]}}
:log {:verbosity "silent"
:warnings "silent"
:errors {:brokenReferences "console"}}
:preprocessors ["tokens-studio"]}
debug? (update :log merge {:verbosity "verbose"
:warnings "warn"}))
js-data (clj->js data)]
(when debug?
(js/console.log "Input Data" js-data))
(sd. js-data)))
(defn resolve-sd-tokens+
"Resolves references and math expressions using StyleDictionary.
Returns a promise with the resolved dictionary."
[tokens & {:keys [debug?] :as config}]
(let [performance-start (js/window.performance.now)
sd (tokens->style-dictionary+ tokens config)]
(when debug?
(js/console.log "StyleDictionary" sd))
(-> sd
(.buildAllPlatforms "json")
(.catch js/console.error)
(.then (fn [^js resp]
(let [performance-end (js/window.performance.now)
duration-ms (- performance-end performance-start)
resolved-tokens (.-allTokens resp)]
(when debug?
(js/console.log "Time elapsed" duration-ms "ms")
(js/console.log "Resolved tokens" resolved-tokens))
resolved-tokens))))))
(defn humanize-errors [{:keys [errors value] :as _token}]
(->> (map (fn [err]
(case err
:style-dictionary/missing-reference (str "Could not resolve reference token with the name: " value)
nil))
errors)
(str/join "\n")))
(defn tokens-name-map [tokens]
(->> tokens
(map (fn [[_ x]] [(:name x) x]))
(into {})))
(defn resolve-tokens+
[tokens & {:keys [debug?] :as config}]
(p/let [sd-tokens (-> (tokens-name-map tokens)
(clj->js)
(resolve-sd-tokens+ config))]
(let [resolved-tokens (reduce
(fn [acc ^js cur]
(let [value (.-value cur)
resolved-value (d/parse-integer (.-value cur))
original-value (-> cur .-original .-value)
id (uuid (.-uuid (.-id cur)))
missing-reference? (and (not resolved-value)
(re-find #"\{" value)
(= value original-value))]
(cond-> (assoc-in acc [id :resolved-value] resolved-value)
missing-reference? (update-in [id :errors] (fnil conj #{}) :style-dictionary/missing-reference))))
tokens sd-tokens)]
(when debug?
(js/console.log "Resolved tokens" resolved-tokens))
resolved-tokens)))
(defn resolve-workspace-tokens+
[& {:keys [debug?] :as config}]
(when-let [workspace-tokens @refs/workspace-tokens]
(resolve-tokens+ workspace-tokens)))
;; Hooks -----------------------------------------------------------------------
(defonce !tokens-cache (atom nil))
(defn use-resolved-tokens
"The StyleDictionary process function is async, so we can't use resolved values directly.
This hook will return the unresolved tokens as state until they are processed,
then the state will be updated with the resolved tokens."
[tokens & {:keys [cache-atom]
:or {cache-atom !tokens-cache}}]
(let [tokens-state (mf/use-state (get @cache-atom tokens tokens))]
(mf/use-effect
(mf/deps tokens)
(fn []
(let [cached (get @cache-atom tokens)]
(cond
;; The tokens are already processing somewhere
(p/promise? cached) (p/then cached #(reset! tokens-state %))
;; Get the cached entry
(some? cached) (reset! tokens-state cached)
;; No cached entry, start processing
:else (let [promise+ (resolve-tokens+ tokens)]
(swap! cache-atom assoc tokens promise+)
(p/then promise+ (fn [resolved-tokens]
(swap! cache-atom assoc tokens resolved-tokens)
(reset! tokens-state resolved-tokens))))))))
@tokens-state))
(defn use-resolved-workspace-tokens
([] (use-resolved-tokens nil))
([options]
(-> (mf/deref refs/workspace-tokens)
(use-resolved-tokens options))))
;; Testing ---------------------------------------------------------------------
(defn tokens-studio-example []
(-> (shadow.resource/inline "./data/example-tokens-set.json")
(js/JSON.parse)
.-core))
(comment
(defonce !output (atom nil))
@!output
(-> (resolve-workspace-tokens+ {:debug? true})
(p/then #(reset! !output %)))
(->> @refs/workspace-tokens
(resolve-tokens+))
(->
(clj->js {"a" {:name "a" :value "5"}
"b" {:name "b" :value "{a} * 2"}})
(#(resolve-sd-tokens+ % {:debug? true})))
(-> (tokens-studio-example)
(resolve-sd-tokens+ {:debug? true}))
nil)