🎉 Add composite typography token

This commit is contained in:
Florian Schroedl
2025-08-18 13:43:20 +02:00
committed by Andrés Moya
parent 68a95cf0d0
commit 9106617436
14 changed files with 1480 additions and 233 deletions

View File

@@ -48,7 +48,8 @@
:stroke-width "borderWidth"
:text-case "textCase"
:text-decoration "textDecoration"
:font-weight "fontWeights"})
:font-weight "fontWeights"
:typography "typography"})
(def dtcg-token-type->token-type
(set/map-invert token-type->dtcg-token-type))
@@ -180,6 +181,12 @@
(def font-weight-keys (schema-keys schema:font-weight))
(def ^:private schema:typography
[:map
[:typography {:optional true} token-name-ref]])
(def typography-token-keys (schema-keys schema:typography))
(def ^:private schema:text-decoration
[:map
[:text-decoration {:optional true} token-name-ref]])
@@ -192,7 +199,8 @@
font-weight-keys
text-case-keys
text-decoration-keys
font-weight-keys))
font-weight-keys
typography-token-keys))
;; TODO: Created to extract the font-size feature from the typography feature flag.
;; Delete this once the typography feature flag is removed.
@@ -215,6 +223,7 @@
axis-keys
rotation-keys
typography-keys
typography-token-keys
number-keys))
(def ^:private schema:tokens
@@ -260,12 +269,13 @@
changed-sub-attr
#{:m1 :m2 :m3 :m4})
(font-size-keys shape-attr) #{shape-attr}
(letter-spacing-keys shape-attr) #{shape-attr}
(font-family-keys shape-attr) #{shape-attr}
(text-case-keys shape-attr) #{shape-attr}
(text-decoration-keys shape-attr) #{shape-attr}
(font-weight-keys shape-attr) #{shape-attr}
(font-size-keys shape-attr) #{shape-attr :typography}
(letter-spacing-keys shape-attr) #{shape-attr :typography}
(font-family-keys shape-attr) #{shape-attr :typography}
(text-case-keys shape-attr) #{shape-attr :typography}
(text-decoration-keys shape-attr) #{shape-attr :typography}
(font-weight-keys shape-attr) #{shape-attr :typography}
(border-radius-keys shape-attr) #{shape-attr}
(sizing-keys shape-attr) #{shape-attr}
(opacity-keys shape-attr) #{shape-attr}

View File

@@ -0,0 +1,264 @@
{
"~:id": "~u021b87d4-813e-8066-8006-b36537098786",
"~:file-id": "~uef9b2783-804c-8017-8006-ae6f7eab52ad",
"~:created-at": "~m1756113434655",
"~:data": {
"~:objects": {
"~u00000000-0000-0000-0000-000000000000": {
"~#shape": {
"~:y": 0,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:name": "Root Frame",
"~:width": 0.01,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 0.0,
"~:y": 0.0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.01
}
},
{
"~#point": {
"~:x": 0.0,
"~:y": 0.01
}
}
],
"~:r2": 0,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:r3": 0,
"~:r1": 0,
"~:id": "~u00000000-0000-0000-0000-000000000000",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 0,
"~:proportion": 1.0,
"~:r4": 0,
"~:selrect": {
"~#rect": {
"~:x": 0,
"~:y": 0,
"~:width": 0.01,
"~:height": 0.01,
"~:x1": 0,
"~:y1": 0,
"~:x2": 0.01,
"~:y2": 0.01
}
},
"~:fills": [
{
"~:fill-color": "#FFFFFF",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 0.01,
"~:flip-y": null,
"~:shapes": ["~u97915939-ccca-808f-8006-aeb8eee2aa47"]
}
},
"~u97915939-ccca-808f-8006-aeb8eee2aa47": {
"~#shape": {
"~:y": 301,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:grow-type": "~:auto-width",
"~:content": {
"~:type": "root",
"~:children": [
{
"~:type": "paragraph-set",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:text-transform": "unset",
"~:text-align": "left",
"~:font-id": "gfont-open-sans",
"~:font-size": "40",
"~:font-weight": "400",
"~:text-direction": "ltr",
"~:font-variant-id": "regular",
"~:weight": "400",
"~:text-decoration": "none",
"~:letter-spacing": "2",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "Open Sans",
"~:text": "Some Text"
}
],
"~:text-transform": "unset",
"~:text-align": "left",
"~:font-id": "gfont-open-sans",
"~:key": "f7ij3",
"~:font-size": "40",
"~:font-weight": "400",
"~:text-direction": "ltr",
"~:type": "paragraph",
"~:font-variant-id": "regular",
"~:weight": "400",
"~:text-decoration": "none",
"~:letter-spacing": "2",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "Open Sans"
}
]
}
]
},
"~:hide-in-viewer": false,
"~:name": "Some Text",
"~:width": 214,
"~:type": "~:text",
"~:points": [
{
"~#point": {
"~:x": 376,
"~:y": 301
}
},
{
"~#point": {
"~:x": 590,
"~:y": 301
}
},
{
"~#point": {
"~:x": 590,
"~:y": 349
}
},
{
"~#point": {
"~:x": 376,
"~:y": 349
}
}
],
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:opacity": 1,
"~:id": "~u97915939-ccca-808f-8006-aeb8eee2aa47",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:applied-tokens": {},
"~:position-data": [
{
"~#rect": {
"~:y": 352.33333444595337,
"~:font-style": "normal",
"~:text-transform": "none",
"~:font-size": "40px",
"~:font-weight": "400",
"~:y1": -3.3333334922790527,
"~:width": 213.5729217529297,
"~:text-decoration": "none solid rgb(0, 0, 0)",
"~:letter-spacing": "2px",
"~:x": 376,
"~:x1": 0,
"~:y2": 51.33333444595337,
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:x2": 213.5729217529297,
"~:direction": "ltr",
"~:font-family": "\"Open Sans\"",
"~:height": 54.66666793823242,
"~:text": "Some Text"
}
}
],
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:x": 376,
"~:selrect": {
"~#rect": {
"~:x": 376,
"~:y": 301,
"~:width": 214,
"~:height": 48,
"~:x1": 376,
"~:y1": 301,
"~:x2": 590,
"~:y2": 349
}
},
"~:flip-x": null,
"~:height": 48,
"~:flip-y": null
}
}
},
"~:id": "~uef9b2783-804c-8017-8006-ae6f7eab52ae",
"~:name": "Page 1"
}
}

View File

