Compare commits

..

6 Commits

Author SHA1 Message Date
Eva Marco
47c36b24ac 🎉 Add flag 2026-02-23 10:41:31 +01:00
Eva Marco
b24bcccda6 🎉 Add test 2026-02-19 13:19:38 +01:00
Eva Marco
0739a67493 ♻️ Extract token parsing 2026-02-19 12:16:04 +01:00
Eva Marco
f04356d3cf ♻️ Extract mouse navigation as hook 2026-02-19 12:15:55 +01:00
Eva Marco
9db0d250c5 ♻️ Extract floating position as hook 2026-02-19 12:15:44 +01:00
Eva Marco
f78c76ffde 🎉 Create token combobox 2026-02-19 12:13:42 +01:00
30 changed files with 1028 additions and 418 deletions

View File

@@ -87,10 +87,6 @@
[:map
[:valid ::sm/boolean]])
(def ^:private schema:connectivity
[:map
[:licenses ::sm/boolean]])
(defn- get-team-org
[cfg {:keys [team-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)]
@@ -101,11 +97,6 @@
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get (str baseuri "/api/users/" (str profile-id)) schema:user params)))
(defn- get-connectivity
[cfg params]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get (str baseuri "/api/connectivity") schema:connectivity params)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INITIALIZATION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -114,8 +105,7 @@
[_ cfg]
(when (contains? cf/flags :nitrate)
{:get-team-org (partial get-team-org cfg)
:is-valid-user (partial is-valid-user cfg)
:connectivity (partial get-connectivity cfg)}))
:is-valid-user (partial is-valid-user cfg)}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; UTILS
@@ -138,7 +128,3 @@
(let [params (assoc (or params {}) :team-id (:id team))
org (call cfg :get-team-org params)]
(assoc team :organization-id (:id org) :organization-name (:name org))))
(defn connectivity
[cfg]
(call cfg :connectivity {}))

View File

@@ -262,7 +262,6 @@
'app.rpc.commands.ldap
'app.rpc.commands.management
'app.rpc.commands.media
'app.rpc.commands.nitrate
'app.rpc.commands.profile
'app.rpc.commands.projects
'app.rpc.commands.search

View File

@@ -1,20 +0,0 @@
(ns app.rpc.commands.nitrate
(:require
[app.common.schema :as sm]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.rpc.doc :as-alias doc]
[app.util.services :as sv]))
(def schema:connectivity
[:map {:title "nitrate-connectivity"}
[:licenses ::sm/boolean]])
(sv/defmethod ::get-nitrate-connectivity
{::rpc/auth false
::doc/added "1.18"
::sm/params [:map]
::sm/result schema:connectivity}
[cfg _params]
(nitrate/connectivity cfg))

View File

@@ -122,6 +122,7 @@
;; Only for developtment.
:tiered-file-data-storage
:token-base-font-size
:token-combobox
:token-color
:token-shadow
:token-tokenscript

View File

