diff --git a/.opencode/skills/backport-commit/SKILL.md b/.opencode/skills/backport-commit/SKILL.md new file mode 100644 index 0000000000..c8092402db --- /dev/null +++ b/.opencode/skills/backport-commit/SKILL.md @@ -0,0 +1,90 @@ +--- +name: backport-commit +description: Port changes from a specific Git commit to the current branch by manually applying the diff, avoiding cherry-pick when it would introduce complex conflicts. +--- + +# Backport Commit + +Port changes from a specific Git commit to the current branch by manually +applying the diff, avoiding `git cherry-pick` when it would introduce +complex conflicts. + +## When to Use + +Use this skill whenever the user asks to backport a commit, especially when: + +- The commit touches multiple modules or files with significant divergence +- `git cherry-pick` is explicitly ruled out ("do not use cherry-pick") +- The target commit is old enough that conflicts are likely +- The commit introduces both source changes AND new files (tests, etc.) +- You need full control over how each hunk is applied + +## Workflow + +### 1. Identify the target commit + +```bash +# Verify the commit exists and understand what it does +git log --oneline -1 + +# Get the full diff (including new/deleted files) +git show + +# Capture the original commit message for later reuse +git log --format='%B' -1 +``` + +### 2. Identify affected modules + +From the file paths in the diff, determine which Penpot modules are affected +(frontend, backend, common, render-wasm, etc.) and read their `AGENTS.md` +files **before** making any changes. If a module has no `AGENTS.md`, skip +that step — verify with `ls /AGENTS.md` first. + +### 3. Read the current state of each affected file + +For every file the diff touches, read the current version on disk to understand +context and ensure correct placement before editing. + +### 4. Apply changes manually (the core of this approach) + +Process every hunk in the diff using the appropriate tool: + +| Diff action | Tool to use | +|-------------|-------------| +| Modify existing file | `edit` — use enough surrounding context in `oldString` to uniquely match the location | +| Add new file | `write` — include proper license header and namespace conventions matching project style | +| Delete file | `bash rm ` | +| Rename/move file | `bash mv `, then apply any content changes with `edit` | + +> **Tip:** Group nearby hunks from the same file into a single `edit` call. +> Use separate calls when hunks are far apart to keep `oldString` short and +> unambiguous. + +Repeat until **all** hunks in the diff are ported. + +### 5. Validate + +Run **lint**, **check-fmt**, and **tests** for every affected module (see each +module's `AGENTS.md` for the exact commands). If the formatter auto-fixes +indentation, verify the logic is still semantically correct. All checks must +pass before moving on. + +### 6. Port the changelog entry (if any) + +If the original commit added or modified a `CHANGES.md` entry, port that entry +too — adapting wording and version references for the target branch. + +### 7. Commit + +Ask the `commiter` sub-agent to create a commit. Stage all relevant files +(exclude unrelated untracked files) and provide the original commit message as +a reference, adapting it as needed for the target branch context. + +## Key Principles + +- **Context matters** — always read files before editing; never guess + indentation or surrounding code +- **Lint + format + test** — never skip validation before committing +- **Preserve intent** — keep the original commit message meaning; the + `commiter` agent handles formatting diff --git a/CHANGES.md b/CHANGES.md index 14b1ce37d7..380a52feeb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -35,7 +35,9 @@ ### :bug: Bugs fixed +- Fix release notes modal appearing behind the dashboard sidebar (by @RenzoMXD) [Github #8296](https://github.com/penpot/penpot/issues/8296) - Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041) +- Fix Plugin API token methods rejecting JS array of strings [Github #9162](https://github.com/penpot/penpot/issues/9162) ## 2.14.4 diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 220602d4e9..93243ffee4 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -497,7 +497,7 @@ (def ^:private schema:create-team [:map {:title "create-team"} - [:name [:string {:max 250}]] + [:name types.team/schema:team-name] [:features {:optional true} ::cfeat/features] [:id {:optional true} ::sm/uuid]]) @@ -591,7 +591,7 @@ (def ^:private schema:update-team [:map {:title "update-team"} - [:name [:string {:max 250}]] + [:name types.team/schema:team-name] [:id ::sm/uuid]]) (sv/defmethod ::update-team diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index 8fc553ff7e..66b412824b 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -767,3 +767,82 @@ (t/is (th/success? (th/command! data))) (t/is (= 1 (:call-count @mock)))))) +(t/deftest create-team-with-invalid-name + (let [profile (th/create-profile* 1 {:is-active true})] + + ;; name with a dot should fail + (let [data {::th/type :create-team + ::rpc/profile-id (:id profile) + :name "foo.bar"} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation))) + + ;; name with a colon should fail + (let [data {::th/type :create-team + ::rpc/profile-id (:id profile) + :name "foo:bar"} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation))) + + ;; name with a slash should fail + (let [data {::th/type :create-team + ::rpc/profile-id (:id profile) + :name "foo/bar"} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation))) + + ;; valid name should succeed + (let [data {::th/type :create-team + ::rpc/profile-id (:id profile) + :name "My Valid Team"} + out (th/command! data)] + (t/is (th/success? out))))) + +(t/deftest update-team-with-invalid-name + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id profile)})] + + ;; name with a dot should fail + (let [data {::th/type :update-team + ::rpc/profile-id (:id profile) + :id (:id team) + :name "foo.bar"} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation))) + + ;; name with a colon should fail + (let [data {::th/type :update-team + ::rpc/profile-id (:id profile) + :id (:id team) + :name "foo:bar"} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation))) + + ;; name with a slash should fail + (let [data {::th/type :update-team + ::rpc/profile-id (:id profile) + :id (:id team) + :name "foo/bar"} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation))) + + ;; valid name should succeed + (let [data {::th/type :update-team + ::rpc/profile-id (:id profile) + :id (:id team) + :name "My Valid Team"} + out (th/command! data)] + (t/is (th/success? out))))) + diff --git a/common/src/app/common/types/team.cljc b/common/src/app/common/types/team.cljc index ad9bac999c..73a4085819 100644 --- a/common/src/app/common/types/team.cljc +++ b/common/src/app/common/types/team.cljc @@ -20,6 +20,12 @@ (def schema:role [::sm/one-of {:title "TeamRole"} valid-roles]) +(def schema:team-name + [:and + [::sm/text {:max 250}] + [:fn {:error/code "errors.team-name-invalid-chars"} + (fn [s] (not (re-find #"[.:/]" s)))]]) + ;; FIXME: specify more fields (def schema:team [:map {:title "Team"} diff --git a/frontend/resources/images/features/2.15-mcp-01.gif b/frontend/resources/images/features/2.15-mcp-01.gif new file mode 100644 index 0000000000..365bb5ce63 Binary files /dev/null and b/frontend/resources/images/features/2.15-mcp-01.gif differ diff --git a/frontend/resources/images/features/2.15-mcp-02.gif b/frontend/resources/images/features/2.15-mcp-02.gif new file mode 100644 index 0000000000..b2030b9c12 Binary files /dev/null and b/frontend/resources/images/features/2.15-mcp-02.gif differ diff --git a/frontend/resources/images/features/2.15-mcp-03.gif b/frontend/resources/images/features/2.15-mcp-03.gif new file mode 100644 index 0000000000..1a79926e64 Binary files /dev/null and b/frontend/resources/images/features/2.15-mcp-03.gif differ diff --git a/frontend/resources/images/features/2.15-slide-0.jpg b/frontend/resources/images/features/2.15-slide-0.jpg new file mode 100644 index 0000000000..8e5b90b7dd Binary files /dev/null and b/frontend/resources/images/features/2.15-slide-0.jpg differ diff --git a/frontend/src/app/main/ui/auth/login.cljs b/frontend/src/app/main/ui/auth/login.cljs index fd601978b8..51c59f0c71 100644 --- a/frontend/src/app/main/ui/auth/login.cljs +++ b/frontend/src/app/main/ui/auth/login.cljs @@ -36,8 +36,8 @@ (mf/defc demo-warning* [] [:> context-notification* - {:level :warning - :content (tr "auth.demo-warning")}]) + {:level :warning} + (tr "auth.demo-warning")]) (defn create-demo-profile [] diff --git a/frontend/src/app/main/ui/dashboard/sidebar.scss b/frontend/src/app/main/ui/dashboard/sidebar.scss index 5adcb89a04..0b5da67e3f 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.scss +++ b/frontend/src/app/main/ui/dashboard/sidebar.scss @@ -26,7 +26,7 @@ margin: 0 var(--sp-l) 0 0; border-right: $b-1 solid var(--panel-border-color); background-color: var(--panel-background-color); - z-index: var(--z-index-dropdown); + z-index: var(--z-index-panels); } //SIDEBAR CONTENT COMPONENT diff --git a/frontend/src/app/main/ui/dashboard/team_form.cljs b/frontend/src/app/main/ui/dashboard/team_form.cljs index 5f6fdaae90..a2ef4d1490 100644 --- a/frontend/src/app/main/ui/dashboard/team_form.cljs +++ b/frontend/src/app/main/ui/dashboard/team_form.cljs @@ -7,7 +7,7 @@ (ns app.main.ui.dashboard.team-form (:require-macros [app.main.style :as stl]) (:require - [app.common.schema :as sm] + [app.common.types.team :as ctt] [app.main.data.common :as dcm] [app.main.data.event :as ev] [app.main.data.modal :as modal] @@ -24,7 +24,7 @@ (def ^:private schema:team-form [:map {:title "TeamForm"} - [:name [::sm/text {:max 250}]]]) + [:name ctt/schema:team-name]]) (defn- on-create-success [_form response] diff --git a/frontend/src/app/main/ui/onboarding/team_choice.cljs b/frontend/src/app/main/ui/onboarding/team_choice.cljs index bb896e49b5..0163de6c3d 100644 --- a/frontend/src/app/main/ui/onboarding/team_choice.cljs +++ b/frontend/src/app/main/ui/onboarding/team_choice.cljs @@ -8,6 +8,7 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.schema :as sm] + [app.common.types.team :as ctt] [app.main.data.common :as dcm] [app.main.data.event :as ev] [app.main.data.profile :as du] @@ -59,7 +60,7 @@ (def ^:private schema:team-form [:map {:title "TeamForm"} - [:name [::sm/text {:max 250}]] + [:name ctt/schema:team-name] [:role :keyword] [:emails {:optional true} [::sm/set ::sm/email]]]) diff --git a/frontend/src/app/main/ui/releases.cljs b/frontend/src/app/main/ui/releases.cljs index a5d2f5610b..7919fc045b 100644 --- a/frontend/src/app/main/ui/releases.cljs +++ b/frontend/src/app/main/ui/releases.cljs @@ -33,6 +33,7 @@ [app.main.ui.releases.v2-12] [app.main.ui.releases.v2-13] [app.main.ui.releases.v2-14] + [app.main.ui.releases.v2-15] [app.main.ui.releases.v2-2] [app.main.ui.releases.v2-3] [app.main.ui.releases.v2-4] @@ -105,4 +106,4 @@ (defmethod rc/render-release-notes "0.0" [params] - (rc/render-release-notes (assoc params :version "2.14"))) + (rc/render-release-notes (assoc params :version "2.15"))) diff --git a/frontend/src/app/main/ui/releases/v2_15.cljs b/frontend/src/app/main/ui/releases/v2_15.cljs new file mode 100644 index 0000000000..8c2f61580f --- /dev/null +++ b/frontend/src/app/main/ui/releases/v2_15.cljs @@ -0,0 +1,159 @@ +;; 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.releases.v2-15 + (:require-macros [app.main.style :as stl]) + (:require + [app.common.data.macros :as dm] + [app.main.ui.releases.common :as c] + [rumext.v2 :as mf])) + +(defmethod c/render-release-notes "2.15" + [{:keys [slide klass next finish navigate version]}] + (mf/html + (case slide + :start + [:div {:class (stl/css-case :modal-overlay true)} + [:div.animated {:class klass} + [:div {:class (stl/css :modal-container)} + [:img {:src "images/features/2.15-slide-0.jpg" + :class (stl/css :start-image) + :border "0" + :alt "Penpot 2.15 is here!"}] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-header)} + [:h1 {:class (stl/css :modal-title)} + "What’s new in Penpot?"] + + [:div {:class (stl/css :version-tag)} + (dm/str "Version " version)]] + + [:div {:class (stl/css :features-block)} + [:span {:class (stl/css :feature-title)} + "One major feature: the Penpot MCP Server, with infinite workflow possibilities"] + + [:p {:class (stl/css :feature-content)} + "This release marks a major MCP milestone: Penpot MCP moves from an early technical setup to an accessible in-app experience via hosted remote setup. Whether you already know MCP or are new to it, it's now zero-friction to connect your AI client and turn prompts into real actions on real design data."] + + [:p {:class (stl/css :feature-content)} + "With 2.15, we are opening the door to truly multi-directional workflows between design and code, while staying faithful to Penpot values: openness, freedom of choice, and respect for your data."] + + [:p {:class (stl/css :feature-content)} + "Let’s dive in!"]] + + [:div {:class (stl/css :navigation)} + [:button {:class (stl/css :next-btn) + :on-click next} "Continue"]]]]]] + + 0 + [:div {:class (stl/css-case :modal-overlay true)} + [:div.animated {:class klass} + [:div {:class (stl/css :modal-container)} + [:img {:src "images/features/2.15-mcp-01.gif" + :class (stl/css :start-image) + :border "0" + :alt "Penpot MCP Server: AI connected to real design context"}] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-header)} + [:h1 {:class (stl/css :modal-title)} + "Penpot MCP Server: AI connected to real design context"]] + + [:div {:class (stl/css :feature)} + [:p {:class (stl/css :feature-content)} + "Penpot MCP Server is the bridge between your AI client and your Penpot file. You describe what you need in natural language, your agent picks the right operation, and MCP translates that into real actions through Penpot APIs."] + + [:p {:class (stl/css :feature-content)} + "This is not a generic 'describe and generate' flow. It is context-aware work with components, tokens, pages, layers, and structure. In short: design expressed as code, now usable through your preferred AI assistant."] + + [:p {:class (stl/css :feature-content)} + "You can run MCP in two ways. Remote MCP is hosted and simpler to set up. Local MCP runs on your machine and gives advanced teams extra control. Same vision, different operating model."]] + + [:div {:class (stl/css :navigation)} + [:& c/navigation-bullets + {:slide slide + :navigate navigate + :total 4}] + + [:button {:on-click next + :class (stl/css :next-btn)} "Continue"]]]]]] + + 1 + [:div {:class (stl/css-case :modal-overlay true)} + [:div.animated {:class klass} + [:div {:class (stl/css :modal-container)} + [:img {:src "images/features/2.15-mcp-02.gif" + :class (stl/css :start-image) + :border "0" + :alt "Multi-directional workflows, from design to code and back"}] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-header)} + [:h1 {:class (stl/css :modal-title)} + "Multi-directional workflows, from design to code and back"]] + + [:div {:class (stl/css :feature)} + [:p {:class (stl/css :feature-content)} + "The biggest unlock in 2.15 is multi-directionality. You can move from design to code, and from code back to design, without losing intent or structure in the process."] + + [:p {:class (stl/css :feature-content)} + "• Generate semantic HTML/CSS from real layouts."] + [:p {:class (stl/css :feature-content)} + "• Translate tokens and styles into code variables."] + [:p {:class (stl/css :feature-content)} + "• Export only assets in use."] + [:p {:class (stl/css :feature-content)} + "• Validate design-code consistency."] + [:p {:class (stl/css :feature-content)} + "• Reorganize layers, apply naming rules, and automate repetitive design system maintenance."] + + [:p {:class (stl/css :feature-content)} + "This is where MCP becomes workflow infrastructure. Less manual glue work, fewer handoff gaps, and faster iterations between designers and developers."]] + + [:div {:class (stl/css :navigation)} + [:& c/navigation-bullets + {:slide slide + :navigate navigate + :total 4}] + + [:button {:on-click next + :class (stl/css :next-btn)} "Continue"]]]]]] + + 2 + [:div {:class (stl/css-case :modal-overlay true)} + [:div.animated {:class klass} + [:div {:class (stl/css :modal-container)} + [:img {:src "images/features/2.15-mcp-03.gif" + :class (stl/css :start-image) + :border "0" + :alt "Your stack, your model, your control"}] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-header)} + [:h1 {:class (stl/css :modal-title)} + "Your stack, your model, your control"]] + + [:div {:class (stl/css :feature)} + [:p {:class (stl/css :feature-content)} + "With MCP, you connect Penpot to the AI client and model you already trust. Cursor, Claude, VS Code, Codex, or another MCP-compatible setup: the workflow adapts to your stack, not the other way around."] + + [:p {:class (stl/css :feature-content)} + "You can run it hosted for a faster setup, or locally when you need tighter infrastructure control. The same applies to data boundaries: Penpot provides the bridge to your design context, while your team decides how and where AI runs."] + + [:p {:class (stl/css :feature-content)} + "In practice, this means teams can automate design and code workflows without giving up tool freedom, deployment control, or ownership of their process. +"]] + [:div {:class (stl/css :navigation)} + + [:& c/navigation-bullets + {:slide slide + :navigate navigate + :total 3}] + + [:button {:on-click finish + :class (stl/css :next-btn)} "Let's go"]]]]]]))) + diff --git a/frontend/src/app/main/ui/releases/v2_15.scss b/frontend/src/app/main/ui/releases/v2_15.scss new file mode 100644 index 0000000000..e5d13841eb --- /dev/null +++ b/frontend/src/app/main/ui/releases/v2_15.scss @@ -0,0 +1,102 @@ +// 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 "refactor/common-refactor.scss" as deprecated; + +.modal-overlay { + @extend .modal-overlay-base; +} + +.modal-container { + display: grid; + grid-template-columns: deprecated.$s-324 1fr; + height: deprecated.$s-500; + width: deprecated.$s-888; + border-radius: deprecated.$br-8; + background-color: var(--modal-background-color); + border: deprecated.$s-2 solid var(--modal-border-color); +} + +.start-image { + width: deprecated.$s-324; + border-radius: deprecated.$br-8 0 0 deprecated.$br-8; +} + +.modal-content { + padding: deprecated.$s-40; + display: grid; + grid-template-rows: auto 1fr deprecated.$s-32; + gap: deprecated.$s-24; + + a { + color: var(--button-primary-background-color-rest); + } +} + +.modal-header { + display: grid; + gap: deprecated.$s-8; +} + +.version-tag { + @include deprecated.flexCenter; + @include deprecated.headlineSmallTypography; + height: deprecated.$s-32; + width: deprecated.$s-96; + background-color: var(--communication-tag-background-color); + color: var(--communication-tag-foreground-color); + border-radius: deprecated.$br-8; +} + +.modal-title { + @include deprecated.headlineLargeTypography; + color: var(--modal-title-foreground-color); +} + +.features-block { + display: flex; + flex-direction: column; + gap: deprecated.$s-16; + width: deprecated.$s-440; +} + +.feature { + display: flex; + flex-direction: column; + gap: deprecated.$s-8; +} + +.feature-title { + @include deprecated.bodyLargeTypography; + color: var(--modal-title-foreground-color); +} + +.feature-content { + @include deprecated.bodyMediumTypography; + margin: 0; + color: var(--modal-text-foreground-color); +} + +.feature-list { + @include deprecated.bodyMediumTypography; + color: var(--modal-text-foreground-color); + list-style: disc; + display: grid; + gap: deprecated.$s-8; +} + +.navigation { + width: 100%; + display: grid; + grid-template-areas: "bullets button"; +} + +.next-btn { + @extend .button-primary; + width: deprecated.$s-100; + justify-self: flex-end; + grid-area: button; +} diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 230839c745..1a1144b086 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -1338,7 +1338,7 @@ {:enumerable false :schema [:tuple [:fn token-proxy?] - [:maybe [:set [:and ::sm/keyword [:fn token-attr?]]]]] + [:maybe [::sm/set [:and ::sm/keyword [:fn token-attr?]]]]] :fn (fn [token attrs] (let [token (u/locate-token file-id (obj/get token "$set-id") (obj/get token "$id")) kw-attrs (into #{} (map token-attr-plugin->token-attr attrs))] diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index 13888f0481..a6f1d3a850 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -49,8 +49,19 @@ (get map:token-attr->token-attr-plugin k k)) (defn token-attr-plugin->token-attr + "Resolve a plugin-side token attribute reference to its canonical + internal keyword. + + Accepts either a Clojure keyword (the canonical form, e.g. `:r1`, + `:fill`) or a string (the natural shape that arrives from a JS plugin + call such as `shape.applyToken(token, [\"fill\"])`). Converts strings + to keywords first, then maps verbose plugin-side aliases (e.g. + `:border-radius-top-left`) to their internal short form (e.g. `:r1`). + Inputs that are already in canonical form (`:r1`, `:fill`, `\"fill\"`, + …) pass through unchanged." [k] - (get map:token-attr-plugin->token-attr k k)) + (let [k (cond-> k (string? k) keyword)] + (get map:token-attr-plugin->token-attr k k))) (defn applied-tokens-plugin->applied-tokens [value] @@ -186,13 +197,13 @@ {:enumerable false :schema [:tuple [:vector [:fn shape-proxy?]] - [:maybe [:set [:and ::sm/keyword [:fn token-attr?]]]]] + [:maybe [::sm/set [:and ::sm/keyword [:fn token-attr?]]]]] :fn (fn [shapes attrs] (apply-token-to-shapes plugin-id file-id set-id id (map #(obj/get % "$id") shapes) attrs))} :applyToSelected {:enumerable false - :schema [:tuple [:maybe [:set [:and ::sm/keyword [:fn token-attr?]]]]] + :schema [:tuple [:maybe [::sm/set [:and ::sm/keyword [:fn token-attr?]]]]] :fn (fn [attrs] (let [selected (get-in @st/state [:workspace-local :selected])] (apply-token-to-shapes plugin-id file-id set-id id selected attrs)))})) diff --git a/frontend/test/frontend_tests/plugins/tokens_test.cljs b/frontend/test/frontend_tests/plugins/tokens_test.cljs new file mode 100644 index 0000000000..3c0d1cda1a --- /dev/null +++ b/frontend/test/frontend_tests/plugins/tokens_test.cljs @@ -0,0 +1,82 @@ +;; 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 frontend-tests.plugins.tokens-test + (:require + [app.plugins.tokens :as ptok] + [cljs.test :as t :include-macros true])) + +;; Regression coverage for issue #9162. +;; +;; Plugin code calling `shape.applyToken(token, ["fill"])` or +;; `token.applyToShapes([rect], ["fill"])` from JavaScript supplies a JS +;; array of strings. Penpot's plugin proxies expect a Clojure set of +;; keywords. Two coupled defects made these calls silently no-op (or, with +;; `throwValidationErrors` enabled, throw a "check error"): +;; +;; 1. `token-attr-plugin->token-attr` only consulted its alias map when +;; the input was already a keyword — string inputs like "fill" or +;; "border-radius-top-left" fell through to the identity branch +;; unchanged, so the downstream `cto/token-attr?` predicate (which +;; checks against a set of keywords) returned false. +;; 2. The `applyToken` / `applyToShapes` / `applyToSelected` schemas used +;; plain `[:set ...]`, which does not have a `:decode/json` +;; transformer for the JS array → Clojure set coercion. Penpot's +;; custom `[::sm/set ...]` does. Switching to the registered set type +;; lets the standard JSON decoder pipeline turn the JS argument into +;; a set of strings, after which the `[:and ::sm/keyword [:fn +;; token-attr?]]` element schema coerces each string to a keyword and +;; validates it. +;; +;; These helper-level tests pin the string-friendly conversion contract; +;; the schema-level fix is covered by the existing plugin integration +;; suite that exercises `applyToken` end-to-end. + +(t/deftest token-attr-plugin->token-attr-passes-canonical-form-through + ;; Both already-canonical short names and unaliased names pass through + ;; unchanged. + (t/is (= :fill (ptok/token-attr-plugin->token-attr :fill))) + (t/is (= :stroke-color (ptok/token-attr-plugin->token-attr :stroke-color))) + (t/is (= :r1 (ptok/token-attr-plugin->token-attr :r1))) + (t/is (= :p2 (ptok/token-attr-plugin->token-attr :p2)))) + +(t/deftest token-attr-plugin->token-attr-resolves-verbose-plugin-aliases + ;; Plugin-side verbose names (e.g. `:border-radius-top-left`) map to + ;; their canonical short internal form (`:r1`) so plugin authors can + ;; spell the corner explicitly without the engine having to know both. + (t/is (= :r1 (ptok/token-attr-plugin->token-attr :border-radius-top-left))) + (t/is (= :r2 (ptok/token-attr-plugin->token-attr :border-radius-top-right))) + (t/is (= :r3 (ptok/token-attr-plugin->token-attr :border-radius-bottom-right))) + (t/is (= :r4 (ptok/token-attr-plugin->token-attr :border-radius-bottom-left))) + (t/is (= :p1 (ptok/token-attr-plugin->token-attr :padding-top-left))) + (t/is (= :m3 (ptok/token-attr-plugin->token-attr :margin-bottom-right)))) + +(t/deftest token-attr-plugin->token-attr-coerces-string-input + ;; This is the actual regression — JS plugin calls supply strings. + (t/is (= :fill (ptok/token-attr-plugin->token-attr "fill"))) + (t/is (= :stroke-color (ptok/token-attr-plugin->token-attr "stroke-color"))) + ;; Verbose plugin aliases work via the string path too. + (t/is (= :r1 (ptok/token-attr-plugin->token-attr "border-radius-top-left"))) + (t/is (= :m3 (ptok/token-attr-plugin->token-attr "margin-bottom-right")))) + +(t/deftest token-attr?-accepts-keyword-input + (t/is (true? (boolean (ptok/token-attr? :fill)))) + (t/is (true? (boolean (ptok/token-attr? :stroke-color)))) + (t/is (true? (boolean (ptok/token-attr? :r1)))) + (t/is (true? (boolean (ptok/token-attr? :p2))))) + +(t/deftest token-attr?-accepts-string-input + ;; Same JS-array-of-strings reproducer as the issue, exercised at the + ;; predicate layer the plugin schemas call into. + (t/is (true? (boolean (ptok/token-attr? "fill")))) + (t/is (true? (boolean (ptok/token-attr? "stroke-color")))) + (t/is (true? (boolean (ptok/token-attr? "r1")))) + (t/is (true? (boolean (ptok/token-attr? "m3"))))) + +(t/deftest token-attr?-rejects-unknown-input + (t/is (false? (boolean (ptok/token-attr? :not-a-real-attr)))) + (t/is (false? (boolean (ptok/token-attr? "not-a-real-attr")))) + (t/is (false? (boolean (ptok/token-attr? nil))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index b7b53f8fbb..8260e62a45 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -19,6 +19,7 @@ [frontend-tests.logic.pasting-in-containers-test] [frontend-tests.main-errors-test] [frontend-tests.plugins.context-shapes-test] + [frontend-tests.plugins.tokens-test] [frontend-tests.svg-fills-test] [frontend-tests.tokens.import-export-test] [frontend-tests.tokens.logic.token-actions-test] @@ -61,6 +62,7 @@ 'frontend-tests.logic.groups-test 'frontend-tests.logic.pasting-in-containers-test 'frontend-tests.plugins.context-shapes-test + 'frontend-tests.plugins.tokens-test 'frontend-tests.svg-fills-test 'frontend-tests.tokens.import-export-test 'frontend-tests.tokens.logic.token-actions-test diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 978694deff..2b2d91ddd7 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -1480,6 +1480,10 @@ msgstr "The recovery token is invalid." msgid "errors.invalid-text" msgstr "Invalid text" +#: common/src/app/common/types/team.cljc:26 +msgid "errors.team-name-invalid-chars" +msgstr "The team name can't contain any of the following characters:'.', ':' or '/'" + #: src/app/main/ui/static.cljs:74 msgid "errors.invite-invalid" msgstr "Invite invalid"