Compare commits

..

21 Commits

Author SHA1 Message Date
Eva Marco
49e43f030b ♻️ Refactor crud token test with new render 2026-02-23 16:09:13 +01:00
Eva Marco
80c7c8205b ♻️ Refactor apply token test to match new render 2026-02-23 13:53:37 +01:00
Dalai Felinto
27c4ddba10 📎 Use generic error when failing to download font
The font specific error string was never added to en.po (my own mistake).

Looking further into it, there is no need to add more work to
translators when a generic error goes a long way.

Specially since this is not expected to happen.
2026-02-23 09:42:51 +01:00
Marina López
16a067c0ae Add nitrate subscription plan card 2026-02-20 13:15:26 +01:00
Pablo Alba
90288e32d5 Show different info on nitrate dialog by connectivity 2026-02-20 10:19:25 +01:00
Luis de Dios
a82cf34d35 Merge pull request #8415 from oraios/mcp-prod
 MCP changes to improve handling of use cases 2 & 3
2026-02-19 16:01:10 +01:00
Alejandro Alonso
3f277b7daf Merge pull request #8416 from penpot/luis-revert-mcp-changes
Revert " MCP changes to improve handling of use cases 2 & 3…
2026-02-19 15:54:56 +01:00
Luis de Dios
21a1320f16 Revert " MCP changes to improve handling of use cases 2 & 3 (#8369)"
This reverts commit 0a54d25d5a.
2026-02-19 14:46:44 +01:00
Dominik Jain
0a54d25d5a MCP changes to improve handling of use cases 2 & 3 (#8369)
* 📎 Fix spelling errors

* 🚧 Temporary workaround for sizing options not working

Add instructions explaining that FlexLayout sizing options do not work.
Relates to https://github.com/penpot/penpot-mcp/issues/39

* 🚧 Temporary workaround for Token resolvedValue not working

Instruct LLM to not use this property.
To be reverted once #8341 is fixed.

*  Improve description of token values

*  Make clear that ExecuteCodeTool serialises automatically

LLMs sometimes decide to apply serialisation themselves, which is unnecessary,
and which this seeks to prevent.

* 🚧 Temporary workaround for fills/strokes being read-only

Add instructions to make the limintations.
Once #8357 is resolved, this can be reverted.

* ♻️ Move high-level instructions to the end

In this way, they can reasonably reference the more low-level concepts

* 📚 Add instructions on cloning and the branch to use

* 📚 Revise instructions on prerequisites

* Do not state that pnpm must be available after Node.js installation
  (it is installed by corepack)
* Do not state that caddy is required; it is required only when
  rebuilding the API documentation for the server, which is not
  a task relevant to regular users.
* Do not strongly suggest that MCP users should be using the devenv.
* Windows: Add pointer to use Git Bash

* 📚 Remove unnecessary details on what the boostrap script does

* 📚 Update information on repository structure

* 📚 Add section on 'Development' to README
2026-02-19 14:29:07 +01:00
Dominik Jain
7cf88359fa 📚 Add section on 'Development' to README 2026-02-18 20:22:34 +01:00
Dominik Jain
ea4c6c3998 📚 Update information on repository structure 2026-02-18 20:17:05 +01:00
Dominik Jain
f8dd02169c 📚 Remove unnecessary details on what the boostrap script does 2026-02-18 11:14:21 +01:00
Dominik Jain
ebdae2cf65 📚 Revise instructions on prerequisites
* Do not state that pnpm must be available after Node.js installation
  (it is installed by corepack)
* Do not state that caddy is required; it is required only when
  rebuilding the API documentation for the server, which is not
  a task relevant to regular users.
* Do not strongly suggest that MCP users should be using the devenv.
* Windows: Add pointer to use Git Bash
2026-02-18 11:11:25 +01:00
Dominik Jain
79d3469f36 📚 Add instructions on cloning and the branch to use 2026-02-18 10:56:21 +01:00
Dominik Jain
6a49b5df8c ♻️ Move high-level instructions to the end
In this way, they can reasonably reference the more low-level concepts
2026-02-17 13:16:21 +01:00
Dominik Jain
141847585e 🚧 Temporary workaround for fills/strokes being read-only
Add instructions to make the limintations.
Once #8357 is resolved, this can be reverted.
2026-02-17 12:51:48 +01:00
Dominik Jain
7a52550889 Make clear that ExecuteCodeTool serialises automatically
LLMs sometimes decide to apply serialisation themselves, which is unnecessary,
and which this seeks to prevent.
2026-02-15 22:20:38 +01:00
Dominik Jain
08fc6fe917 Improve description of token values 2026-02-12 17:45:50 +01:00
Dominik Jain
926d573d3e 🚧 Temporary workaround for Token resolvedValue not working
Instruct LLM to not use this property.
To be reverted once #8341 is fixed.
2026-02-12 17:24:44 +01:00
Dominik Jain
bac04f8a73 🚧 Temporary workaround for sizing options not working
Add instructions explaining that FlexLayout sizing options do not work.
Relates to https://github.com/penpot/penpot-mcp/issues/39
2026-02-12 12:37:24 +01:00
Dominik Jain
b4e815e787 📎 Fix spelling errors 2026-02-12 12:36:51 +01:00
30 changed files with 418 additions and 1028 deletions

View File

@@ -87,6 +87,10 @@
[: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)]
@@ -97,6 +101,11 @@
(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
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -105,7 +114,8 @@
[_ cfg]
(when (contains? cf/flags :nitrate)
{:get-team-org (partial get-team-org cfg)
:is-valid-user (partial is-valid-user cfg)}))
:is-valid-user (partial is-valid-user cfg)
:connectivity (partial get-connectivity cfg)}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; UTILS
@@ -128,3 +138,7 @@
(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,6 +262,7 @@
'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

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

View File

@@ -1,16 +1,17 @@
import { test, expect } from "@playwright/test";
import { WorkspacePage } from "../../pages/WorkspacePage";
import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage";
import { BaseWebSocketPage } from "../../pages/BaseWebSocketPage";
import {
setupEmptyTokensFile,
setupTokensFile,
setupTypographyTokensFile,
setupEmptyTokensFileRender,
setupTokensFileRender,
setupTypographyTokensFileRender,
testTokenCreationFlow,
unfoldTokenTree,
} from "./helpers";
test.beforeEach(async ({ page }) => {
await WorkspacePage.init(page);
await WasmWorkspacePage.init(page);
await WasmWorkspacePage.mockConfigFlags(page, ["enable-feature-design-tokens-v1"]);
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-tokens.json");
});
@@ -30,89 +31,6 @@ 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",
@@ -241,7 +159,7 @@ test.describe("Tokens - creation", () => {
const selfReferenceError = "Token has self reference";
const missingReferenceError = "Missing token references";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFileRender(page);
await tokensSidebar
.getByRole("button", { name: "Add Token: Color" })
@@ -403,7 +321,7 @@ test.describe("Tokens - creation", () => {
const missingReferenceError = "Missing token references";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFileRender(page);
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -548,7 +466,7 @@ test.describe("Tokens - creation", () => {
const missingReferenceError = "Missing token references";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFileRender(page);
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -684,7 +602,7 @@ test.describe("Tokens - creation", () => {
const missingReferenceError = "Missing token references";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFileRender(page);
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -800,7 +718,7 @@ test.describe("Tokens - creation", () => {
const missingReferenceError = "Missing token references";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFileRender(page);
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -914,7 +832,7 @@ test.describe("Tokens - creation", () => {
const emptyNameError = "Name should be at least 1 character";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page, { flags: ["enable-token-shadow"] });
await setupEmptyTokensFileRender(page, { flags: ["enable-token-shadow"] });
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -1095,7 +1013,7 @@ test.describe("Tokens - creation", () => {
page,
}) => {
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupTypographyTokensFile(page);
await setupTypographyTokensFileRender(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
@@ -1130,7 +1048,7 @@ test.describe("Tokens - creation", () => {
const emptyNameError = "Name should be at least 1 character";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page, { flags: ["enable-token-shadow"] });
await setupEmptyTokensFileRender(page, { flags: ["enable-token-shadow"] });
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -1315,7 +1233,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 setupEmptyTokensFile(page);
await setupEmptyTokensFileRender(page);
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -1562,7 +1480,7 @@ test.describe("Tokens - creation", () => {
test("User adds typography token with reference", async ({ page }) => {
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupTypographyTokensFile(page);
await setupTypographyTokensFileRender(page);
const newTokenTitle = "NewReference";
@@ -1612,7 +1530,7 @@ test.describe("Tokens - creation", () => {
test("User creates grouped color token", async ({ page }) => {
const { workspacePage, tokensUpdateCreateModal, tokensSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFileRender(page);
await tokensSidebar
.getByRole("button", { name: "Add Token: Color" })
@@ -1645,7 +1563,7 @@ test.describe("Tokens - creation", () => {
test("User cant create regular token with value missing", async ({
page,
}) => {
const { tokensUpdateCreateModal } = await setupEmptyTokensFile(page);
const { tokensUpdateCreateModal } = await setupEmptyTokensFileRender(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
@@ -1672,7 +1590,7 @@ test.describe("Tokens - creation", () => {
test("User duplicate color token", async ({ page }) => {
const { tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
await setupTokensFileRender(page);
await expect(tokensSidebar).toBeVisible();
@@ -1696,7 +1614,7 @@ test.describe("Tokens - creation", () => {
test("User creates grouped color token", async ({ page }) => {
const { workspacePage, tokensUpdateCreateModal, tokensSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFileRender(page);
await tokensSidebar.getByRole("button", { name: "Add Token: Color" }).click();
@@ -1725,7 +1643,7 @@ test("User creates grouped color token", async ({ page }) => {
});
test("User cant create regular token with value missing", async ({ page }) => {
const { tokensUpdateCreateModal } = await setupEmptyTokensFile(page);
const { tokensUpdateCreateModal } = await setupEmptyTokensFileRender(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
@@ -1752,7 +1670,7 @@ test("User cant create regular token with value missing", async ({ page }) => {
test("User duplicate color token", async ({ page }) => {
const { tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
await setupTokensFileRender(page);
await expect(tokensSidebar).toBeVisible();
@@ -1778,7 +1696,7 @@ test.describe("Tokens tab - edition", () => {
page,
}) => {
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupTypographyTokensFile(page);
await setupTypographyTokensFileRender(page);
await tokensSidebar
.getByRole("button")
@@ -1874,7 +1792,7 @@ test.describe("Tokens tab - edition", () => {
page,
}) => {
const { tokensUpdateCreateModal, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
await setupTokensFileRender(page);
await expect(tokensSidebar).toBeVisible();
@@ -1910,7 +1828,7 @@ test.describe("Tokens tab - edition", () => {
page,
}) => {
const { workspacePage, tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFileRender(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
@@ -1965,7 +1883,7 @@ test.describe("Tokens tab - edition", () => {
test.describe("Tokens tab - delete", () => {
test("User delete color token", async ({ page }) => {
const { tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
await setupTokensFileRender(page);
await expect(tokensSidebar).toBeVisible();
@@ -1985,7 +1903,7 @@ test.describe("Tokens tab - delete", () => {
});
test("User removes node and all child tokens", async ({ page }) => {
const { tokensSidebar } = await setupTokensFile(page);
const { tokensSidebar } = await setupTokensFileRender(page);
await expect(tokensSidebar).toBeVisible();

View File

@@ -1,5 +1,6 @@
import { test, expect } from "@playwright/test";
import { WorkspacePage } from "../../pages/WorkspacePage";
import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage";
const setupEmptyTokensFile = async (page, options = {}) => {
const { flags = [] } = options;
@@ -40,6 +41,45 @@ 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",
@@ -85,6 +125,51 @@ 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",
@@ -93,6 +178,14 @@ 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,
{
@@ -114,7 +207,7 @@ const testTokenCreationFlow = async (
const missingReferenceError = "Missing token references";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFileRender(page);
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -259,8 +352,11 @@ const unfoldTokenTree = async (tokensTabPanel, type, tokenName) => {
export {
setupEmptyTokensFile,
setupEmptyTokensFileRender,
setupTokensFile,
setupTokensFileRender,
setupTypographyTokensFile,
setupTypographyTokensFileRender,
testTokenCreationFlow,
unfoldTokenTree,
};

View File

@@ -40,34 +40,6 @@ 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"] });
@@ -429,21 +401,13 @@ test.describe("Remapping Tokens", () => {
test("User renames border radius token with alias references", async ({
page,
}) => {
const { tokensSidebar } = await setupTokensFile(page, {
flags: ["enable-token-combobox", "enable-feature-token-input"],
});
const { tokensSidebar } = await setupTokensFile(page);
// Create base border radius token
await createTokenCombobox(
page,
"Border Radius",
"base-radius",
"Value",
"4",
);
await createToken(page, "Border Radius", "base-radius", "Value", "4");
// Create derived border radius token
await createTokenCombobox(
await createToken(
page,
"Border Radius",
"card-radius",
@@ -479,21 +443,13 @@ test.describe("Remapping Tokens", () => {
tokensUpdateCreateModal,
tokensSidebar,
tokenContextMenuForToken,
} = await setupTokensFile(page, {
flags: ["enable-token-combobox", "enable-feature-token-input"],
});
} = await setupTokensFile(page);
// Create base border radius token
await createTokenCombobox(
page,
"Border Radius",
"radius-sm",
"Value",
"4",
);
await createToken(page, "Border Radius", "radius-sm", "Value", "4");
// Create derived border radius token
await createTokenCombobox(
await createToken(
page,
"Border Radius",
"button-radius",

View File

@@ -0,0 +1,15 @@
(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.download-font")))))))))
(st/emit! (ntf/error (tr "errors.generic")))))))))
on-delete-variant
(mf/use-fn

View File

@@ -16,6 +16,7 @@
[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]
@@ -304,7 +305,7 @@
(if (:nitrate-licence profile)
;; TODO update when org creation route is ready
(dom/open-new-window "/control-center/org/create")
(st/emit! (modal/show :nitrate-form {})))))]
(st/emit! (dnt/show-nitrate-popup)))))]
[:> dropdown-menu* props
@@ -550,7 +551,7 @@
(if (:nitrate-licence profile)
;; TODO update when org creation route is ready
(dom/open-new-window "/control-center/org/create")
(st/emit! (modal/show :nitrate-form {})))))]
(st/emit! (dnt/show-nitrate-popup)))))]
(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.modal :as modal]
[app.main.data.nitrate :as dnt]
[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! (modal/show :nitrate-form {}))))]
(st/emit! (dnt/show-nitrate-popup))))]
;; TODO add translations for this texts when we have the definitive ones
[:div {:class (stl/css :nitrate-banner :highlighted)}

View File

@@ -35,8 +35,6 @@
(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]
@@ -85,7 +83,6 @@
:name name
:resolved (get option :resolved-value)
:ref ref
:role "option"
:focused (= id focused)
:on-click on-click}]
@@ -97,7 +94,6 @@
: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}]))))
@@ -105,16 +101,15 @@
(mf/defc options-dropdown*
{::mf/schema schema:options-dropdown}
[{:keys [ref on-click options selected focused empty-to-end align wrapper-ref class] :rest props}]
[{:keys [ref on-click options selected focused empty-to-end align] :rest props}]
(let [align
(d/nilv align :left)
props
(mf/spread-props props
{:class [class (stl/css-case :option-list true
:left-align (= align :left)
:right-align (= align :right))]
:ref wrapper-ref
{:class (stl/css-case :option-list true
:left-align (= align :left)
:right-align (= align :right))
:tab-index "-1"
:role "listbox"})

View File

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

View File

@@ -1,95 +0,0 @@
(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,10 +22,12 @@
(mf/defc nitrate-form-modal*
{::mf/register modal/components
::mf/register-as :nitrate-form}
[]
::mf/register-as :nitrate-form
::mf/wrap-props true}
[connectivity]
(let [initial (mf/with-memo []
(let [show-buttons (:licenses connectivity)
initial (mf/with-memo []
{:subscription "yearly"})
form (fm/use-form :schema schema:nitrate-form
:initial initial)
@@ -55,26 +57,35 @@
"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."]
[: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)}]]]
(if show-buttons
[:& fm/form {:form form}
[:p {:class (stl/css :modal-text-large)}
[: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."]]]
[:& 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-medium)}
[:a {:class (stl/css :link)}
"See my current plan"]]]]]]))
[: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"]]])]]]]))

View File

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

View File

@@ -4,6 +4,7 @@
[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]
@@ -562,7 +563,7 @@
:recommended (= subscription-type "professional")
:show-button-cta (= subscription-type "professional")}])
(when (not= subscription-type "enterprise")
(when (and (not= subscription-type "enterprise") (not (contains? cf/flags :nitrate)))
[:> plan-card* {:card-title (tr "subscription.settings.enterprise")
:card-title-icon i/character-e
:price-value "$950"
@@ -575,5 +576,21 @@
: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")}])]]]))
: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}])]]]))

View File

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

View File

@@ -2,7 +2,6 @@
(: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]))
@@ -17,6 +16,4 @@
(dm/export fonts/fonts-combobox*)
(dm/export fonts/composite-fonts-combobox*)
(dm/export select/select-indexed*)
(dm/export combobox/combobox*)
(dm/export select/select-indexed*)

View File

@@ -1,290 +0,0 @@
;; 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

@@ -1,19 +0,0 @@
// 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

@@ -1,71 +0,0 @@
;; 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

@@ -1,114 +0,0 @@
;; 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

@@ -1,66 +0,0 @@
;; 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,10 +8,8 @@
(: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]
@@ -41,10 +39,7 @@
: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")})
border-radius-props (if (contains? cf/flags :token-combobox)
(mf/spread-props props {:input-component token.controls/combobox*})
props)]
font-weight-props (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.font-weight-value-enter")})]
(case token-type
:color [:> color/form* props]
@@ -54,5 +49,4 @@
: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,7 +21,6 @@
[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*]]
@@ -98,10 +97,6 @@
(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))
@@ -229,80 +224,78 @@
error-message (first error-messages)]
(swap! form assoc-in [:extra-errors :value] {:message error-message}))))))))]
[(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)}
[:> 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
:token-type token-type
: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
: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,31 +50,45 @@ Follow the steps below to enable the integration.
### Prerequisites
The project requires [Node.js](https://nodejs.org/) (tested with v22.x
with corepack).
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.
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`.
On Windows, use the Git Bash terminal to ensure compatibility with the provided scripts.
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).
### 0. Clone the Appropriate Branch of the Repository
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.
> [!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.
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 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.)
```shell
cd mcp/
./scripts/setup
```
@@ -86,9 +100,9 @@ pnpm run bootstrap
This bootstrap command will:
* install dependencies for all components (`pnpm -r run install`)
* build all components (`pnpm -r run build`)
* start all components (`pnpm -r --parallel run start`)
* install dependencies for all components
* build all components
* start all components
### 2. Load the Plugin in Penpot and Establish the Connection
@@ -195,31 +209,30 @@ 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** (`common/`):
1. **Common Types** (`packages/common/`):
- Shared TypeScript definitions for request/response protocol
- Ensures type safety across server and plugin components
2. **Penpot MCP Server** (`mcp-server/`):
2. **Penpot MCP Server** (`packages/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** (`penpot-plugin/`):
3. **Penpot MCP Plugin** (`packages/plugin/`):
- Connects to the MCP server via WebSocket
- Executes tasks in Penpot using the Plugin API
- Sends structured responses back to the server#
4. **Helper Scripts** (`python-scripts/`):
- Python scripts that prepare data for the MCP server (development use)
4. **Types Generator** (`types-generator/`):
- Generates data on API types 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. All configuration
options use the `PENPOT_MCP_` prefix for consistency.
The Penpot MCP server can be configured using environment variables.
### Server Configuration
@@ -263,3 +276,9 @@ 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,10 +1,6 @@
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
@@ -48,6 +44,8 @@ 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**:
@@ -99,11 +97,12 @@ 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 indivudual child margins via `child.layoutChild`.
Optionally, adjust individual child margins via `child.layoutChild`.
- Sizing: `verticalSizing` and `horizontalSizing` are NOT functional. You need to size manually for the time being.
- 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 for dir="column" or dir="row", the order of the `children` array is reversed relative to the visual order!
- CRITICAL: 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
@@ -210,19 +209,6 @@ 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.
@@ -282,14 +268,15 @@ 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`:
* `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)
`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!
* `type: TokenType`
Discovering tokens:
@@ -329,5 +316,24 @@ 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,9 +53,10 @@ 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.\n" +
"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" +
"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 " +