@@ -1,22 +1,22 @@
import { test, expect } from "@playwright/test";
import { WorkspacePage } from "../../pages/WorkspacePage";
import { BaseWebSocketPage } from "../../pages/BaseWebSocketPage";
import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage";
import {
setupTokensFileRender,
setupTypographyTokensFileRender,
setupEmptyTokensFile,
setupTokensFile,
setupTypographyTokensFile,
unfoldTokenTree,
} from "./helpers";
test.beforeEach(async ({ page }) => {
await WasmWorkspacePage.init(page);
await WasmWorkspacePage.mockConfigFlags(page, ["enable-feature-design-tokens-v1"]);
await WorkspacePage.init(page);
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-tokens.json");
});
test.describe("Tokens: Apply token", () => {
test("User applies color token to a shape", async ({ page }) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFileRender(page);
await setupTokensFile(page);
await page.getByRole("tab", { name: "Layers" }).click();
@@ -44,7 +44,9 @@ test.describe("Tokens: Apply token", () => {
page,
}) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFileRender(page);
await setupTokensFile(page, {
flags: ["enable-token-combobox", "enable-feature-token-input"],
});
await page.getByRole("tab", { name: "Layers" }).click();
@@ -83,7 +85,9 @@ test.describe("Tokens: Apply token", () => {
await brTokenPillSM.click();
// Change token from dropdown
const brTokenOptionXl = borderRadiusSection.getByLabel("borderRadius.xl");
const brTokenOptionXl = borderRadiusSection
.getByRole("option", { name: "borderRadius.xl" })
.getByLabel("borderRadius.xl");
await expect(brTokenOptionXl).toBeVisible();
await brTokenOptionXl.click();
@@ -105,7 +109,7 @@ test.describe("Tokens: Apply token", () => {
page,
}) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFileRender(page);
await setupTokensFile(page);
await page.getByRole("tab", { name: "Layers" }).click();
@@ -169,7 +173,7 @@ test.describe("Tokens: Apply token", () => {
test("User applies typography token to a text shape", async ({ page }) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTypographyTokensFileRender(page);
await setupTypographyTokensFile(page);
await page.getByRole("tab", { name: "Layers" }).click();
@@ -203,7 +207,7 @@ test.describe("Tokens: Apply token", () => {
tokensSidebar,
workspacePage,
tokenContextMenuForToken,
} = await setupTokensFileRender(page, { flags: ["enable-token-shadow"] });
} = await setupTokensFile(page, { flags: ["enable-token-shadow"] });
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -489,7 +493,7 @@ test.describe("Tokens: Apply token", () => {
page,
}) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFileRender(page);
await setupTokensFile(page);
// Unfolds dimensions on token panel
await page.getByRole("tab", { name: "Layers" }).click();
@@ -518,7 +522,9 @@ test.describe("Tokens: Apply token", () => {
await dimensionSMTokenPill.nth(1).click();
// Change token from dropdown
const dimensionTokenOptionXl = measuresSection.getByLabel("dimension.xl");
const dimensionTokenOptionXl = measuresSection.getByRole("option", {
name: "dimension.xl",
});
await expect(dimensionTokenOptionXl).toBeVisible();
await dimensionTokenOptionXl.click();
@@ -540,7 +546,7 @@ test.describe("Tokens: Apply token", () => {
page,
}) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFileRender(page);
await setupTokensFile(page);
// Unfolds dimensions on token panel
await page.getByRole("tab", { name: "Layers" }).click();
@@ -572,7 +578,9 @@ test.describe("Tokens: Apply token", () => {
await dimensionSMTokenPill.click();
// Change token from dropdown
const dimensionTokenOptionXl = measuresSection.getByLabel("dimension.xl");
const dimensionTokenOptionXl = measuresSection.getByRole("option", {
name: "dimension.xl",
});
await expect(dimensionTokenOptionXl).toBeVisible();
await dimensionTokenOptionXl.click();
@@ -594,7 +602,7 @@ test.describe("Tokens: Apply token", () => {
page,
}) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFileRender(page);
await setupTokensFile(page);
// Unfolds dimensions on token panel
await page.getByRole("tab", { name: "Layers" }).click();
@@ -626,7 +634,9 @@ test.describe("Tokens: Apply token", () => {
await dimensionSMTokenPill.click();
// Change token from dropdown
const dimensionTokenOptionXl = measuresSection.getByLabel("dimension.xl");
const dimensionTokenOptionXl = measuresSection.getByRole("option", {
name: "dimension.xl",
});
await expect(dimensionTokenOptionXl).toBeVisible();
await dimensionTokenOptionXl.click();
@@ -648,7 +658,7 @@ test.describe("Tokens: Apply token", () => {
page,
}) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFileRender(page);
await setupTokensFile(page);
// Unfolds dimensions on token panel
await page.getByRole("tab", { name: "Layers" }).click();
@@ -681,8 +691,9 @@ test.describe("Tokens: Apply token", () => {
await dimensionXSTokenPill.click();
// Change token from dropdown
const dimensionTokenOptionXl =
borderRadiusSection.getByLabel("dimension.xl");
const dimensionTokenOptionXl = borderRadiusSection.getByRole("option", {
name: "dimension.xl",
});
await expect(dimensionTokenOptionXl).toBeVisible();
await dimensionTokenOptionXl.click();
@@ -701,7 +712,7 @@ test.describe("Tokens: Apply token", () => {
});
test("User applies stroke width token to a shape", async ({ page }) => {
const workspace = new WasmWorkspacePage(page, {
const workspace = new WorkspacePage(page, {
textEditor: true,
});
// Set up
@@ -751,7 +762,9 @@ test.describe("Tokens: Apply token", () => {
});
await tokenDropdown.click();
const widthOptionSmall = firstStrokeRow.getByLabel("width-small");
const widthOptionSmall = firstStrokeRow.getByRole("option", {
name: "width-small",
});
await expect(widthOptionSmall).toBeVisible();
await widthOptionSmall.click();
const StrokeWidthPillSmall = firstStrokeRow.getByRole("button", {
@@ -761,7 +774,7 @@ test.describe("Tokens: Apply token", () => {
});
test("User applies margin token to a shape", async ({ page }) => {
const workspace = new WasmWorkspacePage(page, {
const workspace = new WorkspacePage(page, {
textEditor: true,
});
// Set up
@@ -853,7 +866,7 @@ test.describe("Tokens: Detach token", () => {
page,
}) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFileRender(page);
await setupTokensFile(page);
await page.getByRole("tab", { name: "Layers" }).click();

View File

@@ -1,17 +1,16 @@
import { test, expect } from "@playwright/test";
import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage";
import { WorkspacePage } from "../../pages/WorkspacePage";
import { BaseWebSocketPage } from "../../pages/BaseWebSocketPage";
import {
setupEmptyTokensFileRender,
setupTokensFileRender,
setupTypographyTokensFileRender,
setupEmptyTokensFile,
setupTokensFile,
setupTypographyTokensFile,
testTokenCreationFlow,
unfoldTokenTree,
} from "./helpers";
test.beforeEach(async ({ page }) => {
await WasmWorkspacePage.init(page);
await WasmWorkspacePage.mockConfigFlags(page, ["enable-feature-design-tokens-v1"]);
await WorkspacePage.init(page);
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-tokens.json");
});
@@ -31,6 +30,89 @@ test.describe("Tokens - creation", () => {
});
});
test("User creates border radius token with combobox", async ({ page }) => {
const invalidValueError = "Invalid token value";
const emptyNameError = "Name should be at least 1 character";
const selfReferenceError = "Token has self reference";
const missingReferenceError = "Missing token references";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page , {
flags: ["enable-token-combobox", "enable-feature-token-input"],
});
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
const addTokenButton = tokensTabPanel.getByRole("button", {
name: `Add Token: Border Radius`,
});
await addTokenButton.click();
await expect(tokensUpdateCreateModal).toBeVisible();
// Placeholder checks
await expect(
tokensUpdateCreateModal.getByPlaceholder(
"Enter border radius token name",
),
).toBeVisible();
await expect(
tokensUpdateCreateModal.getByPlaceholder(
"Enter a value or alias with {alias}",
),
).toBeVisible();
// Elements
const nameField = tokensUpdateCreateModal.getByLabel("Name");
const valueField = tokensUpdateCreateModal.getByRole("combobox", {
name: "Value",
});
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
// Create first token
await nameField.fill("my-token");
await valueField.fill("1 + 2");
await expect(
tokensUpdateCreateModal.getByText("Resolved value: 3"),
).toBeVisible();
await expect(submitButton).toBeEnabled();
await submitButton.click();
await expect(
tokensTabPanel.getByRole("button", { name: "my-token" }),
).toBeEnabled();
// Create second token referencing the first one using the combobox options
await addTokenButton.click();
await nameField.fill("my-token-2");
const toggleDropdownButton = tokensUpdateCreateModal.getByRole("button", {
name: "Open token list",
});
await toggleDropdownButton.click();
const option = page.getByRole("option", { name: "my-token" });
await expect(option).toBeVisible();
await option.click();
await expect(
tokensUpdateCreateModal.getByText("Resolved value: 3"),
).toBeVisible();
await valueField.pressSequentially(" + 2");
await expect(
tokensUpdateCreateModal.getByText("Resolved value: 5"),
).toBeVisible();
await valueField.pressSequentially(" + {");
await option.click();
await expect(
tokensUpdateCreateModal.getByText("Resolved value: 8"),
).toBeVisible();
});
test("User creates dimensions token", async ({ page }) => {
await testTokenCreationFlow(page, {
tokenLabel: "Dimensions",
@@ -159,7 +241,7 @@ test.describe("Tokens - creation", () => {
const selfReferenceError = "Token has self reference";
const missingReferenceError = "Missing token references";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupEmptyTokensFileRender(page);
await setupEmptyTokensFile(page);
await tokensSidebar
.getByRole("button", { name: "Add Token: Color" })
@@ -321,7 +403,7 @@ test.describe("Tokens - creation", () => {
const missingReferenceError = "Missing token references";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFileRender(page);
await setupEmptyTokensFile(page);
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -466,7 +548,7 @@ test.describe("Tokens - creation", () => {
const missingReferenceError = "Missing token references";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFileRender(page);
await setupEmptyTokensFile(page);
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -602,7 +684,7 @@ test.describe("Tokens - creation", () => {
const missingReferenceError = "Missing token references";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFileRender(page);
await setupEmptyTokensFile(page);
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -718,7 +800,7 @@ test.describe("Tokens - creation", () => {
const missingReferenceError = "Missing token references";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFileRender(page);
await setupEmptyTokensFile(page);
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -832,7 +914,7 @@ test.describe("Tokens - creation", () => {
const emptyNameError = "Name should be at least 1 character";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFileRender(page, { flags: ["enable-token-shadow"] });
await setupEmptyTokensFile(page, { flags: ["enable-token-shadow"] });
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -1013,7 +1095,7 @@ test.describe("Tokens - creation", () => {
page,
}) => {
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupTypographyTokensFileRender(page);
await setupTypographyTokensFile(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
@@ -1048,7 +1130,7 @@ test.describe("Tokens - creation", () => {
const emptyNameError = "Name should be at least 1 character";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFileRender(page, { flags: ["enable-token-shadow"] });
await setupEmptyTokensFile(page, { flags: ["enable-token-shadow"] });
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -1233,7 +1315,7 @@ test.describe("Tokens - creation", () => {
test("User creates typography token", async ({ page }) => {
const emptyNameError = "Name should be at least 1 character";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFileRender(page);
await setupEmptyTokensFile(page);
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -1480,7 +1562,7 @@ test.describe("Tokens - creation", () => {
test("User adds typography token with reference", async ({ page }) => {
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupTypographyTokensFileRender(page);
await setupTypographyTokensFile(page);
const newTokenTitle = "NewReference";
@@ -1530,7 +1612,7 @@ test.describe("Tokens - creation", () => {
test("User creates grouped color token", async ({ page }) => {
const { workspacePage, tokensUpdateCreateModal, tokensSidebar } =
await setupEmptyTokensFileRender(page);
await setupEmptyTokensFile(page);
await tokensSidebar
.getByRole("button", { name: "Add Token: Color" })
@@ -1563,7 +1645,7 @@ test.describe("Tokens - creation", () => {
test("User cant create regular token with value missing", async ({
page,
}) => {
const { tokensUpdateCreateModal } = await setupEmptyTokensFileRender(page);
const { tokensUpdateCreateModal } = await setupEmptyTokensFile(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
@@ -1590,7 +1672,7 @@ test.describe("Tokens - creation", () => {
test("User duplicate color token", async ({ page }) => {
const { tokensSidebar, tokenContextMenuForToken } =
await setupTokensFileRender(page);
await setupTokensFile(page);
await expect(tokensSidebar).toBeVisible();
@@ -1614,7 +1696,7 @@ test.describe("Tokens - creation", () => {
test("User creates grouped color token", async ({ page }) => {
const { workspacePage, tokensUpdateCreateModal, tokensSidebar } =
await setupEmptyTokensFileRender(page);
await setupEmptyTokensFile(page);
await tokensSidebar.getByRole("button", { name: "Add Token: Color" }).click();
@@ -1643,7 +1725,7 @@ test("User creates grouped color token", async ({ page }) => {
});
test("User cant create regular token with value missing", async ({ page }) => {
const { tokensUpdateCreateModal } = await setupEmptyTokensFileRender(page);
const { tokensUpdateCreateModal } = await setupEmptyTokensFile(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
@@ -1670,7 +1752,7 @@ test("User cant create regular token with value missing", async ({ page }) => {
test("User duplicate color token", async ({ page }) => {
const { tokensSidebar, tokenContextMenuForToken } =
await setupTokensFileRender(page);
await setupTokensFile(page);
await expect(tokensSidebar).toBeVisible();
@@ -1696,7 +1778,7 @@ test.describe("Tokens tab - edition", () => {
page,
}) => {
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupTypographyTokensFileRender(page);
await setupTypographyTokensFile(page);
await tokensSidebar
.getByRole("button")
@@ -1792,7 +1874,7 @@ test.describe("Tokens tab - edition", () => {
page,
}) => {
const { tokensUpdateCreateModal, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFileRender(page);
await setupTokensFile(page);
await expect(tokensSidebar).toBeVisible();
@@ -1828,7 +1910,7 @@ test.describe("Tokens tab - edition", () => {
page,
}) => {
const { workspacePage, tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFileRender(page);
await setupEmptyTokensFile(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
@@ -1883,7 +1965,7 @@ test.describe("Tokens tab - edition", () => {
test.describe("Tokens tab - delete", () => {
test("User delete color token", async ({ page }) => {
const { tokensSidebar, tokenContextMenuForToken } =
await setupTokensFileRender(page);
await setupTokensFile(page);
await expect(tokensSidebar).toBeVisible();
@@ -1903,7 +1985,7 @@ test.describe("Tokens tab - delete", () => {
});
test("User removes node and all child tokens", async ({ page }) => {
const { tokensSidebar } = await setupTokensFileRender(page);
const { tokensSidebar } = await setupTokensFile(page);
await expect(tokensSidebar).toBeVisible();

View File

@@ -1,6 +1,5 @@
import { test, expect } from "@playwright/test";
import { WorkspacePage } from "../../pages/WorkspacePage";
import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage";
const setupEmptyTokensFile = async (page, options = {}) => {
const { flags = [] } = options;
@@ -41,45 +40,6 @@ const setupEmptyTokensFile = async (page, options = {}) => {
};
};
const setupEmptyTokensFileRender = async (page, options = {}) => {
const { flags = [] } = options;
const workspacePage = new WasmWorkspacePage(page);
if (flags.length > 0) {
await workspacePage.mockConfigFlags(flags);
}
await workspacePage.setupEmptyFile();
await workspacePage.mockRPC(
"get-team?id=*",
"workspace/get-team-tokens.json",
);
await workspacePage.mockRPC(
"update-file?id=*",
"workspace/update-file-create-rect.json",
);
await workspacePage.goToWorkspace({
fileId: "c7ce0794-0992-8105-8004-38f280443849",
pageId: "66697432-c33d-8055-8006-2c62cc084cad",
});
const tokensTabButton = page.getByRole("tab", { name: "Tokens" });
await tokensTabButton.click();
return {
workspacePage,
tokenThemeUpdateCreateModal: workspacePage.tokenThemeUpdateCreateModal,
tokensUpdateCreateModal: workspacePage.tokensUpdateCreateModal,
tokenThemesSetsSidebar: workspacePage.tokenThemesSetsSidebar,
tokenSetItems: workspacePage.tokenSetItems,
tokensSidebar: workspacePage.tokensSidebar,
tokenSetGroupItems: workspacePage.tokenSetGroupItems,
tokenContextMenuForSet: workspacePage.tokenContextMenuForSet,
};
};
const setupTokensFile = async (page, options = {}) => {
const {
file = "workspace/get-file-tokens.json",
@@ -125,51 +85,6 @@ const setupTokensFile = async (page, options = {}) => {
};
};
const setupTokensFileRender = async (page, options = {}) => {
const {
file = "workspace/get-file-tokens.json",
fileFragment = "workspace/get-file-fragment-tokens.json",
flags = ["enable-feature-token-input"],
} = options;
const workspacePage = new WasmWorkspacePage(page);
if (flags.length > 0) {
await workspacePage.mockConfigFlags(flags);
}
await workspacePage.setupEmptyFile();
await workspacePage.mockRPC(
"get-team?id=*",
"workspace/get-team-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",
);
await workspacePage.goToWorkspace({
fileId: "c7ce0794-0992-8105-8004-38f280443849",
pageId: "66697432-c33d-8055-8006-2c62cc084cad",
});
const tokensTabButton = page.getByRole("tab", { name: "Tokens" });
await tokensTabButton.click();
return {
workspacePage,
tokensUpdateCreateModal: workspacePage.tokensUpdateCreateModal,
tokenThemeUpdateCreateModal: workspacePage.tokenThemeUpdateCreateModal,
tokenThemesSetsSidebar: workspacePage.tokenThemesSetsSidebar,
tokenSetItems: workspacePage.tokenSetItems,
tokenSetGroupItems: workspacePage.tokenSetGroupItems,
tokensSidebar: workspacePage.tokensSidebar,
tokenContextMenuForToken: workspacePage.tokenContextMenuForToken,
tokenContextMenuForSet: workspacePage.tokenContextMenuForSet,
};
};
const setupTypographyTokensFile = async (page, options = {}) => {
return setupTokensFile(page, {
file: "workspace/get-file-typography-tokens.json",
@@ -178,14 +93,6 @@ const setupTypographyTokensFile = async (page, options = {}) => {
});
};
const setupTypographyTokensFileRender = async (page, options = {}) => {
return setupTokensFileRender(page, {
file: "workspace/get-file-typography-tokens.json",
fileFragment: "workspace/get-file-fragment-typography-tokens.json",
...options,
});
};
const testTokenCreationFlow = async (
page,
{
@@ -207,7 +114,7 @@ const testTokenCreationFlow = async (
const missingReferenceError = "Missing token references";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFileRender(page);
await setupEmptyTokensFile(page);
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -352,11 +259,8 @@ const unfoldTokenTree = async (tokensTabPanel, type, tokenName) => {
export {
setupEmptyTokensFile,
setupEmptyTokensFileRender,
setupTokensFile,
setupTokensFileRender,
setupTypographyTokensFile,
setupTypographyTokensFileRender,
testTokenCreationFlow,
unfoldTokenTree,
};

View File

@@ -40,6 +40,34 @@ const createToken = async (page, type, name, textFieldName, value) => {
await expect(tokensUpdateCreateModal).not.toBeVisible();
};
const createTokenCombobox = async (page, type, name, textFieldName, value) => {
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
const { tokensUpdateCreateModal } = await setupTokensFile(page, {
flags: ["enable-token-shadow"],
});
// Create base token
await tokensTabPanel
.getByRole("button", { name: `Add Token: ${type}` })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill(name);
const valueFill = tokensUpdateCreateModal.getByRole("combobox", {
name: textFieldName,
});
await valueFill.fill(value);
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await submitButton.click();
await expect(tokensUpdateCreateModal).not.toBeVisible();
};
const renameToken = async (page, oldName, newName) => {
const { tokensUpdateCreateModal, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page, { flags: ["enable-token-shadow"] });
@@ -401,13 +429,21 @@ test.describe("Remapping Tokens", () => {
test("User renames border radius token with alias references", async ({
page,
}) => {
const { tokensSidebar } = await setupTokensFile(page);
const { tokensSidebar } = await setupTokensFile(page, {
flags: ["enable-token-combobox", "enable-feature-token-input"],
});
// Create base border radius token
await createToken(page, "Border Radius", "base-radius", "Value", "4");
await createTokenCombobox(
page,
"Border Radius",
"base-radius",
"Value",
"4",
);
// Create derived border radius token
await createToken(
await createTokenCombobox(
page,
"Border Radius",
"card-radius",
@@ -443,13 +479,21 @@ test.describe("Remapping Tokens", () => {
tokensUpdateCreateModal,
tokensSidebar,
tokenContextMenuForToken,
} = await setupTokensFile(page);
} = await setupTokensFile(page, {
flags: ["enable-token-combobox", "enable-feature-token-input"],
});
// Create base border radius token
await createToken(page, "Border Radius", "radius-sm", "Value", "4");
await createTokenCombobox(
page,
"Border Radius",
"radius-sm",
"Value",
"4",
);
// Create derived border radius token
await createToken(
await createTokenCombobox(
page,
"Border Radius",
"button-radius",

View File

@@ -1,15 +0,0 @@
(ns app.main.data.nitrate
(:require
[app.main.data.modal :as modal]
[app.main.repo :as rp]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
(defn show-nitrate-popup
[]
(ptk/reify ::show-nitrate-popup
ptk/WatchEvent
(watch [_ _ _]
(->> (rp/cmd! ::get-nitrate-connectivity {})
(rx/map (fn [connectivity]
(modal/show :nitrate-form (or connectivity {}))))))))

View File

@@ -369,7 +369,7 @@
(dom/trigger-download filename blob))
(fn [error]
(js/console.error "error downloading font" error)
(st/emit! (ntf/error (tr "errors.generic")))))))))
(st/emit! (ntf/error (tr "errors.download-font")))))))))
on-delete-variant
(mf/use-fn

View File

@@ -16,7 +16,6 @@
[app.main.data.dashboard :as dd]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.data.nitrate :as dnt]
[app.main.data.notifications :as ntf]
[app.main.data.team :as dtm]
[app.main.refs :as refs]
@@ -305,7 +304,7 @@
(if (:nitrate-licence profile)
;; TODO update when org creation route is ready
(dom/open-new-window "/control-center/org/create")
(st/emit! (dnt/show-nitrate-popup)))))]
(st/emit! (modal/show :nitrate-form {})))))]
[:> dropdown-menu* props
@@ -551,7 +550,7 @@
(if (:nitrate-licence profile)
;; TODO update when org creation route is ready
(dom/open-new-window "/control-center/org/create")
(st/emit! (dnt/show-nitrate-popup)))))]
(st/emit! (modal/show :nitrate-form {})))))]
(if empty?
[:div {:class (stl/css :nitrate-orgs-empty)}
[:span {:class (stl/css :nitrate-penpot-icon)}

View File

@@ -6,7 +6,7 @@
[app.common.data.macros :as dm]
[app.config :as cf]
[app.main.data.event :as ev]
[app.main.data.nitrate :as dnt]
[app.main.data.modal :as modal]
[app.main.router :as rt]
[app.main.store :as st]
[app.main.ui.components.dropdown-menu :refer [dropdown-menu-item*]]
@@ -121,7 +121,7 @@
(let [handle-click
(mf/use-fn
(fn []
(st/emit! (dnt/show-nitrate-popup))))]
(st/emit! (modal/show :nitrate-form {}))))]
;; TODO add translations for this texts when we have the definitive ones
[:div {:class (stl/css :nitrate-banner :highlighted)}

View File

@@ -35,6 +35,8 @@
(def ^:private schema:options-dropdown
[:map
[:ref {:optional true} fn?]
[:class {:optional true} :string]
[:wrapper-ref {:optional true} :any]
[:on-click fn?]
[:options [:vector schema:option]]
[:selected {:optional true} :any]
@@ -83,6 +85,7 @@
:name name
:resolved (get option :resolved-value)
:ref ref
:role "option"
:focused (= id focused)
:on-click on-click}]
@@ -94,6 +97,7 @@
:aria-label (get option :aria-label)
:icon (get option :icon)
:ref ref
:role "option"
:focused (= id focused)
:dimmed (true? (:dimmed option))
:on-click on-click}]))))
@@ -101,15 +105,16 @@
(mf/defc options-dropdown*
{::mf/schema schema:options-dropdown}
[{:keys [ref on-click options selected focused empty-to-end align] :rest props}]
[{:keys [ref on-click options selected focused empty-to-end align wrapper-ref class] :rest props}]
(let [align
(d/nilv align :left)
props
(mf/spread-props props
{:class (stl/css-case :option-list true
:left-align (= align :left)
:right-align (= align :right))
{:class [class (stl/css-case :option-list true
:left-align (= align :left)
:right-align (= align :right))]
:ref wrapper-ref
:tab-index "-1"
:role "listbox"})

View File

@@ -40,6 +40,7 @@
:id id
:on-click on-click
:data-id id
:aria-label name
:data-testid "dropdown-option"}
(if selected

View File

@@ -0,0 +1,95 @@
(ns app.main.ui.ds.controls.utilities.utils
(:require
[app.common.data.macros :as dm]
[app.common.types.token :as cto]
[app.util.i18n :refer [tr]]
[cuerdas.core :as str]))
(defn- token->dropdown-option
[token]
{:id (str (get token :id))
:type :token
:resolved-value (get token :value)
:name (get token :name)})
(defn- generate-dropdown-options
[tokens no-sets]
(if (empty? tokens)
[{:type :empty
:label (if no-sets
(tr "ds.inputs.numeric-input.no-applicable-tokens")
(tr "ds.inputs.numeric-input.no-matches"))}]
(->> tokens
(map (fn [[type items]]
(cons {:group true
:type :group
:id (dm/str "group-" (name type))
:name (name type)}
(map token->dropdown-option items))))
(interpose [{:separator true
:id "separator"
:type :separator}])
(apply concat)
(vec)
(not-empty))))
(defn- extract-partial-brace-text
[s]
(when-let [start (str/last-index-of s "{")]
(subs s (inc start))))
(defn- filter-token-groups-by-name
[tokens filter-text]
(let [lc-filter (str/lower filter-text)]
(into {}
(keep (fn [[group tokens]]
(let [filtered (filter #(str/includes? (str/lower (:name %)) lc-filter) tokens)]
(when (seq filtered)
[group filtered]))))
tokens)))
(defn- sort-groups-and-tokens
"Sorts both the groups and the tokens inside them alphabetically.
Input:
A map where:
- keys are groups (keywords or strings, e.g. :dimensions, :colors)
- values are vectors of token maps, each containing at least a :name key
Example input:
{:dimensions [{:name \"tres\"} {:name \"quini\"}]
:colors [{:name \"azul\"} {:name \"rojo\"}]}
Output:
A sorted map where:
- groups are ordered alphabetically by key
- tokens inside each group are sorted alphabetically by :name
Example output:
{:colors [{:name \"azul\"} {:name \"rojo\"}]
:dimensions [{:name \"quini\"} {:name \"tres\"}]}"
[groups->tokens]
(into (sorted-map) ;; ensure groups are ordered alphabetically by their key
(for [[group tokens] groups->tokens]
[group (sort-by :name tokens)])))
(defn get-token-dropdown-options
[tokens filter-term]
(delay
(let [tokens (if (delay? tokens) @tokens tokens)
sorted-tokens (sort-groups-and-tokens tokens)
partial (extract-partial-brace-text filter-term)
options (if (seq partial)
(filter-token-groups-by-name sorted-tokens partial)
sorted-tokens)
no-sets? (nil? sorted-tokens)]
(generate-dropdown-options options no-sets?))))
(defn filter-tokens-for-input
[raw-tokens input-type]
(delay
(-> (deref raw-tokens)
(select-keys (get cto/tokens-by-input input-type))
(not-empty))))

View File

@@ -22,12 +22,10 @@
(mf/defc nitrate-form-modal*
{::mf/register modal/components
::mf/register-as :nitrate-form
::mf/wrap-props true}
[connectivity]
::mf/register-as :nitrate-form}
[]
(let [show-buttons (:licenses connectivity)
initial (mf/with-memo []
(let [initial (mf/with-memo []
{:subscription "yearly"})
form (fm/use-form :schema schema:nitrate-form
:initial initial)
@@ -57,35 +55,26 @@
"Sail ho shrouds spirits boom mizzenmast yardarm. Pinnace holystone mizzenmast quarter crow's nest nipperkin grog yardarm hempen halter furl."]
[:p {:class (stl/css :modal-text-large)}
"Deadlights jack lad schooner scallywag dance the hempen jig carouser broadside cable strike colors."]
(if show-buttons
[:& fm/form {:form form}
[:p {:class (stl/css :modal-text-large)}
[:p {:class (stl/css :modal-text-large)}
[:& fm/form {:form form}
[:& fm/radio-buttons
{:options [{:label "Price Tag Montly" :value "monthly"}
{:label "Price Tag Yearly (Discount)" :value "yearly"}]
:name :subscription
:class (stl/css :radio-btns)}]]]
[:& fm/radio-buttons
{:options [{:label "Price Tag Montly" :value "monthly"}
{:label "Price Tag Yearly (Discount)" :value "yearly"}]
:name :subscription
:class (stl/css :radio-btns)}]]
[:p {:class (stl/css :modal-text-large :modal-buttons-section)}
[:div {:class (stl/css :modal-buttons-section)}
[:> button* {:variant "primary"
:on-click on-click
:class (stl/css :modal-button)}
"UPGRADE TO NITRATE"]
[:div {:class (stl/css :modal-text-small :modal-info)}
"Cancel anytime before your next billing cycle."]]]
[:p {:class (stl/css :modal-text-large :modal-buttons-section)}
[:div {:class (stl/css :modal-buttons-section)}
[:> button* {:variant "primary"
:on-click on-click
:class (stl/css :modal-button)}
"UPGRADE TO NITRATE"]
[:div {:class (stl/css :modal-text-small :modal-info)}
"Cancel anytime before your next billing cycle."]]]
[:p {:class (stl/css :modal-text-medium)}
[:a {:class (stl/css :link)}
"See my current plan"]]]
[:div {:class (stl/css :contact)}
[:p {:class (stl/css :modal-text-large)}
"Contact us to upgrade to Nitrate:"]
[:p {:class (stl/css :modal-text-large)}
[:a {:class (stl/css :link) :href "mailto:sales@penpot.app"}
"sales@penpot.app"]]])]]]]))
[:p {:class (stl/css :modal-text-medium)}
[:a {:class (stl/css :link)}
"See my current plan"]]]]]]))

View File

@@ -89,8 +89,3 @@
padding: var(--sp-l) 0 0 0;
gap: 0;
}
.contact {
margin-block-start: $sz-96;
color: var(--color-foreground-primary);
}

View File

@@ -4,7 +4,6 @@
[app.common.data.macros :as dm]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.config :as cf]
[app.main.data.auth :as da]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
@@ -563,7 +562,7 @@
:recommended (= subscription-type "professional")
:show-button-cta (= subscription-type "professional")}])
(when (and (not= subscription-type "enterprise") (not (contains? cf/flags :nitrate)))
(when (not= subscription-type "enterprise")
[:> plan-card* {:card-title (tr "subscription.settings.enterprise")
:card-title-icon i/character-e
:price-value "$950"
@@ -576,21 +575,5 @@
:cta-link #(open-subscription-modal "enterprise" subscription)
:cta-text-with-icon (tr "subscription.settings.more-information")
:cta-link-with-icon go-to-pricing-page
:show-button-cta (= subscription-type "professional")}])
;; TODO add translations for this texts when we have the definitive ones
(when (and (contains? cf/flags :nitrate) (not (:nitrate-licence profile)))
[:> plan-card* {:card-title "Business Nitrate"
:card-title-icon i/character-n
:price-value "$25"
:price-period "org member"
:benefits-title (tr "subscription.settings.benefits.all-unlimited-benefits")
:benefits ["Crea organizaciones y añade personas, que usarán Penpot con las reglas que configures."
"Acceso exclusivo al Control Center"
"Lorem ipsum"]
:cta-text (tr "subscription.settings.subscribe")
;; TODO add link to open nitrate modal
:cta-link #(dom/open-new-window "https://penpot.app/nitrate")
:cta-text-with-icon (tr "subscription.settings.more-information")
:cta-link-with-icon go-to-pricing-page}])]]]))
:show-button-cta (= subscription-type "professional")}])]]]))

View File

@@ -143,8 +143,7 @@
(let [token-ids (set tokens-in-path-ids)
remaining-tokens (filter (fn [token]
(not (contains? token-ids (:id token))))
selected-token-set-tokens)
_ (prn "Remaining tokens:" remaining-tokens)]
selected-token-set-tokens)]
(seq remaining-tokens))))
delete-token

View File

@@ -2,6 +2,7 @@
(:require
[app.common.data.macros :as dm]
[app.main.ui.workspace.tokens.management.forms.controls.color-input :as color]
[app.main.ui.workspace.tokens.management.forms.controls.combobox :as combobox]
[app.main.ui.workspace.tokens.management.forms.controls.fonts-combobox :as fonts]
[app.main.ui.workspace.tokens.management.forms.controls.input :as input]
[app.main.ui.workspace.tokens.management.forms.controls.select :as select]))
@@ -16,4 +17,6 @@
(dm/export fonts/fonts-combobox*)
(dm/export fonts/composite-fonts-combobox*)
(dm/export select/select-indexed*)
(dm/export select/select-indexed*)
(dm/export combobox/combobox*)

View File

@@ -0,0 +1,290 @@
;; 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 INC
(ns app.main.ui.workspace.tokens.management.forms.controls.combobox
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[app.main.data.style-dictionary :as sd]
[app.main.ui.context :as muc]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.input :as ds]
[app.main.ui.ds.controls.shared.options-dropdown :refer [options-dropdown*]]
[app.main.ui.ds.controls.utilities.utils :as csu]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.forms :as fc]
[app.main.ui.workspace.tokens.management.forms.controls.floating :refer [use-floating-dropdown]]
[app.main.ui.workspace.tokens.management.forms.controls.navigation :refer [use-navigation]]
[app.main.ui.workspace.tokens.management.forms.controls.token-parsing :as tp]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :refer [tr]]
[app.util.object :as obj]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(defn- resolve-value
[tokens prev-token token-name value]
(let [valid-token-name?
(and (string? token-name)
(re-matches cto/token-name-validation-regex token-name))
token
{:value value
:name (if (or (not valid-token-name?) (str/blank? token-name))
"__PENPOT__TOKEN__NAME__PLACEHOLDER__"
token-name)}
tokens
(-> tokens
;; Remove previous token when renaming a token
(dissoc (:name prev-token))
(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 (:name token))]
(if resolved-value
(rx/of {:value resolved-value})
(rx/of {:error (first errors)}))))))))
(mf/defc combobox*
[{:keys [name tokens token token-type empty-to-end ref] :rest props}]
(let [form (mf/use-ctx fc/context)
token-name (get-in @form [:data :name] nil)
touched?
(and (contains? (:data @form) name)
(get-in @form [:touched name]))
error
(get-in @form [:errors name])
value
(get-in @form [:data name] "")
is-open* (mf/use-state false)
is-open (deref is-open*)
listbox-id (mf/use-id)
filter-term* (mf/use-state "")
filter-term (deref filter-term*)
options-ref (mf/use-ref nil)
dropdown-ref (mf/use-ref nil)
internal-ref (mf/use-ref nil)
nodes-ref (mf/use-ref nil)
wrapper-ref (mf/use-ref nil)
icon-button-ref (mf/use-ref nil)
ref (or ref internal-ref)
raw-tokens-by-type (mf/use-ctx muc/active-tokens-by-type)
filtered-tokens-by-type
(mf/with-memo [raw-tokens-by-type token-type]
(csu/filter-tokens-for-input raw-tokens-by-type token-type))
visible-options
(mf/with-memo [filtered-tokens-by-type token]
(if token
(tp/remove-self-token filtered-tokens-by-type token)
filtered-tokens-by-type))
dropdown-options
(mf/with-memo [visible-options filter-term]
(csu/get-token-dropdown-options visible-options (str "{" filter-term)))
set-option-ref
(mf/use-fn
(fn [node]
(let [state (mf/ref-val nodes-ref)
state (d/nilv state #js {})
id (dom/get-data node "id")
state (obj/set! state id node)]
(mf/set-ref-val! nodes-ref state))))
toggle-dropdown
(mf/use-fn
(mf/deps is-open)
(fn [event]
(dom/prevent-default event)
(swap! is-open* not)
(let [input-node (mf/ref-val ref)]
(dom/focus! input-node))))
resolve-stream
(mf/with-memo [token]
(if (contains? token :value)
(rx/behavior-subject (:value token))
(rx/subject)))
on-option-enter
(mf/use-fn
(mf/deps value resolve-stream name)
(fn [id]
(let [input-node (mf/ref-val ref)
final-val (tp/select-option-by-id id options-ref input-node value)]
(fm/on-input-change form name final-val true)
(rx/push! resolve-stream final-val)
(reset! filter-term* "")
(reset! is-open* false))))
{:keys [focused-id on-key-down]}
(use-navigation
{:is-open is-open
:options-ref options-ref
:nodes-ref nodes-ref
:toggle-dropdown toggle-dropdown
:is-open* is-open*
:on-enter on-option-enter})
on-change
(mf/use-fn
(mf/deps resolve-stream name form)
(fn [event]
(let [node (dom/get-target event)
value (dom/get-input-value node)
token (tp/active-token value node)]
(fm/on-input-change form name value)
(rx/push! resolve-stream value)
(if token
(do
(reset! is-open* true)
(reset! filter-term* (:partial token)))
(do
(reset! is-open* false)
(reset! filter-term* ""))))))
on-option-click
(mf/use-fn
(mf/deps value resolve-stream ref name)
(fn [event]
(let [input-node (mf/ref-val ref)
node (dom/get-current-target event)
id (dom/get-data node "id")
final-val (tp/select-option-by-id id options-ref input-node value)]
(fm/on-input-change form name final-val true)
(rx/push! resolve-stream final-val)
(reset! filter-term* "")
(reset! is-open* false)
(dom/focus! input-node)
(let [new-cursor (+ (str/index-of final-val "}") 1)]
(set! (.-selectionStart input-node) new-cursor)
(set! (.-selectionEnd input-node) new-cursor)))))
hint*
(mf/use-state {})
hint
(deref hint*)
props
(mf/spread-props props {:on-change on-change
:value value
:variant "comfortable"
:hint-message (:message hint)
:on-key-down on-key-down
:hint-type (:type hint)
:ref ref
:role "combobox"
:aria-activedescendant focused-id
:aria-controls listbox-id
:aria-expanded is-open
:slot-end
(when (some? @filtered-tokens-by-type)
(mf/html
[:> icon-button*
{:variant "action"
:icon i/arrow-down
:ref icon-button-ref
:tooltip-class (stl/css :button-tooltip)
:class (stl/css :invisible-button)
:tab-index "-1"
:aria-label (tr "ds.inputs.numeric-input.open-token-list-dropdown")
:on-mouse-down dom/prevent-default
:on-click toggle-dropdown}]))})
props
(if (and error touched?)
(mf/spread-props props {:hint-type "error"
:hint-message (:message error)})
props)
{:keys [style ready?]} (use-floating-dropdown is-open wrapper-ref dropdown-ref)]
(mf/with-effect [resolve-stream tokens token name token-name]
(let [subs (->> resolve-stream
(rx/debounce 300)
(rx/mapcat (partial resolve-value tokens token token-name))
(rx/map (fn [result]
(d/update-when result :error
(fn [error]
((:error/fn error) (:error/value error))))))
(rx/subs! (fn [{:keys [error value]}]
(let [touched? (get-in @form [:touched name])]
(when touched?
(if error
(do
(swap! form assoc-in [:extra-errors name] {:message error})
(reset! hint* {:message error :type "error"}))
(let [message (tr "workspace.tokens.resolved-value" value)]
(swap! form update :extra-errors dissoc name)
(reset! hint* {:message message :type "hint"}))))))))]
(fn []
(rx/dispose! subs))))
(mf/with-effect [dropdown-options]
(mf/set-ref-val! options-ref dropdown-options))
(mf/with-effect [is-open* ref wrapper-ref]
(when is-open
(let [handler (fn [event]
(let [wrapper-node (mf/ref-val wrapper-ref)
dropdown-node (mf/ref-val dropdown-ref)
target (dom/get-target event)]
(when (and wrapper-node dropdown-node
(not (dom/child? target wrapper-node))
(not (dom/child? target dropdown-node)))
(reset! is-open* false))))]
(.addEventListener js/document "mousedown" handler)
(fn []
(.removeEventListener js/document "mousedown" handler)))))
[:div {:ref wrapper-ref}
[:> ds/input* props]
(when ^boolean is-open
(let [options (if (delay? dropdown-options) @dropdown-options dropdown-options)]
(mf/portal
(mf/html
[:> options-dropdown* {:on-click on-option-click
:class (stl/css :dropdown)
:style {:visibility (if ready? "visible" "hidden")
:left (:left style)
:top (or (:top style) "unset")
:bottom (or (:bottom style) "unset")
:width (:width style)}
:id listbox-id
:options options
:focused focused-id
:selected nil
:align :right
:empty-to-end empty-to-end
:wrapper-ref dropdown-ref
:ref set-option-ref}])
(dom/get-body))))]))

View File

@@ -0,0 +1,19 @@
// 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 INC
@use "ds/_utils.scss" as *;
@use "ds/_sizes.scss" as *;
// TODO: Remove this after creating a non deprecated scrollbar class
@use "refactor/common-refactor.scss" as deprecated;
.dropdown {
// TODO: create new scrollbar class not deprecated.
@extend .new-scrollbar;
position: fixed;
max-block-size: $sz-400;
max-block-size: 200px;
overflow-y: auto;
}

View File

@@ -0,0 +1,71 @@
;; 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 INC
(ns app.main.ui.workspace.tokens.management.forms.controls.floating
(:require
[app.util.dom :as dom]
[rumext.v2 :as mf]))
(defn use-floating-dropdown [is-open wrapper-ref dropdown-ref]
(let [position* (mf/use-state nil)
position (deref position*)
ready* (mf/use-state false)
ready (deref ready*)
calculate-position
(fn [node]
(let [combobox-rect (dom/get-bounding-rect node)
dropdown-node (mf/ref-val dropdown-ref)
dropdown-height (if dropdown-node
(-> (dom/get-bounding-rect dropdown-node)
(:height))
0)
windows-height (-> (dom/get-window-size)
(:height))
space-below (- windows-height (:bottom combobox-rect))
open-up? (and dropdown-height
(> dropdown-height space-below))
position (if open-up?
{:bottom (str (- windows-height (:top combobox-rect) 12) "px")
:left (str (:left combobox-rect) "px")
:width (str (:width combobox-rect) "px")
:placement :top}
{:top (str (+ (:bottom combobox-rect) 4) "px")
:left (str (:left combobox-rect) "px")
:width (str (:width combobox-rect) "px")
:placement :bottom})]
(reset! ready* true)
(reset! position* position)))]
(mf/with-effect [is-open dropdown-ref wrapper-ref]
(when is-open
(let [handler (fn [event]
(let [dropdown-node (mf/ref-val dropdown-ref)
target (dom/get-target event)]
(when (or (nil? dropdown-node)
(not (instance? js/Node target))
(not (.contains dropdown-node target)))
(js/requestAnimationFrame
(fn []
(let [wrapper-node (mf/ref-val wrapper-ref)]
(reset! ready* true)
(calculate-position wrapper-node)))))))]
(handler nil)
(.addEventListener js/window "resize" handler)
(.addEventListener js/window "scroll" handler true)
(fn []
(.removeEventListener js/window "resize" handler)
(.removeEventListener js/window "scroll" handler true)))))
{:style position
:ready? ready
:recalculate calculate-position}))

View File

@@ -0,0 +1,114 @@
;; 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 INC
(ns app.main.ui.workspace.tokens.management.forms.controls.navigation
(:require
[app.util.dom :as dom]
[app.util.keyboard :as kbd]
[app.util.object :as obj]
[rumext.v2 :as mf]))
(defn- focusable-option?
[option]
(and (:id option)
(not= :group (:type option))
(not= :separator (:type option))))
(defn- first-focusable-id
[options]
(some #(when (focusable-option? %) (:id %)) options))
(defn next-focus-id
[options focused-id direction]
(let [focusable (filter focusable-option? options)
ids (map :id focusable)
idx (.indexOf (clj->js ids) focused-id)
next-idx (case direction
:down (min (dec (count ids)) (inc (if (= idx -1) -1 idx)))
:up (max 0 (dec (if (= idx -1) 0 idx))))]
(nth ids next-idx nil)))
(defn use-navigation
[{:keys [is-open options-ref nodes-ref is-open* toggle-dropdown on-enter]}]
(let [focused-id* (mf/use-state nil)
focused-id (deref focused-id*)
on-key-down
(mf/use-fn
(mf/deps is-open focused-id)
(fn [event]
(let [up? (kbd/up-arrow? event)
down? (kbd/down-arrow? event)
enter? (kbd/enter? event)
esc? (kbd/esc? event)
;; TODO: this should be optional?
open-dropdown (kbd/is-key? event "{")
close-dropdown (kbd/is-key? event "}")
options (mf/ref-val options-ref)
options (if (delay? options) @options options)]
(cond
down?
(do
(dom/prevent-default event)
(if is-open
(let [next-id (next-focus-id options focused-id :down)]
(reset! focused-id* next-id))
(do
(toggle-dropdown event)
(reset! focused-id* (first-focusable-id options)))))
up?
(when is-open
(dom/prevent-default event)
(let [next-id (next-focus-id options focused-id :up)]
(reset! focused-id* next-id)))
open-dropdown
(reset! is-open* true)
close-dropdown
(reset! is-open* false)
enter?
(do
(dom/prevent-default event)
(if is-open
(on-enter focused-id)
(do
(reset! focused-id* (first-focusable-id options))
(toggle-dropdown event))))
esc?
(do
(dom/prevent-default event)
(reset! is-open* false))
:else nil))))]
;; Initial focus on first option
(mf/with-effect [is-open options-ref]
(when is-open
(let [options (mf/ref-val options-ref)
options (if (delay? options) @options options)
first-id (first-focusable-id options)]
(when first-id
(reset! focused-id* first-id)))))
;; auto scroll when key down
(mf/with-effect [focused-id nodes-ref]
(when focused-id
(let [nodes (mf/ref-val nodes-ref)
node (obj/get nodes focused-id)]
(when node
(dom/scroll-into-view-if-needed!
node {:block "nearest"
:inline "nearest"})))))
{:focused-id focused-id
:on-key-down on-key-down}))

View File

@@ -0,0 +1,66 @@
;; 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 INC
(ns app.main.ui.workspace.tokens.management.forms.controls.token-parsing
(:require
[app.main.ui.ds.controls.select :refer [get-option]]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(defn extract-partial-token
[value cursor]
(let [text-before (subs value 0 cursor)
last-open (str/last-index-of text-before "{")
last-close (str/last-index-of text-before "}")]
(when (and last-open (or (nil? last-close) (> last-open last-close)))
{:start last-open
:partial (subs text-before (inc last-open))})))
(defn replace-active-token
[value cursor new-name]
(let [before (subs value 0 cursor)
last-open (str/last-index-of before "{")
last-close (str/last-index-of before "}")]
(if (and last-open
(or (nil? last-close)
(> last-open last-close)))
(let [after-start (subs value last-open)
close-pos (str/index-of after-start "}")
end (if close-pos
(+ last-open close-pos 1)
cursor)]
(str (subs value 0 last-open)
"{" new-name "}"
(subs value end)))
(str (subs value 0 cursor)
"{" new-name "}"
(subs value cursor)))))
(defn active-token [value input-node]
(let [cursor (.-selectionStart input-node)]
(extract-partial-token value cursor)))
(defn remove-self-token [filtered-options current-token]
(let [group (:type current-token)
current-id (:id current-token)
filtered-options (deref filtered-options)]
(update filtered-options group
(fn [options]
(remove #(= (:id %) current-id) options)))))
(defn select-option-by-id
[id options-ref input-node value]
(let [cursor (.-selectionStart input-node)
options (mf/ref-val options-ref)
options (if (delay? options) @options options)
option (get-option options id)
name (:name option)
final-val (replace-active-token value cursor name)]
final-val))

View File

@@ -8,8 +8,10 @@
(:require
[app.common.data :as d]
[app.common.types.tokens-lib :as ctob]
[app.config :as cf]
[app.main.refs :as refs]
[app.main.ui.workspace.tokens.management.forms.color :as color]
[app.main.ui.workspace.tokens.management.forms.controls :as token.controls]
[app.main.ui.workspace.tokens.management.forms.font-family :as font-family]
[app.main.ui.workspace.tokens.management.forms.generic-form :as generic]
[app.main.ui.workspace.tokens.management.forms.shadow :as shadow]
@@ -39,7 +41,10 @@
:token token})
text-case-props (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.text-case-value-enter")})
text-decoration-props (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.text-decoration-value-enter")})
font-weight-props (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.font-weight-value-enter")})]
font-weight-props (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.font-weight-value-enter")})
border-radius-props (if (contains? cf/flags :token-combobox)
(mf/spread-props props {:input-component token.controls/combobox*})
props)]
(case token-type
:color [:> color/form* props]
@@ -49,4 +54,5 @@
:text-case [:> generic/form* text-case-props]
:text-decoration [:> generic/form* text-decoration-props]
:font-weight [:> generic/form* font-weight-props]
:border-radius [:> generic/form* border-radius-props]
[:> generic/form* props])))

View File

@@ -21,6 +21,7 @@
[app.main.data.workspace.tokens.remapping :as remap]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.context :as muc]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
@@ -97,6 +98,10 @@
(and (:name token) (:value token))
(assoc (:name token) token)))
active-tokens-by-type
(mf/with-memo [tokens]
(delay (ctob/group-by-type tokens)))
schema
(mf/with-memo [tokens-tree-in-selected-set active-tab]
(make-schema tokens-tree-in-selected-set active-tab))
@@ -224,78 +229,80 @@
error-message (first error-messages)]
(swap! form assoc-in [:extra-errors :value] {:message error-message}))))))))]
[:> fc/form* {:class (stl/css :form-wrapper)
:form form
:on-submit on-submit}
[:div {:class (stl/css :token-rows)}
[(mf/provider muc/active-tokens-by-type) {:value active-tokens-by-type}
[:> fc/form* {:class (stl/css :form-wrapper)
:form form
:on-submit on-submit}
[:div {:class (stl/css :token-rows)}
[:> heading* {:level 2 :typography "headline-medium" :class (stl/css :form-modal-title)}
(if (= action "edit")
(tr "workspace.tokens.edit-token" token-type)
(tr "workspace.tokens.create-token" token-type))]
[:> heading* {:level 2 :typography "headline-medium" :class (stl/css :form-modal-title)}
(if (= action "edit")
(tr "workspace.tokens.edit-token" token-type)
(tr "workspace.tokens.create-token" token-type))]
[:div {:class (stl/css :input-row)}
[:> fc/form-input* {:id "token-name"
:name :name
:label (tr "workspace.tokens.token-name")
:placeholder (tr "workspace.tokens.enter-token-name" token-title)
:max-length max-input-length
:variant "comfortable"
:trim true
:auto-focus true}]]
[:div {:class (stl/css :input-row)}
[:> fc/form-input* {:id "token-name"
:name :name
:label (tr "workspace.tokens.token-name")
:placeholder (tr "workspace.tokens.enter-token-name" token-title)
:max-length max-input-length
:variant "comfortable"
:trim true
:auto-focus true}]]
[:div {:class (stl/css :input-row)}
(case type
:indexed
[:> input-component
{:token token
:tokens tokens
:tab active-tab
:value-subfield value-subfield
:handle-toggle on-toggle-tab}]
[:div {:class (stl/css :input-row)}
(case type
:indexed
[:> input-component
{:token token
:tokens tokens
:tab active-tab
:value-subfield value-subfield
:handle-toggle on-toggle-tab}]
:composite
[:> input-component
{:token token
:tokens tokens
:tab active-tab
:handle-toggle on-toggle-tab}]
:composite
[:> input-component
{:token token
:tokens tokens
:tab active-tab
:handle-toggle on-toggle-tab}]
[:> input-component
{:placeholder (or input-value-placeholder
(tr "workspace.tokens.token-value-enter"))
:label (tr "workspace.tokens.token-value")
:name :value
:token token
:tokens tokens}])]
[:> input-component
{:placeholder (or input-value-placeholder
(tr "workspace.tokens.token-value-enter"))
:label (tr "workspace.tokens.token-value")
:name :value
:token token
:token-type token-type
:tokens tokens}])]
[:div {:class (stl/css :input-row)}
[:> fc/form-input* {:id "token-description"
:name :description
:label (tr "workspace.tokens.token-description")
:placeholder (tr "workspace.tokens.token-description")
:max-length max-input-length
:variant "comfortable"
:is-optional true}]]
[:div {:class (stl/css :input-row)}
[:> fc/form-input* {:id "token-description"
:name :description
:label (tr "workspace.tokens.token-description")
:placeholder (tr "workspace.tokens.token-description")
:max-length max-input-length
:variant "comfortable"
:is-optional true}]]
[:div {:class (stl/css-case :button-row true
:with-delete (= action "edit"))}
(when (= action "edit")
[:> button* {:on-click on-delete-token
:on-key-down handle-key-down-delete
:class (stl/css :delete-btn)
:type "button"
:icon i/delete
:variant "secondary"}
(tr "labels.delete")])
[:div {:class (stl/css-case :button-row true
:with-delete (= action "edit"))}
(when (= action "edit")
[:> button* {:on-click on-delete-token
:on-key-down handle-key-down-delete
:class (stl/css :delete-btn)
:type "button"
:icon i/delete
:variant "secondary"}
(tr "labels.delete")])
[:> button* {:on-click on-cancel
:on-key-down handle-key-down-cancel
:type "button"
:id "token-modal-cancel"
:variant "secondary"}
(tr "labels.cancel")]
[:> button* {:on-click on-cancel
:on-key-down handle-key-down-cancel
:type "button"
:id "token-modal-cancel"
:variant "secondary"}
(tr "labels.cancel")]
[:> fc/form-submit* {:variant "primary"
:on-submit on-submit}
(tr "labels.save")]]]]))
[:> fc/form-submit* {:variant "primary"
:on-submit on-submit}
(tr "labels.save")]]]]]))

View File

@@ -50,45 +50,31 @@ Follow the steps below to enable the integration.
### Prerequisites
The project requires [Node.js](https://nodejs.org/) (tested with v22.x).
Following the installation of Node.js, the tools `corepack` and `npx`
should be available in your terminal.
The project requires [Node.js](https://nodejs.org/) (tested with v22.x
with corepack).
On Windows, use the Git Bash terminal to ensure compatibility with the provided scripts.
Following the installation of Node.js, the tools `pnpm` and `npx`
should be available in your terminal. For ensure corepack installed
and enabled correctly, just execute the `./scripts/setup`.
### 0. Clone the Appropriate Branch of the Repository
It is also required to have `caddy` executeable in the path, it is
used for start a local server for generate types documentation from
the current branch. If you want to run it outside devenv where all
dependencies are already provided, please download caddy from
[here](https://caddyserver.com/download).
> [!IMPORTANT]
> The branches are subject to change in the future.
> Be sure to check the instructions for the latest information on which branch to use.
You should probably be using penpot devenv, where all this
dependencies are already present and correctly setup. But nothing
prevents you execute this outside of devenv if you satisfy the
specified dependencies.
Clone the Penpot repository, using the proper branch depending on the
version of Penpot you want to use the MCP server with.
* For released versions of Penpot, use the `mcp-prod` branch:
```shell
git clone https://github.com/penpot/penpot.git --branch mcp-prod --depth 1
```
* For the latest development version of Penpot, use the `develop` branch:
```shell
git clone https://github.com/penpot/penpot.git --branch develop --depth 1
```
Then change into the `mcp` directory:
```shell
cd penpot/mcp
```
### 1. Build & Launch the MCP Server and the Plugin Server
If it's your first execution, install the required dependencies.
(If you are using the Penpot devenv, this step is not necessary, as dependencies are already installed.)
If it's your first execution, install the required dependencies:
```shell
cd mcp/
./scripts/setup
```
@@ -100,9 +86,9 @@ pnpm run bootstrap
This bootstrap command will:
* install dependencies for all components
* build all components
* start all components
* install dependencies for all components (`pnpm -r run install`)
* build all components (`pnpm -r run build`)
* start all components (`pnpm -r --parallel run start`)
### 2. Load the Plugin in Penpot and Establish the Connection
@@ -209,30 +195,31 @@ To add the Penpot MCP server to a Claude Code project, issue the command
This repository is a monorepo containing four main components:
1. **Common Types** (`packages/common/`):
1. **Common Types** (`common/`):
- Shared TypeScript definitions for request/response protocol
- Ensures type safety across server and plugin components
2. **Penpot MCP Server** (`packages/server/`):
2. **Penpot MCP Server** (`mcp-server/`):
- Provides MCP tools to LLMs for Penpot interaction
- Runs a WebSocket server accepting connections from the Penpot MCP plugin
- Implements request/response correlation with unique task IDs
- Handles task timeouts and proper error reporting
3. **Penpot MCP Plugin** (`packages/plugin/`):
3. **Penpot MCP Plugin** (`penpot-plugin/`):
- Connects to the MCP server via WebSocket
- Executes tasks in Penpot using the Plugin API
- Sends structured responses back to the server#
4. **Types Generator** (`types-generator/`):
- Generates data on API types for the MCP server (development use)
4. **Helper Scripts** (`python-scripts/`):
- Python scripts that prepare data for the MCP server (development use)
The core components are written in TypeScript, rendering interactions with the
Penpot Plugin API both natural and type-safe.
## Configuration
The Penpot MCP server can be configured using environment variables.
The Penpot MCP server can be configured using environment variables. All configuration
options use the `PENPOT_MCP_` prefix for consistency.
### Server Configuration
@@ -276,9 +263,3 @@ you may set the following environment variables to configure the two servers
* `PENPOT_MCP_SERVER_ADDRESS=<your-address>`: This sets the hostname or IP address
where the MCP server can be reached. The Penpot MCP Plugin uses this to construct
the WebSocket URL as `ws://<your-address>:<port>` (default port: `4402`).
## Development
* The [contribution guidelines for Penpot](../CONTRIBUTING.md) apply
* Auto-formatting: Use `pnpm run fmt`
* Generating API type data: See [types-generator/README.md](types-generator/README.md)

View File

@@ -1,6 +1,10 @@
You have access to Penpot tools in order to interact with a Penpot design project directly.
As a precondition, the user must connect the Penpot design project to the MCP server using the Penpot MCP Plugin.
IMPORTANT: When transferring styles from a Penpot design to code, make sure that you strictly adhere to the design.
NEVER make assumptions about missing values and don't get overly creative (e.g. don't pick your own colours and stick to
non-creative defaults such as white/black if you are lacking information).
# Executing Code
One of your key tools is the `execute_code` tool, which allows you to run JavaScript code using the Penpot Plugin API
@@ -44,8 +48,6 @@ Actual low-level shape types are `Rectangle`, `Path`, `Text`, `Ellipse`, `Image`
**Other Writable Properties**:
* `name` - Shape name
* `fills`, `strokes` - Styling properties
IMPORTANT: The contents of the arrays are read-only. You cannot modify individual fills/strokes; you need to replace the entire array to change them, e.g.
`shape.fills = [{ fillColor: "#FF0000", fillOpacity: 1 }]` to set a single red fill.
* `rotation`, `opacity`, `blocked`, `hidden`, `visible`
**Z-Order**:
@@ -97,12 +99,11 @@ Boards can have layout systems that automatically control the positioning and sp
- `dir`: "row" | "column" | "row-reverse" | "column-reverse"
- Padding: `topPadding`, `rightPadding`, `bottomPadding`, `leftPadding`, or combined `verticalPadding`, `horizontalPadding`
- To modify spacing: adjust `rowGap` and `columnGap` properties, not individual child positions.
Optionally, adjust individual child margins via `child.layoutChild`.
- Sizing: `verticalSizing` and `horizontalSizing` are NOT functional. You need to size manually for the time being.
Optionally, adjust indivudual child margins via `child.layoutChild`.
- When a board has flex layout,
- child positions are controlled by the layout system, not by individual x/y coordinates (unless `child.layoutChild.absolute` is true);
appending or inserting children automatically positions them according to the layout rules.
- CRITICAL: For dir="column" or dir="row", the order of the `children` array is reversed relative to the visual order!
- CRITICAL: For for dir="column" or dir="row", the order of the `children` array is reversed relative to the visual order!
Therefore, the element that appears first in the array, appears visually at the end (bottom/right) and vice versa.
ALWAYS BEAR IN MIND THAT THE CHILDREN ARRAY ORDER IS REVERSED FOR dir="column" OR dir="row"!
- CRITICAL: The FlexLayout method `board.flex.appendChild` is BROKEN. To append children to a flex layout board such that
@@ -209,6 +210,19 @@ Common tasks - Quick Reference (ALWAYS use penpotUtils for these):
});
Always validate against the root container that is supposed to contain the shapes.
# Visual Inspection of Designs
For many tasks, it can be critical to visually inspect the design. Remember to use the `export_shape` tool for this purpose!
# Revising Designs
* Before applying design changes, ask: "Would a designer consider this appropriate?"
* When dealing with containment issues, ask: Is the parent too small OR is the child too large?
Container sizes are usually intentional, check content first.
* Check for reasonable font sizes and typefaces
* The use of flex layouts is encouraged for cases where elements are arranged in rows or columns with consistent spacing/positioning.
Consider converting boards to flex layout when appropriate.
# Asset Libraries
Libraries in Penpot are collections of reusable design assets (components, colors, and typographies) that can be shared across files.
@@ -268,15 +282,14 @@ The token library: `penpot.library.local.tokens` (type: `TokenCatalog`)
* `tokens: Token[]` - All tokens in set
* `addToken(type: TokenType, name: string, value: TokenValueString): Token` - Creates a token, adding it to the set.
- `TokenType`: "color" | "dimension" | "spacing" | "typography" | "shadow" | "opacity" | "borderRadius" | "borderWidth" | "fontWeights" | "fontSizes" | "fontFamilies" | "letterSpacing" | "textDecoration" | "textCase"
- `value`: depends on the type of token (inspect `Token` and related types)
- Examples:
const token = set.addToken("color", "color.primary", "#0066FF"); // direct value
const token2 = set.addToken("color", "color.accent", "{color.primary}"); // reference to another token
`Token`: union type encompassing various token types, with common properties:
* `name: string` - Token name (typically structured, e.g. "color.base.white")
* `value` - Raw value (direct value or reference to another token like "{color.primary}")
* `resolvedValue` - Computed final value (follows references) - currently NOT working, do not use!
`Token`:
* `name: string` - Token name (may include group path like "color.base.white")
* `value: string | TokenValueString` - Raw value (may be direct value or reference to another token like "{color.primary}")
* `resolvedValue` - Computed final value (follows references)
* `type: TokenType`
Discovering tokens:
@@ -316,24 +329,5 @@ Removing tokens:
Simply set the respective property directly - token binding is automatically removed, e.g.
shape.fills = [{ fillColor: "#000000", fillOpacity: 1 }]; // Removes fill token
# Visual Inspection of Designs
For many tasks, it can be critical to visually inspect the design. Remember to use the `export_shape` tool for this purpose!
# Creating and Translating Designs
* When transferring styles from a Penpot design to code, make sure that you strictly adhere to the design.
NEVER make assumptions about missing values and don't get overly creative (e.g. don't pick your own colours and stick to
non-creative defaults such as white/black if you are lacking information).
# Revising Designs
* Before applying design changes, ask: "Would a designer consider this appropriate?"
* When dealing with containment issues, ask: Is the parent too small OR is the child too large?
Container sizes are usually intentional, check content first.
* Check for reasonable font sizes and typefaces
* The use of flex layouts is encouraged for cases where elements are arranged in rows or columns with consistent spacing/positioning.
Consider converting boards to flex layout when appropriate.
--
You have hereby read the 'Penpot High-Level Overview' and need not use a tool to read it again.

View File

@@ -53,10 +53,9 @@ export class ExecuteCodeTool extends Tool<ExecuteCodeArgs> {
"could come in handy later should be stored in `storage` instead of just a fleeting variable; " +
"you can also store functions and thus build up a library).\n" +
"Think of the code being executed as the body of a function: " +
"The tool call returns whatever you return in the applicable `return` statement, if any. " +
"You can return arbitrary JS objects; no need to apply JSON.stringify.\n" +
"The tool call returns whatever you return in the applicable `return` statement, if any.\n" +
"If an exception occurs, the exception's message will be returned to you.\n" +
"Any output that you generate via the `console` object will be returned to you separately; so you may use it " +
"Any output that you generate via the `console` object will be returned to you separately; so you may use it" +
"to track what your code is doing, but you should *only* do so only if there is an ACTUAL NEED for this! " +
"VERY IMPORTANT: Don't use logging prematurely! NEVER log the data you are returning, as you will otherwise receive it twice!\n" +
"VERY IMPORTANT: In general, try a simple approach first, and only if it fails, try more complex code that involves " +