@@ -0,0 +1,400 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"plugins/runtime",
"design-tokens/v1",
"layout/grid",
"styles/v2",
"fdata/pointer-map",
"fdata/objects-map",
"components/v2",
"fdata/shape-data-type"
]
},
"~:team-id": "~ub34726bd-fd32-8122-8004-b1a4bef38917",
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "Typography",
"~:revn": 133,
"~:modified-at": "~m1756113434658",
"~:vern": 0,
"~:id": "~uef9b2783-804c-8017-8006-ae6f7eab52ad",
"~:is-shared": false,
"~:migrations": {
"~#ordered-set": []
},
"~:version": 67,
"~:project-id": "~u0df61468-6cbf-8067-8005-6b453ce996d0",
"~:created-at": "~m1755780585133",
"~:data": {
"~:pages": ["~uef9b2783-804c-8017-8006-ae6f7eab52ae"],
"~:pages-index": {
"~uef9b2783-804c-8017-8006-ae6f7eab52ae": {
"~#penpot/pointer": [
"~u021b87d4-813e-8066-8006-b36537098786",
{
"~:created-at": "~m1756113434662"
}
]
}
},
"~:id": "~uef9b2783-804c-8017-8006-ae6f7eab52ad",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
},
"~:tokens-lib": {
"~#penpot/tokens-lib": {
"~:sets": {
"~#ordered-map": [
[
"S-Typography",
{
"~#penpot/token-set": {
"~:id": "~u7239ff52-c1d9-8014-8006-ae7ad49690e3",
"~:name": "Typography",
"~:description": "",
"~:modified-at": "~m1755800261695",
"~:tokens": {
"~#ordered-map": [
[
"42dotSans",
{
"~#penpot/token": {
"~:id": "~u7239ff52-c1d9-8014-8006-ae7ac05000f0",
"~:name": "42dotSans",
"~:type": "~:font-family",
"~:value": ["Aboreto"],
"~:description": "",
"~:modified-at": "~m1755798229771"
}
}
],
[
"Full",
{
"~#penpot/token": {
"~:id": "~u7239ff52-c1d9-8014-8006-ae7ad49690e3",
"~:name": "Full",
"~:type": "~:typography",
"~:value": {
"~:font-family": ["42dot Sans"],
"~:font-size": "100",
"~:font-weight": "300",
"~:letter-spacing": "2",
"~:text-case": "uppercase",
"~:text-decoration": "underline"
},
"~:description": "",
"~:modified-at": "~m1755800261695"
}
}
],
[
"FontSizeOnly",
{
"~#penpot/token": {
"~:id": "~u97915939-ccca-808f-8006-aeb2b3f4dea3",
"~:name": "FontSizeOnly",
"~:type": "~:typography",
"~:value": {
"~:font-size": "10"
},
"~:description": "",
"~:modified-at": "~m1755798203347"
}
}
],
[
"Empty",
{
"~#penpot/token": {
"~:id": "~u97915939-ccca-808f-8006-aeb2b795ca5b",
"~:name": "Empty",
"~:type": "~:typography",
"~:value": {},
"~:description": "",
"~:modified-at": "~m1755798207063"
}
}
],
[
"WithAtomicReference",
{
"~#penpot/token": {
"~:id": "~u97915939-ccca-808f-8006-aeb2c2049024",
"~:name": "WithAtomicReference",
"~:type": "~:typography",
"~:value": {
"~:font-size": "{Large}",
"~:font-family": ["{42dotSans}"],
"~:font-weight": "{Bold}",
"~:text-decoration": "{striked}",
"~:text-case": "{uppercase}"
},
"~:description": "",
"~:modified-at": "~m1755800252452"
}
}
],
[
"Bold",
{
"~#penpot/token": {
"~:id": "~u97915939-ccca-808f-8006-aeb2d58dc828",
"~:name": "Bold",
"~:type": "~:font-weight",
"~:value": "Bold",
"~:description": "",
"~:modified-at": "~m1755798237751"
}
}
],
[
"Large",
{
"~#penpot/token": {
"~:id": "~u97915939-ccca-808f-8006-aeb2dfd57494",
"~:name": "Large",
"~:type": "~:font-size",
"~:value": "40",
"~:description": "",
"~:modified-at": "~m1755798248277"
}
}
],
[
"Small",
{
"~#penpot/token": {
"~:id": "~u97915939-ccca-808f-8006-aeb2ebc1d2e7",
"~:name": "Small",
"~:type": "~:font-size",
"~:value": "{Large} / 2",
"~:description": "",
"~:modified-at": "~m1755798260488"
}
}
],
[
"uppercase",
{
"~#penpot/token": {
"~:id": "~u97915939-ccca-808f-8006-aeb2f759c415",
"~:name": "uppercase",
"~:type": "~:text-case",
"~:value": "uppercase",
"~:description": "",
"~:modified-at": "~m1755798272360"
}
}
],
[
"striked",
{
"~#penpot/token": {
"~:id": "~u97915939-ccca-808f-8006-aeb305c7a666",
"~:name": "striked",
"~:type": "~:text-decoration",
"~:value": "strike-through",
"~:description": "",
"~:modified-at": "~m1755798287134"
}
}
],
[
"wide",
{
"~#penpot/token": {
"~:id": "~u97915939-ccca-808f-8006-aeb39796c280",
"~:name": "wide",
"~:type": "~:letter-spacing",
"~:value": "20",
"~:description": "",
"~:modified-at": "~m1755798436443"
}
}
],
[
"none",
{
"~#penpot/token": {
"~:id": "~u97915939-ccca-808f-8006-aeb857f7ff94",
"~:name": "none",
"~:type": "~:text-decoration",
"~:value": "none",
"~:description": "",
"~:modified-at": "~m1755799682015"
}
}
]
]
}
}
}
],
[
"S-Other",
{
"~#penpot/token-set": {
"~:id": "~u97915939-ccca-808f-8006-aeb3c391e0f4",
"~:name": "Other",
"~:description": "",
"~:modified-at": "~m1755798626309",
"~:tokens": {
"~#ordered-map": [
[
"red",
{
"~#penpot/token": {
"~:id": "~u97915939-ccca-808f-8006-aeb3f86a2d7e",
"~:name": "red",
"~:type": "~:color",
"~:value": "#AA0022",
"~:description": "",
"~:modified-at": "~m1755798535592"
}
}
],
[
"rounded",
{
"~#penpot/token": {
"~:id": "~u97915939-ccca-808f-8006-aeb412ca40b0",
"~:name": "rounded",
"~:type": "~:border-radius",
"~:value": "20px",
"~:description": "",
"~:modified-at": "~m1755798562602"
}
}
],
[
"dimensions",
{
"~#penpot/token": {
"~:id": "~u97915939-ccca-808f-8006-aeb41bd0336c",
"~:name": "dimensions",
"~:type": "~:dimensions",
"~:value": "20",
"~:description": "",
"~:modified-at": "~m1755798571840"
}
}
],
[
"number",
{
"~#penpot/token": {
"~:id": "~u97915939-ccca-808f-8006-aeb4244ec6e6",
"~:name": "number",
"~:type": "~:number",
"~:value": "30",
"~:description": "",
"~:modified-at": "~m1755798580539"
}
}
],
[
"transparent",
{
"~#penpot/token": {
"~:id": "~u97915939-ccca-808f-8006-aeb4300eff0d",
"~:name": "transparent",
"~:type": "~:opacity",
"~:value": "0.5",
"~:description": "",
"~:modified-at": "~m1755798592571"
}
}
],
[
"45deg",
{
"~#penpot/token": {
"~:id": "~u97915939-ccca-808f-8006-aeb43d30e201",
"~:name": "45deg",
"~:type": "~:rotation",
"~:value": "45",
"~:description": "",
"~:modified-at": "~m1755798606019"
}
}
],
[
"spacing",
{
"~#penpot/token": {
"~:id": "~u97915939-ccca-808f-8006-aeb44783b70f",
"~:name": "spacing",
"~:type": "~:spacing",
"~:value": "20",
"~:description": "",
"~:modified-at": "~m1755798616590"
}
}
],
[
"sizing",
{
"~#penpot/token": {
"~:id": "~u97915939-ccca-808f-8006-aeb44e0e6903",
"~:name": "sizing",
"~:type": "~:sizing",
"~:value": "20",
"~:description": "",
"~:modified-at": "~m1755798623289"
}
}
]
]
}
}
}
]
]
},
"~:themes": {
"~#ordered-map": [
[
"",
{
"~#ordered-map": [
[
"__PENPOT__HIDDEN__TOKEN__THEME__",
{
"~#penpot/token-theme": {
"~:id": "~u00000000-0000-0000-0000-000000000000",
"~:name": "__PENPOT__HIDDEN__TOKEN__THEME__",
"~:group": "",
"~:description": "",
"~:is-source": false,
"~:external-id": "",
"~:modified-at": "~m1755798544595",
"~:sets": {
"~#set": ["Typography", "Other"]
}
}
}
]
]
}
]
]
},
"~:active-themes": {
"~#set": ["/__PENPOT__HIDDEN__TOKEN__THEME__"]
}
}
}
}
}

View File

@@ -35,18 +35,25 @@ const setupEmptyTokensFile = async (page) => {
};
};
const setupTokensFile = async (page) => {
const setupTokensFile = async (page, options = {}) => {
const {
file = "workspace/get-file-tokens.json",
fileFragment = "workspace/get-file-fragment-tokens.json",
flags = [],
} = options;
const workspacePage = new WorkspacePage(page);
if (flags.length) {
await workspacePage.mockConfigFlags(flags);
}
await workspacePage.setupEmptyFile();
await workspacePage.mockRPC(
"get-team?id=*",
"workspace/get-team-tokens.json",
);
await workspacePage.mockRPC(/get\-file\?/, "workspace/get-file-tokens.json");
await workspacePage.mockRPC(
/get\-file\-fragment\?/,
"workspace/get-file-fragment-tokens.json",
);
await workspacePage.mockRPC(/get\-file\?/, file);
await workspacePage.mockRPC(/get\-file\-fragment\?/, fileFragment);
await workspacePage.mockRPC(
"update-file?id=*",
"workspace/update-file-create-rect.json",
@@ -73,6 +80,15 @@ const setupTokensFile = async (page) => {
};
};
const setupTypographyTokensFile = async (page, options = {}) => {
return setupTokensFile(page, {
file: "workspace/get-file-typography-tokens.json",
fileFragment: "workspace/get-file-fragment-typography-tokens.json",
flags: ["enable-token-typography-types"],
...options,
});
};
test.describe("Tokens: Tokens Tab", () => {
test("Clicking tokens tab button opens tokens sidebar tab", async ({
page,
@@ -531,9 +547,7 @@ test.describe("Tokens: Sets Tab", () => {
const assertEmptySetsList = async (el) => {
const buttons = await el.getByRole("button").allTextContents();
const filteredButtons = buttons.filter(
(text) => text === "Create one.",
);
const filteredButtons = buttons.filter((text) => text === "Create one.");
await expect(filteredButtons.length).toEqual(2); // We assume there are no themes, so we have two "Create one" buttons.
};
@@ -860,5 +874,74 @@ test.describe("Tokens: Themes modal", () => {
});
await expect(inputColor).toHaveValue("000000");
});
test("User applies typography token to a text shape", async ({ page }) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTypographyTokensFile(page);
await page.getByRole("tab", { name: "Layers" }).click();
await workspacePage.layers
.getByTestId("layer-row")
.filter({ hasText: "Some Text" })
.click();
const tokensTabButton = page.getByRole("tab", { name: "Tokens" });
await tokensTabButton.click();
await tokensSidebar
.getByRole("button")
.filter({ hasText: "Typography" })
.click();
await tokensSidebar.getByRole("button", { name: "Full" }).click();
const fontSizeInput = workspacePage.rightSidebar.getByRole("textbox", {
name: "Font Size",
});
await expect(fontSizeInput).toBeVisible();
await expect(fontSizeInput).toHaveValue("100");
});
test("User edits typography token and all fields are valid", async ({
page,
}) => {
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupTypographyTokensFile(page);
await tokensSidebar
.getByRole("button")
.filter({ hasText: "Typography" })
.click();
// Open edit modal for "Full" typography token
const token = tokensSidebar.getByRole("button", { name: "Full" });
await token.click({ button: "right" });
await page.getByText("Edit token").click();
// Modal opens
await expect(tokensUpdateCreateModal).toBeVisible();
const saveButton = tokensUpdateCreateModal.getByRole("button", {
name: /save/i,
});
// Invalidate incorrect values for font size
const fontSizeField = tokensUpdateCreateModal.getByLabel(/Font Size/i);
await fontSizeField.fill("invalid");
await expect(
tokensUpdateCreateModal.getByText(/Invalid token value:/),
).toBeVisible();
await expect(saveButton).toBeDisabled();
// Allow empty fields
await fontSizeField.fill("");
await expect(saveButton).toBeEnabled();
await saveButton.click();
// Modal should close, token should be visible (with new name) in sidebar
await expect(tokensUpdateCreateModal).not.toBeVisible();
});
});
});

View File

@@ -31,14 +31,21 @@
Setup transforms from tokens-studio used to parse and resolved token values."
(do
(sd-transforms/register sd)
(.registerTransformGroup sd #js {:name "penpot"
:transforms
;; Rebuild sd-transforms without "ts/typography/compose/shorthand" (we need to keep a typography map)
(.concat (sd-transforms/getTransforms)
#js ["ts/color/css/hexrgba"
"ts/color/modifiers"
"color/css"])})
(.registerFormat sd #js {:name "custom/json"
:format (fn [^js res]
(.-tokens (.-dictionary res)))})
(.. res -dictionary -tokens))})
sd))
(def default-config
{:platforms {:json
{:transformGroup "tokens-studio"
{:transformGroup "penpot"
;; Required: The StyleDictionary API is focused on files even when working in the browser
:files [{:format "custom/json" :destination "penpot"}]}}
:preprocessors ["tokens-studio"]
@@ -64,7 +71,6 @@
(and (string? s)
(re-matches #"^-?\d+(\.\d+)?(px|rem)$" s)))
;; TODO: After mergin "dimension-tokens" revisit this function to check if it's still
(defn- parse-sd-token-number-value
"Parses `value` of a number `sd-token` into a map like `{:value 1 :unit \"px\"}`.
If the `value` is not parseable and/or has missing references returns a map with `:errors`."
@@ -113,9 +119,9 @@
"Parses `value` of a dimensions `sd-token` into a map like `{:value 1 :unit \"px\"}`.
If the `value` is not parseable and/or has missing references returns a map with `:errors`.
If the `value` is parseable but is out of range returns a map with `warnings`."
[value has-references?]
(let [parsed-value (cft/parse-token-value value)
[value]
(let [missing-references? (seq (ctob/find-token-value-references value))
parsed-value (cft/parse-token-value value)
out-of-scope (not (<= 0 (:value parsed-value) 1))
references (seq (ctob/find-token-value-references value))]
(cond (and parsed-value (not out-of-scope))
@@ -125,10 +131,10 @@
{:errors [(wte/error-with-value :error.style-dictionary/missing-reference references)]
:references references}
(and (not has-references?) out-of-scope)
(and (not missing-references?) out-of-scope)
{:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value-opacity value)]}
(and has-references? out-of-scope parsed-value)
(and missing-references? out-of-scope parsed-value)
(assoc parsed-value :warnings [(wtw/warning-with-value :warning.style-dictionary/invalid-referenced-token-value-opacity value)])
:else {:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value value)]})))
@@ -137,8 +143,9 @@
"Parses `value` of a dimensions `sd-token` into a map like `{:value 1 :unit \"px\"}`.
If the `value` is not parseable and/or has missing references returns a map with `:errors`.
If the `value` is parseable but is out of range returns a map with `warnings`."
[value has-references?]
(let [parsed-value (cft/parse-token-value value)
[value]
(let [missing-references? (seq (ctob/find-token-value-references value))
parsed-value (cft/parse-token-value value)
out-of-scope (< (:value parsed-value) 0)
references (seq (ctob/find-token-value-references value))]
(cond
@@ -149,10 +156,10 @@
{:errors [(wte/error-with-value :error.style-dictionary/missing-reference references)]
:references references}
(and (not has-references?) out-of-scope)
(and (not missing-references?) out-of-scope)
{:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value-stroke-width value)]}
(and has-references? out-of-scope parsed-value)
(and missing-references? out-of-scope parsed-value)
(assoc parsed-value :warnings [(wtw/warning-with-value :warning.style-dictionary/invalid-referenced-token-value-stroke-width value)])
:else {:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value value)]})))
@@ -218,6 +225,55 @@
:else
{:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value-font-weight value)]})))
(defn- parse-sd-token-font-family-value
[value]
(let [missing-references (seq (some ctob/find-token-value-references value))]
(cond
missing-references
{:errors [(wte/error-with-value :error.style-dictionary/missing-reference missing-references)]
:references missing-references}
:else
{:value (-> (js->clj value) (flatten))})))
(defn parse-atomic-typography-value [token-type token-value]
(case token-type
:font-size (parse-sd-token-general-value token-value)
:font-family (parse-sd-token-font-family-value token-value)
:font-weight (parse-sd-token-font-weight-value token-value)
:letter-spacing (parse-sd-token-letter-spacing-value token-value)
:text-case (parse-sd-token-text-case-value token-value)
:text-decoration (parse-sd-token-text-decoration-value token-value)
nil))
(defn- parse-composite-typography-value
[value]
(let [missing-references
(when (string? value)
(seq (ctob/find-token-value-references value)))]
(cond
missing-references
{:errors [(wte/error-with-value :error.style-dictionary/missing-reference missing-references)]
:references missing-references}
(string? value)
{:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value-typography value)]}
:else
(let [converted (js->clj value :keywordize-keys true)
valid-typography (reduce
(fn [acc [k v]]
(let [{:keys [errors value]} (parse-atomic-typography-value k v)]
(if (seq errors)
(update acc :errors concat (map #(assoc % :typography-key k) errors))
(assoc-in acc [:value k] (or value v)))))
{:value {}}
converted)]
valid-typography))))
(defn collect-typography-errors [token]
(group-by :typography-key (: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
@@ -254,18 +310,15 @@
(fn [acc ^js sd-token]
(let [origin-token (get-origin-token sd-token)
value (.-value sd-token)
has-references? (str/includes? (:value origin-token) "{")
parsed-token-value (case (:type origin-token)
:font-family {:value (-> (js->clj value) (flatten))}
parsed-token-value (or
(parse-atomic-typography-value (:type origin-token) value)
(case (:type origin-token)
:typography (parse-composite-typography-value value)
:color (parse-sd-token-color-value value)
:opacity (parse-sd-token-opacity-value value has-references?)
:stroke-width (parse-sd-token-stroke-width-value value has-references?)
:text-case (parse-sd-token-text-case-value value)
:letter-spacing (parse-sd-token-letter-spacing-value value)
:text-decoration (parse-sd-token-text-decoration-value value)
:font-weight (parse-sd-token-font-weight-value value)
:opacity (parse-sd-token-opacity-value value)
:stroke-width (parse-sd-token-stroke-width-value value)
:number (parse-sd-token-number-value value)
(parse-sd-token-general-value value))
(parse-sd-token-general-value value)))
output-token (cond (:errors parsed-token-value)
(merge origin-token parsed-token-value)

View File

@@ -369,6 +369,33 @@
:timeout 7000}))]
(generate-font-weight-text-shape-update font-variant shape-ids page-id on-mismatch)))))
(defn- apply-functions-map
"Apply map of functions `fs` to a map of values `vs` using `args`.
The keys for both must match to be applied with an non-nil value in `vs`.
Returns a vector of the resulting values.
E.g.: `(apply-functions {:a + :b -} {:a 1 :b nil :c 10} [1 1]) => [3]`"
[fs vs args]
(map (fn [[k f]]
(when-let [v (get vs k)]
(apply f v args)))
fs))
(defn update-typography
([value shape-ids attributes] (update-typography value shape-ids attributes nil))
([value shape-ids attributes page-id]
(when (map? value)
(rx/merge
(apply-functions-map
{:font-size update-font-size
:font-family update-font-family
:font-weight update-font-weight
:letter-spacing update-letter-spacing
:text-case update-text-case
:text-decoration update-text-decoration}
value
[shape-ids attributes page-id])))))
;; Events to apply / unapply tokens to shapes ------------------------------------------------------------
(defn apply-token
@@ -383,6 +410,12 @@
(watch [_ state _]
;; We do not allow to apply tokens while text editor is open.
(when (empty? (get state :workspace-editor-state))
(let [attributes-to-remove
;; Remove atomic typography tokens when applying composite and vice-verca
(cond
(ctt/typography-token-keys (:type token)) (set/union attributes-to-remove ctt/typography-keys)
(ctt/typography-keys (:type token)) (set/union attributes-to-remove ctt/typography-token-keys)
:else attributes-to-remove)]
(when-let [tokens (some-> (dsh/lookup-file-data state)
(get :tokens-lib)
(ctob/get-tokens-in-active-sets))]
@@ -405,6 +438,7 @@
resolved-value (get-in resolved-tokens [(cft/token-identifier token) :resolved-value])
tokenized-attributes (cft/attributes-map attributes token)
type (:type token)]
(rx/concat
(rx/of
(st/emit! (ev/event {::ev/name "apply-tokens"
:type type
@@ -415,10 +449,14 @@
attributes-to-remove
(update :applied-tokens #(apply (partial dissoc %) attributes-to-remove))
:always
(update :applied-tokens merge tokenized-attributes))))
(update :applied-tokens merge tokenized-attributes)))))
(when on-update-shape
(on-update-shape resolved-value shape-ids attributes))
(dwu/commit-undo-transaction undo-id)))))))))))
(let [res (on-update-shape resolved-value shape-ids attributes)]
;; Composed updates return observables and need to be executed differently
(if (rx/observable? res)
res
(rx/of res))))
(rx/of (dwu/commit-undo-transaction undo-id)))))))))))))
(defn apply-spacing-token
"Handles edge-case for spacing token when applying token via toggle button.
@@ -554,6 +592,14 @@
:fields [{:label "Font Weight"
:key :font-weight}]}}
:typography
{:title "Typography"
:attributes ctt/typography-token-keys
:on-update-shape update-typography
:modal {:key :tokens/typography
:fields [{:label "Typography"
:key :typography}]}}
:text-decoration
{:title "Text Decoration"
:attributes ctt/text-decoration-keys

View File

@@ -39,6 +39,7 @@
#{:text-case} dwta/update-text-case
#{:text-decoration} dwta/update-text-decoration
#{:font-weight} dwta/update-font-weight
#{:typography} dwta/update-typography
#{:x :y} dwta/update-shape-position
#{:p1 :p2 :p3 :p4} dwta/update-layout-padding
#{:m1 :m2 :m3 :m4} dwta/update-layout-item-margin
@@ -161,7 +162,10 @@
(collect-shapes-update-info resolved-tokens (:objects page))
actions
(actionize-shapes-update-info page-id attrs)]
(actionize-shapes-update-info page-id attrs)
;; Composed updates return observables and need to be executed differently
{:keys [observable normal]} (group-by #(if (rx/observable? %) :observable :normal) actions)]
(l/inf :status "PROGRESS"
:hint "propagate-tokens"
@@ -170,7 +174,9 @@
::l/sync? true)
(rx/merge
(rx/from actions)
(when (seq observable) (apply rx/merge observable))
(when (seq normal) (rx/concat-all (rx/of normal)))
(->> (rx/from frame-ids)
(rx/mapcat (fn [frame-id]
(rx/of (dwt/clear-thumbnail file-id page-id frame-id "frame")

View File

@@ -21,7 +21,6 @@
[app.main.data.workspace.tokens.errors :as wte]
[app.main.data.workspace.tokens.library-edit :as dwtl]
[app.main.data.workspace.tokens.propagation :as dwtp]
[app.main.data.workspace.tokens.warnings :as wtw]
[app.main.fonts :as fonts]
[app.main.refs :as refs]
[app.main.store :as st]
@@ -36,11 +35,13 @@
[app.main.ui.workspace.colorpicker.ramp :refer [ramp-selector*]]
[app.main.ui.workspace.sidebar.options.menus.typography :refer [font-selector*]]
[app.main.ui.workspace.tokens.management.create.input-token-color-bullet :refer [input-token-color-bullet*]]
[app.main.ui.workspace.tokens.management.create.input-tokens-value :refer [input-tokens-value*]]
[app.main.ui.workspace.tokens.management.create.input-tokens-value :refer [input-tokens-value*
token-value-hint*]]
[app.util.dom :as dom]
[app.util.functions :as uf]
[app.util.i18n :refer [tr]]
[app.util.keyboard :as k]
[app.util.object :as obj]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[malli.core :as m]
@@ -101,53 +102,127 @@
;; Validation ------------------------------------------------------------------
(defn validate-empty-input [value]
(if (sequential? value)
(empty? value)
(empty? (str/trim value))))
(defn invalidate-empty-value [token-value]
(when (empty? (str/trim token-value))
(wte/get-error-code :error.token/empty-input)))
(defn validate-self-reference? [token-name value]
(if (sequential? value)
(some #(ctob/token-value-self-reference? token-name %) value)
(ctob/token-value-self-reference? token-name value)))
(defn invalidate-token-empty-value [token]
(invalidate-empty-value (:value token)))
(defn validate-token-value
"Validates token value by resolving the value `input` using `StyleDictionary`.
Returns a promise of either resolved tokens or rejects with an error state."
[{:keys [value name-value token tokens]}]
(let [;; When creating a new token we dont have a token name yet,
;; so we use a temporary token name that hopefully doesn't clash with any of the users token names
token-name (if (str/empty? name-value) "__TOKEN_STUDIO_SYSTEM.TEMP" name-value)]
(cond
(validate-empty-input value)
(rx/throw {:errors [(wte/get-error-code :error.token/empty-input)]})
(defn invalidate-self-reference [token-name token-value]
(when (ctob/token-value-self-reference? token-name token-value)
(wte/get-error-code :error.token/direct-self-reference)))
(validate-self-reference? token-name value)
(rx/throw {:errors [(wte/get-error-code :error.token/direct-self-reference)]})
(defn invalidate-token-self-reference [token]
(invalidate-self-reference (:name token) (:value token)))
:else
(let [tokens' (cond-> tokens
(defn validate-resolve-token
[token prev-token tokens]
(let [token (cond-> token
;; When creating a new token we dont have a name yet,
;; but we still want to resolve the value to show in the form.
;; So we use a temporary token name that hopefully doesn't clash with any of the users token names
(str/empty? (:name token)) (assoc :name "__PENPOT__TOKEN__NAME__PLACEHOLDER__"))
tokens' (cond-> tokens
;; Remove previous token when renaming a token
(not= name-value (:name token)) (dissoc (:name token))
:always (update token-name #(ctob/make-token (merge % {:value (cond
(= (:type token) :font-family) (ctt/split-font-family value)
:else value)
:name token-name
:type (:type token)}))))]
(not= (:name token) (:name prev-token))
(dissoc (:name prev-token))
:always
(update (:name token) #(ctob/make-token (merge % prev-token token))))]
(->> tokens'
(sd/resolve-tokens-interactive)
(rx/mapcat
(fn [resolved-tokens]
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens token-name)]
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))]
(cond
resolved-value (rx/of resolved-token)
:else (rx/throw {:errors (or errors (wte/get-error-code :error/unknown-error))}))))))))))
:else (rx/throw {:errors (or (seq errors)
[(wte/get-error-code :error/unknown-error)])}))))))))
(defn validate-token-with [token validators]
(if-let [error (some (fn [validate] (validate token)) validators)]
(rx/throw {:errors [error]})
(rx/of token)))
(def default-validators
[invalidate-token-empty-value invalidate-token-self-reference])
(defn default-validate-token
"Validates a token by confirming a list of `validator` predicates and resolving the token using `tokens` with StyleDictionary.
Returns rx stream of either a valid resolved token or an errors map.
Props:
token-name, token-value, token-description: Values from the form inputs
prev-token: The existing token currently being edited
tokens: tokens map keyed by token-name
Used to look up the editing token & resolving step.
validators: A list of predicates that will be used to do simple validation on the unresolved token map.
The validators get the token map as input and should either return:
- An errors map .e.g: {:errors []}
- nil (valid token predicate)
Mostly used to do simple checks like invalidating empy token `:name`.
Will default to `default-validators`."
[{:keys [token-name token-value token-description prev-token tokens validators]
:or {validators default-validators}}]
(let [token (-> {:name token-name
:value token-value
:description token-description}
(d/without-nils))]
(->> (rx/of token)
;; Simple validation of the editing token
(rx/mapcat #(validate-token-with % validators))
;; Resolving token via StyleDictionary
(rx/mapcat #(validate-resolve-token % prev-token tokens)))))
(defn invalidate-coll-self-reference
"Invalidate a collection of `token-vals` for a self-refernce against `token-name`.,"
[token-name token-vals]
(when (some #(ctob/token-value-self-reference? token-name %) token-vals)
(wte/get-error-code :error.token/direct-self-reference)))
(defn invalidate-font-family-token-self-reference [token]
(invalidate-coll-self-reference (:name token) (:value token)))
(defn validate-font-family-token
[props]
(-> props
(update :token-value ctt/split-font-family)
(assoc :validators [(fn [token]
(when (empty? (:value token))
(wte/get-error-code :error.token/empty-input)))
invalidate-font-family-token-self-reference])
(default-validate-token)))
(defn invalidate-typography-token-self-reference
"Invalidate token when any of the attributes in token value have a self refernce."
[token]
(let [token-name (:name token)
token-values (:value token)]
(some (fn [[k v]]
(when-let [err (case k
:font-family (invalidate-coll-self-reference token-name v)
(invalidate-self-reference token-name v))]
(assoc err :typography-key k)))
token-values)))
(defn validate-typography-token
[props]
(-> props
(update :token-value
(fn [v]
(-> (or v {})
(d/update-when :font-family #(if (string? %) (ctt/split-font-family %) %)))))
(assoc :validators [invalidate-typography-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.
Uses a custom debouncing logic, as the resolve function is async."
[name-ref token tokens callback & {:keys [timeout] :or {timeout 160}}]
[{:keys [timeout name-ref token tokens callback validate-token]
:or {timeout 160}}]
(let [timeout-id-ref (mf/use-ref nil)
debounced-resolver-callback
(mf/use-fn
@@ -160,14 +235,12 @@
(js/setTimeout
(fn []
(when (not (timeout-outdated-cb?))
(->> (validate-token-value {:value value
:name-value @name-ref
:token token
(->> (validate-token {:token-value value
:token-name @name-ref
:prev-token token
:tokens tokens})
(rx/filter #(not (timeout-outdated-cb?)))
(rx/subs!
callback
callback))))
(rx/subs! callback callback))))
timeout))))]
debounced-resolver-callback))
@@ -175,30 +248,35 @@
;; Component -------------------------------------------------------------------
(mf/defc token-value-hint
[{:keys [result]}]
(let [{:keys [errors warnings resolved-value]} result
empty-message? (nil? result)
message (cond
empty-message? (tr "workspace.tokens.resolved-value" "-")
warnings (wtw/humanize-warnings warnings)
errors (->> (wte/humanize-errors errors)
(str/join "\n"))
:else (tr "workspace.tokens.resolved-value" (or resolved-value result)))
type (cond
empty-message? "hint"
errors "error"
warnings "warning"
:else "hint")]
[:> hint-message*
{:id "token-value-hint"
:message message
:class (stl/css-case :resolved-value (not (or empty-message? (seq warnings) (seq errors))))
:type type}]))
(mf/defc form*
[{:keys [token token-type action selected-token-set-name transform-value on-value-resolve custom-input-token-value custom-input-token-value-props input-value-placeholder]}]
"Form component to edit or create a token of any token type.
Callback props:
validate-token: Function to validate and resolve an editing token, see `default-validate-token`.
on-value-resolve: Will be called when a token value is resolved
Used to sync external state (like color picker)
on-get-token-value: Custom function to get the input value from the dom
(As there might be multiple inputs passed for `custom-input-token-value`)
Can also be used to manipulate the value (E.g.: Auto-prepending # for hex colors)
Custom component props:
custom-input-token-value: Custom component for editing/displaying the token value
custom-input-token-value-props: Custom props passed to the custom-input-token-value merged with the default props"
[{:keys [token
token-type
selected-token-set-name
action
input-value-placeholder
;; Callbacks
validate-token
on-value-resolve
on-get-token-value
;; Custom component props
custom-input-token-value
custom-input-token-value-props]
:or {validate-token default-validate-token}}]
(let [create? (not (instance? ctob/Token token))
token (or token {:type token-type})
token-properties (dwta/get-token-properties token)
@@ -213,7 +291,10 @@
;; Style dictionary resolver needs font families to be an array of strings
(= :font-family (or (:type token) token-type))
(update-in [(:name token) :value] ctt/split-font-family))
(update-in [(:name token) :value] ctt/split-font-family)
(= :typography (or (:type token) token-type))
(d/update-in-when [(:name token) :font-family :value] ctt/split-font-family))
resolved-tokens (sd/use-resolved-tokens active-theme-tokens {:cache-atom form-token-cache-atom
:interactive? true})
@@ -288,37 +369,32 @@
(mf/use-fn
(mf/deps on-value-resolve)
(fn [token-or-err]
(let [error? (:errors token-or-err)
warnings? (:warnings token-or-err)
v (cond
error?
(do
(when on-value-resolve (on-value-resolve nil))
token-or-err)
(when on-value-resolve
(cond
(:errors token-or-err) (on-value-resolve nil)
:else (on-value-resolve (:resolved-value token-or-err))))
(reset! token-resolve-result* token-or-err)))
warnings?
(:warnings {:warnings token-or-err})
on-update-value-debounced
(use-debonced-resolve-callback
{:name-ref token-name-ref
:token token
:tokens active-theme-tokens
:callback set-resolve-value
:validate-token validate-token})
:else
(cond-> (:resolved-value token-or-err)
on-value-resolve on-value-resolve))]
(reset! token-resolve-result* v))))
on-update-value-debounced (use-debonced-resolve-callback token-name-ref token active-theme-tokens set-resolve-value)
;; Callback to update the value state via on of the inputs :on-change
on-update-value
(mf/use-fn
(mf/deps on-update-value-debounced transform-value)
(mf/deps on-update-value-debounced on-get-token-value)
(fn [e]
(let [value (dom/get-target-val e)
value' (if (fn? transform-value)
(transform-value value)
value)]
;; Value got updated in transform, update the dom node
(when (not= value value')
(dom/set-value! (mf/ref-val value-input-ref) value'))
(let [value (if (fn? on-get-token-value)
(on-get-token-value e (mf/ref-val value-ref))
(dom/get-target-val e))]
(mf/set-ref-val! value-ref value)
(on-update-value-debounced value))))
;; Calback to update the value state from the outside (e.g.: color picker)
on-external-update-value
(mf/use-fn
(mf/deps on-update-value-debounced)
@@ -328,10 +404,7 @@
(on-update-value-debounced next-value)))
value-error? (seq (:errors token-resolve-result))
valid-value-field? (and
(not value-error?)
(valid-value? token-resolve-result))
valid-value-field? (not value-error?)
;; Description
description-ref (mf/use-var (:description token))
@@ -359,7 +432,7 @@
on-submit
(mf/use-fn
(mf/deps validate-name validate-descripion token active-theme-tokens)
(mf/deps create? validate-name validate-descripion token active-theme-tokens validate-token)
(fn [e]
(dom/prevent-default e)
;; We have to re-validate the current form values before submitting
@@ -370,11 +443,7 @@
valid-name? (try
(not (:errors (validate-name final-name)))
(catch js/Error _ nil))
final-value (let [value (mf/ref-val value-ref)
font-family? (= :font-family (or (:type token) token-type))]
(if font-family?
(ctt/split-font-family value)
(finalize-value value)))
value (mf/ref-val value-ref)
final-description @description-ref
valid-description? (if final-description
(try
@@ -382,22 +451,23 @@
(catch js/Error _ nil))
true)]
(when (and valid-name? valid-description?)
(->> (validate-token-value {:value final-value
:name-value final-name
:token token
(->> (validate-token {:token-value value
:token-name final-name
:token-description final-description
:prev-token token
:tokens active-theme-tokens})
(rx/subs!
(fn []
(fn [valid-token]
(st/emit!
(if (ctob/token? token)
(dwtl/update-token (:id token)
{:name final-name
:value final-value
:description final-description})
(if create?
(dwtl/create-token {:name final-name
:type token-type
:value final-value
:value (:value valid-token)
:description final-description})
(dwtl/update-token (:id token)
{:name final-name
:value (:value valid-token)
:description final-description}))
(dwtp/propagate-workspace-tokens)
(modal/hide)))))))))
@@ -496,29 +566,25 @@
(let [placeholder (or input-value-placeholder (tr "workspace.tokens.token-value-enter"))
label (tr "workspace.tokens.token-value")
default-value (mf/ref-val value-ref)
ref value-input-ref
error (not (nil? (:errors token-resolve-result)))
on-blur on-update-value]
ref value-input-ref]
(if (fn? custom-input-token-value)
[:> custom-input-token-value
{:placeholder placeholder
:label label
:default-value default-value
:input-ref ref
:error error
:on-blur on-blur
:on-update-value on-update-value
:on-external-update-value on-external-update-value
:custom-input-token-value-props custom-input-token-value-props}]
:custom-input-token-value-props custom-input-token-value-props
:token-resolve-result token-resolve-result}]
[:> input-tokens-value*
{:placeholder placeholder
:label label
:default-value default-value
:ref ref
:error error
:on-blur on-blur
:on-change on-update-value}]))
[:& token-value-hint {:result token-resolve-result}]]
:on-blur on-update-value
:on-change on-update-value
:token-resolve-result token-resolve-result}]))]
[:div {:class (stl/css :input-row)}
[:> input* {:label (tr "workspace.tokens.token-description")
:placeholder (tr "workspace.tokens.token-description")
@@ -606,7 +672,7 @@
:on-change on-change'}]]))
(mf/defc color-picker*
[{:keys [placeholder label default-value input-ref error on-blur on-update-value on-external-update-value custom-input-token-value-props]}]
[{:keys [placeholder label default-value input-ref on-blur on-update-value on-external-update-value custom-input-token-value-props token-resolve-result]}]
(let [{:keys [color on-display-colorpicker]} custom-input-token-value-props
color-ramp-open* (mf/use-state false)
color-ramp-open? (deref color-ramp-open*)
@@ -658,14 +724,14 @@
:label label
:default-value default-value
:ref input-ref
:error error
:on-blur on-blur
:on-change on-update-value
:slot-start swatch}]
(when color-ramp-open?
[:> ramp*
{:color (some-> color (tinycolor/valid-color))
:on-change on-change'}])]))
:on-change on-change'}])
[:> token-value-hint* {:result token-resolve-result}]]))
(mf/defc color-form*
[{:keys [token on-display-colorpicker] :rest props}]
@@ -684,15 +750,19 @@
{:color color
:on-display-colorpicker on-display-colorpicker}))
transform-value
on-get-token-value
(mf/use-fn
(fn [value]
(fn [e]
(let [value (dom/get-target-val e)]
(if (tinycolor/hex-without-hash-prefix? value)
(dm/str "#" value)
value)))]
(let [hex-value (dm/str "#" value)]
(dom/set-value! (dom/get-target e) hex-value)
hex-value)
value))))]
[:> form*
(mf/spread-props props {:token token
:transform-value transform-value
:on-get-token-value on-get-token-value
:on-value-resolve on-value-resolve
:custom-input-token-value color-picker*
:custom-input-token-value-props custom-input-token-value-props})]))
@@ -713,7 +783,7 @@
:full-size true}]]))
(mf/defc font-picker*
[{:keys [default-value input-ref error on-blur on-update-value on-external-update-value]}]
[{:keys [default-value input-ref on-blur on-update-value on-external-update-value token-resolve-result]}]
(let [font* (mf/use-state (fonts/find-font-family default-value))
font (deref font*)
set-font (mf/use-fn
@@ -764,11 +834,11 @@
:label (tr "workspace.tokens.token-font-family-value")
:default-value default-value
:ref input-ref
:error error
:on-blur on-blur
:on-change on-update-value'
:icon "text-font-family"
:slot-end font-selector-button}]
:slot-end font-selector-button
:token-resolve-result token-resolve-result}]
(when font-selector-open?
[:> font-selector-wrapper* {:font font
:input-ref input-ref
@@ -785,7 +855,8 @@
[:> form*
(mf/spread-props props {:token (when token (update token :value ctt/join-font-family))
:custom-input-token-value font-picker*
:on-value-resolve on-value-resolve})]))
:on-value-resolve on-value-resolve
:validate-token validate-font-family-token})]))
(mf/defc text-case-form*
[{:keys [token] :rest props}]
@@ -805,11 +876,102 @@
(mf/spread-props props {:token token
:input-value-placeholder (tr "workspace.tokens.font-weight-value-enter")})])
(def ^:private typography-inputs
#(d/ordered-map
:font-size
{:label "Font Size"
:placeholder (tr "workspace.tokens.token-value-enter")}
:font-family
{:label (tr "workspace.tokens.token-font-family-value")
:placeholder (tr "workspace.tokens.token-font-family-value-enter")}
:font-weight
{:label "Font Weight"
:placeholder (tr "workspace.tokens.font-weight-value-enter")}
:letter-spacing
{:label "Letter Spacing"
:placeholder (tr "workspace.tokens.token-value-enter")}
:text-case
{:label "Text Case"
:placeholder (tr "workspace.tokens.text-case-value-enter")}
:text-decoration
{:label "Text Decoration"
:placeholder (tr "workspace.tokens.text-decoration-value-enter")}))
(mf/defc typography-inputs*
[{:keys [default-value on-blur on-update-value token-resolve-result]}]
(let [typography-inputs (mf/use-memo typography-inputs)
errors-by-key (sd/collect-typography-errors token-resolve-result)]
[:div {:class (stl/css :nested-input-row)}
(for [[k {:keys [label placeholder]}] typography-inputs]
(let [value (get default-value k)
token-resolve-result
(-> {:resolved-value (let [v (get-in token-resolve-result [:resolved-value k])]
(when-not (str/empty? v) v))
:errors (get errors-by-key k)}
(d/without-nils))
input-ref (mf/use-ref)
on-external-update-value
(mf/use-fn
(mf/deps on-update-value)
(fn [next-value]
(let [el (mf/ref-val input-ref)]
(dom/set-value! el next-value)
(on-update-value #js {:target el
:tokenType :font-family}))))
on-change
(mf/use-fn
;; Passing token-type via event to prevent deep function adapting & passing of type
(fn [e]
(-> (obj/set! e "tokenType" k)
(on-update-value))))]
[:div {:class (stl/css :input-row)}
(case k
:font-family
[:> font-picker*
{:key (str k)
:label label
:placeholder placeholder
:input-ref input-ref
:default-value (when value (ctt/join-font-family value))
:on-blur on-blur
:on-update-value on-change
:on-external-update-value on-external-update-value
:token-resolve-result (when (seq token-resolve-result) token-resolve-result)}]
[:> input-tokens-value*
{:key (str k)
:label label
:placeholder placeholder
:default-value value
:on-blur on-blur
:on-change on-change
:token-resolve-result (when (seq token-resolve-result) token-resolve-result)}])]))]))
(mf/defc typography-form*
[{:keys [token] :rest props}]
(let [on-get-token-value
(mf/use-callback
(fn [e prev-value]
(let [token-type (obj/get e "tokenType")
input-value (dom/get-target-val e)]
(if (empty? input-value)
(dissoc prev-value token-type)
(assoc prev-value token-type input-value)))))]
[:> form*
(mf/spread-props props {:token token
:custom-input-token-value typography-inputs*
:validate-token validate-typography-token
:on-get-token-value on-get-token-value})]))
(mf/defc form-wrapper*
[{:keys [token token-type] :as props}]
(let [token-type' (or (:type token) token-type)]
(case token-type'
:color [:> color-form* props]
:typography [:> typography-form* props]
:font-family [:> font-family-form* props]
:text-case [:> text-case-form* props]
:text-decoration [:> text-decoration-form* props]

View File

@@ -39,6 +39,12 @@
gap: $s-4;
}
.nested-input-row {
display: flex;
flex-direction: column;
gap: $s-12;
}
.warning-name-change-notification-wrapper {
margin-block-start: $s-16;
}

View File

@@ -8,9 +8,14 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.data.workspace.tokens.errors :as wte]
[app.main.data.workspace.tokens.warnings :as wtw]
[app.main.ui.ds.controls.utilities.hint-message :refer [hint-message*]]
[app.main.ui.ds.controls.utilities.input-field :refer [input-field*]]
[app.main.ui.ds.controls.utilities.label :refer [label*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon-list]]
[app.util.i18n :refer [tr]]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(def ^:private schema::input-tokens-value
@@ -22,14 +27,39 @@
[:error {:optional true} :boolean]
[:slot-start {:optional true} [:maybe some?]]
[:icon {:optional true}
[:maybe [:and :string [:fn #(contains? icon-list %)]]]]])
[:maybe [:and :string [:fn #(contains? icon-list %)]]]]
[:token-resolve-result {:optional true} :any]])
(mf/defc token-value-hint*
[{:keys [result]}]
(let [{:keys [errors warnings resolved-value]} result
empty-message? (nil? result)
message (cond
empty-message? (tr "workspace.tokens.resolved-value" "-")
warnings (->> (wtw/humanize-warnings warnings)
(str/join "\n"))
errors (->> (wte/humanize-errors errors)
(str/join "\n"))
:else (tr "workspace.tokens.resolved-value" (or resolved-value result)))
type (cond
empty-message? "hint"
errors "error"
warnings "warning"
:else "hint")]
[:> hint-message*
{:id "token-value-hint"
:message message
:class (stl/css-case :resolved-value (not (or empty-message? (seq warnings) (seq errors))))
:type type}]))
(mf/defc input-tokens-value*
{::mf/props :obj
::mf/forward-ref true
::mf/schema schema::input-tokens-value}
[{:keys [class label placeholder error value icon slot-start] :rest props} ref]
(let [id (mf/use-id)
[{:keys [class label placeholder value icon slot-start token-resolve-result] :rest props} ref]
(let [error (not (nil? (:errors token-resolve-result)))
id (mf/use-id)
input-ref (mf/use-ref)
props (mf/spread-props props {:id id
:type "text"
@@ -41,7 +71,10 @@
:slot-start slot-start
:icon icon
:ref (or ref input-ref)})]
[:*
[:div {:class (dm/str class " " (stl/css-case :wrapper true
:input-error error))}
[:> label* {:for id} label]
[:> input-field* props]]))
[:> input-field* props]]
(when token-resolve-result
[:> token-value-hint* {:result token-resolve-result}])]))

View File

@@ -70,7 +70,7 @@
{::mf/wrap-props false}
[{:keys [x y position token token-type action selected-token-set-name] :as _args}]
(let [wrapper-style (use-viewport-position-style x y position (= token-type :color))
modal-size-large* (mf/use-state false)
modal-size-large* (mf/use-state (= token-type :typography))
modal-size-large? (deref modal-size-large*)
close-modal (mf/use-fn
(fn []

View File

@@ -33,13 +33,13 @@
:text-case "text-mixed"
:text-decoration "text-underlined"
:font-weight "text-font-weight"
:typography "text-typography"
:opacity "percentage"
:number "number"
:rotation "rotation"
:spacing "padding-extended"
:string "text-mixed"
:stroke-width "stroke-size"
:typography "text"
:dimensions "expand"
:sizing "expand"
"add"))

View File

@@ -80,7 +80,13 @@
:sizing "Sizing"
:border-radius "Border Radius"
:x "X"
:y "Y"})
:y "Y"
:font-size "Font Size"
:font-family "Font Family"
:font-weight "Font Weight"
:letter-spacing "Letter Spacing"
:text-case "Text Case"
:text-decoration "Text Decoration"})
;; Helper functions
@@ -104,12 +110,23 @@
(str/join ", " (map attribute-dictionary values)) ".")))
grouped-values)))
(defn format-token-value [token-value]
(cond
(map? token-value)
(->> (map (fn [[k v]] (str "- " (category-dictionary k) ": " (format-token-value v))) token-value)
(str/join "\n")
(str "\n"))
(sequential? token-value)
(str/join "," token-value)
:else
(str token-value)))
(defn- generate-tooltip
"Generates a tooltip for a given token"
[is-viewer shape theme-token token half-applied no-valid-value ref-not-in-active-set]
(let [{:keys [name type resolved-value]} token
value (cond->> (:value token)
(= :font-family type) ctt/join-font-family)
(let [{:keys [name type resolved-value value]} token
resolved-value-theme (:resolved-value theme-token)
resolved-value (or resolved-value-theme resolved-value)
{:keys [title] :as token-props} (dwta/get-token-properties theme-token)
@@ -125,8 +142,8 @@
grouped-values (group-by dimensions-dictionary app-token-keys)
base-title (dm/str "Token: " name "\n"
(tr "workspace.tokens.original-value" value) "\n"
(tr "workspace.tokens.resolved-value" resolved-value)
(tr "workspace.tokens.original-value" (format-token-value value)) "\n"
(tr "workspace.tokens.resolved-value" (format-token-value resolved-value))
(when (= (:type token) :number)
(dm/str "\n" (tr "workspace.tokens.more-options"))))]

View File

@@ -860,6 +860,173 @@
(t/is (nil? (:fill-color-ref-id fill)))
(t/is (nil? (:fill-color-ref-file fill))))))))))
(t/deftest test-apply-typography-token
(t/testing "applies typography (composite) tokens"
(t/async
done
(let [font-size-token {:name "font-size-reference"
:value "100px"
:type :font-size}
font-family-token {:name "font-family-reference"
:value ["Arial" "sans-serif"]
:type :font-family}
typography-token {:name "typography.heading"
:value {:font-size "24px"
:font-weight "bold"
:font-family [(:font-id txt/default-text-attrs) "Arial" "sans-serif"]
:letter-spacing "2"
:text-case "uppercase"
:text-decoration "underline"}
:type :typography}
file (-> (setup-file-with-tokens)
(update-in [:data :tokens-lib]
#(-> %
(ctob/add-token-in-set "Set A" (ctob/make-token font-size-token))
(ctob/add-token-in-set "Set A" (ctob/make-token font-family-token))
(ctob/add-token-in-set "Set A" (ctob/make-token typography-token)))))
store (ths/setup-store file)
text-1 (cths/get-shape file :text-1)
events [(dwta/apply-token {:shape-ids [(:id text-1)]
:attributes #{:typography}
:token (toht/get-token file "typography.heading")
:on-update-shape dwta/update-typography})]]
(tohs/run-store-async
store done events
(fn [new-state]
(let [file' (ths/get-file-from-state new-state)
text-1' (cths/get-shape file' :text-1)
text-1' (def text-1' text-1')
style-text-blocks (->> (:content text-1')
(txt/content->text+styles)
(remove (fn [[_ text]] (str/empty? (str/trim text))))
(mapv (fn [[style text]]
{:styles (merge txt/default-text-attrs style)
:text-content text}))
(first)
(:styles))]
(t/is (some? (:applied-tokens text-1')))
(t/is (= (:typography (:applied-tokens text-1')) "typography.heading"))
(t/is (= (:font-size style-text-blocks) "24"))
(t/is (= (:font-weight style-text-blocks) "400"))
(t/is (= (:font-family style-text-blocks) "sourcesanspro"))
(t/is (= (:letter-spacing style-text-blocks) "2"))
(t/is (= (:text-transform style-text-blocks) "uppercase"))
(t/is (= (:text-decoration style-text-blocks) "underline")))))))))
(t/deftest test-apply-reference-typography-token
(t/testing "applies typography (composite) tokens with references"
(t/async
done
(let [font-size-token {:name "fontSize"
:value "100px"
:type :font-size}
font-family-token {:name "fontFamily"
:value ["Arial" "sans-serif"]
:type :font-family}
typography-token {:name "typography"
:value {:font-size "{fontSize}"
:font-family ["{fontFamily}"]}
:type :typography}
file (-> (setup-file-with-tokens)
(update-in [:data :tokens-lib]
#(-> %
(ctob/add-token-in-set "Set A" (ctob/make-token font-size-token))
(ctob/add-token-in-set "Set A" (ctob/make-token font-family-token))
(ctob/add-token-in-set "Set A" (ctob/make-token typography-token)))))
store (ths/setup-store file)
text-1 (cths/get-shape file :text-1)
events [(dwta/apply-token {:shape-ids [(:id text-1)]
:attributes #{:typography}
:token (toht/get-token file "typography")
:on-update-shape dwta/update-typography})]]
(tohs/run-store-async
store done events
(fn [new-state]
(let [file' (ths/get-file-from-state new-state)
text-1' (cths/get-shape file' :text-1)
style-text-blocks (->> (:content text-1')
(txt/content->text+styles)
(remove (fn [[_ text]] (str/empty? (str/trim text))))
(mapv (fn [[style text]]
{:styles (merge txt/default-text-attrs style)
:text-content text}))
(first)
(:styles))]
(t/is (some? (:applied-tokens text-1')))
(t/is (= (:typography (:applied-tokens text-1')) "typography"))
(t/is (= (:font-size style-text-blocks) "100"))
(t/is (= (:font-family style-text-blocks) "Arial")))))))))
(t/deftest test-unapply-atomic-tokens-on-composite-apply
(t/testing "unapplies atomic typography tokens when applying composite token"
(t/async
done
(let [font-size-token {:name "fontSize"
:value "100px"
:type :font-size}
typography-token {:name "typography"
:value {}
:type :typography}
file (-> (setup-file-with-tokens)
(update-in [:data :tokens-lib]
#(-> %
(ctob/add-token-in-set "Set A" (ctob/make-token font-size-token))
(ctob/add-token-in-set "Set A" (ctob/make-token typography-token)))))
store (ths/setup-store file)
text-1 (cths/get-shape file :text-1)
events [(dwta/apply-token {:shape-ids [(:id text-1)]
:attributes #{:typography}
:token (toht/get-token file "fontSize")})
(dwta/apply-token {:shape-ids [(:id text-1)]
:attributes #{:typography}
:token (toht/get-token file "typography")
:on-update-shape dwta/update-typography})]]
(tohs/run-store-async
store done events
(fn [new-state]
(let [file' (ths/get-file-from-state new-state)
text-1' (cths/get-shape file' :text-1)]
(t/is (some? (:applied-tokens text-1')))
(t/is (= (:typography (:applied-tokens text-1')) "typography"))
(t/is (nil? (:font-size (:applied-tokens text-1')))))))))))
(t/deftest test-unapply-composite-tokens-on-atomic-apply
(t/testing "unapplies composite typography tokens when applying atomic token"
(t/async
done
(let [font-size-token {:name "fontSize"
:value "100px"
:type :font-size}
typography-token {:name "typography"
:value {}
:type :typography}
file (-> (setup-file-with-tokens)
(update-in [:data :tokens-lib]
#(-> %
(ctob/add-token-in-set "Set A" (ctob/make-token font-size-token))
(ctob/add-token-in-set "Set A" (ctob/make-token typography-token)))))
store (ths/setup-store file)
text-1 (cths/get-shape file :text-1)
events [(dwta/apply-token {:shape-ids [(:id text-1)]
:attributes #{:typography}
:token (toht/get-token file "typography")
:on-update-shape dwta/update-typography})
(dwta/apply-token {:shape-ids [(:id text-1)]
:attributes #{:font-size}
:token (toht/get-token file "fontSize")
:on-update-shape dwta/update-font-size})]]
(tohs/run-store-async
store done events
(fn [new-state]
(let [file' (ths/get-file-from-state new-state)
text-1' (cths/get-shape file' :text-1)]
(t/is (some? (:applied-tokens text-1')))
(t/is (= (:font-size (:applied-tokens text-1')) "fontSize"))
(t/is (nil? (:typography (:applied-tokens text-1')))))))))))
(t/deftest test-detach-styles-typography
(t/testing "applying any typography token to a shape with a typography style should detach the style"
(t/async