Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Alejandro Alonso 2026-10-26 09:53:22 +01:00
commit e764b17c31
9 changed files with 194 additions and 24 deletions

View File

@ -58,6 +58,7 @@ jobs:
- name: Checkout trusted code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
fetch-depth: 0
ref: ${{ github.event.pull_request.base.sha || github.sha }}

View File

@ -61,8 +61,3 @@ jobs:
working-directory: ./exporter
run: |
./scripts/test
- name: Check no invoke_* in exporter JS glue
working-directory: ./render-wasm
run: |
./check-no-invoke-v export

View File

@ -60,13 +60,3 @@ jobs:
working-directory: ./render-wasm
run: |
./test
- name: Build
working-directory: ./render-wasm
run: |
NODE_ENV=production ./build frontend
- name: Check no invoke_*V in JS glue
working-directory: ./render-wasm
run: |
./check-no-invoke-v

View File

@ -66,7 +66,7 @@
[app.plugins.strokes :as strokes]
[app.plugins.system-events :as se]
[app.plugins.text :as text]
[app.plugins.tokens :refer [applied-tokens-plugin->applied-tokens expand-token-attrs token-attr? valid-token-resolution?]]
[app.plugins.tokens :refer [applied-tokens-plugin->applied-tokens expand-token-attrs per-side-stroke-attrs-restricted? per-side-stroke-widths-message token-attr? valid-token-resolution?]]
[app.plugins.utils :as u]
[app.util.http :as http]
[app.util.object :as obj]
@ -292,7 +292,7 @@
(and (not (ctt/per-side-stroke-shape? (:type (u/proxy->shape self))))
(some #(= :multiple (types.stroke/width-type %)) value))
(u/not-valid plugin-id :strokes "Per-side stroke widths require a board or rectangle")
(u/not-valid plugin-id :strokes per-side-stroke-widths-message)
:else
(st/emit! (dwsh/update-shapes [id] #(assoc % :strokes value) options))))))
@ -1840,6 +1840,11 @@
(some #(not (token-attr? %)) kw-attrs)
(u/not-valid plugin-id :applyToken attrs)
;; The uniform `strokeWidth` alias is exempt: it expands
;; to a uniform width on every side.
(per-side-stroke-attrs-restricted? file-id page-id [id] attrs)
(u/not-valid plugin-id :applyToken per-side-stroke-widths-message)
:else
(st/emit!
(-> (dwta/toggle-token {:token token

View File

@ -111,6 +111,20 @@
(or (= attr stroke-width-alias)
(cto/token-attr? attr))))
(def per-side-stroke-widths-message
"Per-side stroke widths require a board or rectangle")
(defn per-side-stroke-attrs?
"Returns true when the plugin attrs name at least one explicit per-side stroke
width attribute (`strokeWidthTop/Right/Bottom/Left`). The uniform
`strokeWidth` alias is not considered per-side: it expands to all four sides
with the same token and always resolves to a uniform width."
[attrs]
(boolean
(some #(contains? cto/per-side-stroke-width-keys
(token-attr-plugin->token-attr %))
attrs)))
(defn- token-name-schema
[file-id set-id token]
(let [tokens-lib (u/locate-tokens-lib file-id)
@ -181,23 +195,41 @@
(get (:name token))
(resolved-without-errors?))))
(defn per-side-stroke-attrs-restricted?
"Returns true when `attrs` name an explicit per-side stroke width while some
target shape on `page-id` does not support independent sides. Only boards and
rectangles accept them. Shapes absent from the page are ignored, because
token application only touches the active page."
[file-id page-id shape-ids attrs]
(when (per-side-stroke-attrs? attrs)
(boolean
(some (fn [id]
(let [shape (u/locate-shape file-id page-id id)]
(and (some? shape)
(not (cto/per-side-stroke-shape? (:type shape))))))
shape-ids))))
(defn- apply-token-to-shapes
[plugin-id file-id set-id id shape-ids attrs]
[plugin-id code file-id set-id id shape-ids attrs]
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :applyToken "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id code "Plugin doesn't have 'content:write' permission")
:else
(let [token (u/locate-token file-id set-id id)]
(cond
(nil? token)
(u/not-valid plugin-id :applyToSelected id)
(u/not-valid plugin-id code id)
(not (valid-token-resolution? file-id set-id id))
(u/not-valid plugin-id :applyToSelected (:value token))
(u/not-valid plugin-id code (:value token))
(some #(not (token-attr? %)) attrs)
(u/not-valid plugin-id :applyToSelected attrs)
(u/not-valid plugin-id code attrs)
(per-side-stroke-attrs-restricted? file-id (:current-page-id @st/state)
shape-ids attrs)
(u/not-valid plugin-id code per-side-stroke-widths-message)
:else
(st/emit!
@ -429,14 +461,15 @@
[:vector [:fn shape-proxy?]]
[: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))}
(apply-token-to-shapes plugin-id :applyToShapes file-id set-id id
(map #(obj/get % "$id") shapes) attrs))}
:applyToSelected
{:enumerable false
: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)))}))
(apply-token-to-shapes plugin-id :applyToSelected file-id set-id id selected attrs)))}))
;; === Token Set

View File

@ -545,6 +545,23 @@
(t/is (= #{:fill} (ptok/expand-token-attrs #{:fill})))
(t/is (= #{} (ptok/expand-token-attrs nil)))))
(t/deftest per-side-stroke-attrs?-distinguishes-sides-from-the-uniform-alias
(t/testing "the uniform alias is not per-side"
(t/is (false? (ptok/per-side-stroke-attrs? #{:stroke-width})))
(t/is (false? (ptok/per-side-stroke-attrs? #{"stroke-width"}))))
(t/testing "each explicit side is per-side, as keyword or string"
(t/is (true? (ptok/per-side-stroke-attrs? #{:stroke-width-top})))
(t/is (true? (ptok/per-side-stroke-attrs? #{:stroke-width-right})))
(t/is (true? (ptok/per-side-stroke-attrs? #{:stroke-width-bottom})))
(t/is (true? (ptok/per-side-stroke-attrs? #{:stroke-width-left})))
(t/is (true? (ptok/per-side-stroke-attrs? #{"stroke-width-left"}))))
(t/testing "unrelated and empty attrs are not per-side"
(t/is (false? (ptok/per-side-stroke-attrs? #{:fill})))
(t/is (false? (ptok/per-side-stroke-attrs? #{})))
(t/is (false? (ptok/per-side-stroke-attrs? nil)))))
(t/deftest shape-apply-token-accepts-padding-top
(t/async
done
@ -646,6 +663,76 @@
(done)))
0))))
;; Regression coverage for issue #12319.
;;
;; Per-side stroke widths are only supported on boards and rectangles, and the
;; `strokes` setter already rejects them elsewhere. The token path did not:
;; `shape.applyToken` / `token.applyToShapes` / `token.applyToSelected` bound a
;; per-side width token to an ellipse and left the stroke in the per-side state
;; the strokes setter refuses. The token path now rejects the four explicit side
;; properties on unsupported shapes while keeping the uniform `strokeWidth`
;; alias working.
(t/deftest ^:async apply-token-rejects-per-side-stroke-width-on-unsupported-shape
(let [plugin-id "00000000-0000-0000-0000-000000000000"
set-id (cthi/new-id! :token-set)
token-id (cthi/new-id! :stroke-width-token)
theme-id (cthi/new-id! :theme)
file (-> (ctht/sample-file-with-tokens
:file-id :file1
:lib-fn #(-> %
(ctob/add-set
(ctob/make-token-set :id set-id
:name "widths"))
(ctob/add-theme
(ctob/make-token-theme :id theme-id
:name "theme"
:sets #{"widths"}))
(ctob/add-token
set-id
(ctob/make-token :id token-id
:name "width.sm"
:type :stroke-width
:value 10)))
:status-fn #(ctos/set-tokens-status % #{theme-id} #{set-id})))
store (ths/setup-store file)
_ (set! st/state store)
_ (set! st/stream (ptk/input-stream store))
^js context (api/create-context plugin-id)
^js ellipse (.createEllipse context)
id (obj/get ellipse "$id")
^js library (.-library context)
^js local (.-local library)
^js catalog (.-tokens local)
^js token-set (.getSetById catalog (str set-id))
^js token (.getTokenById token-set (str token-id))
path [:files (:id file) :data :pages-index (cthf/current-page-id file)
:objects id :applied-tokens]]
(swap! store assoc-in [:plugins :flags plugin-id :throw-validation-errors] true)
(t/testing "shape.applyToken rejects an explicit per-side width"
(t/is (thrown? js/Error (.applyToken ellipse token #js ["strokeWidthTop"])))
(t/is (nil? (get-in @store path))))
(t/testing "token.applyToShapes rejects an explicit per-side width"
(t/is (thrown? js/Error (.applyToShapes token #js [ellipse] #js ["strokeWidthLeft"])))
(t/is (nil? (get-in @store path))))
(t/testing "token.applyToSelected rejects an explicit per-side width"
(swap! store assoc-in [:workspace-local :selected] #{id})
(t/is (thrown? js/Error (.applyToSelected token #js ["strokeWidthRight"])))
(t/is (nil? (get-in @store path))))
;; The uniform alias stays valid: it expands to the four sides with the same
;; token and resolves to a uniform width, which the strokes setter accepts.
(t/testing "the uniform strokeWidth alias is still accepted"
(.applyToken ellipse token #js ["strokeWidth"])
(await (async/wait-for
#(= "width.sm" (get-in @store (conj path :stroke-width-top)))
"uniform stroke width token applied"))
(t/is (= "width.sm" (get-in @store (conj path :stroke-width-top)))))))
(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"))))

View File

@ -28,6 +28,7 @@
- **plugins-runtime**: Fixed the `fontFamilies` token property mapping so `Shape.applyToken(token, ["fontFamilies"])` resolves to the canonical `:font-family` attribute and applied-token readback exposes the documented `fontFamilies` key instead of the undocumented singular `fontFamily`. Closes #11405.
- **plugins-runtime**: `TokenFontFamilies.resolvedValue` and the `fontFamilies` member of `TokenTypographyValue` now keep a family whose name has several words (`Hanken Grotesk`) as one entry, instead of splitting it into an array of its words, and keep names with words the resolver would otherwise read as colors or numbers (`Red Hat Display`, `Press Start 2P`) unchanged.
- **plugins-runtime**: Opening a plugin no longer drops running background plugins (`allowBackground`, such as the MCP plugin) from the runtime registry. They kept running, but their UI messages were no longer delivered to the plugin and they could no longer be unloaded, so MCP tasks timed out after another plugin was opened.
- **plugins-runtime**: `Shape.applyToken()`, `Token.applyToShapes()` and `Token.applyToSelected()` now reject an explicit per-side stroke width property (`strokeWidthTop`, `strokeWidthRight`, `strokeWidthBottom`, `strokeWidthLeft`) on shapes other than boards and rectangles, matching the `strokes` setter. The uniform `strokeWidth` property keeps working on every shape. Closes #12319.
## 1.5.0 (2026-07-08)

View File

@ -481,6 +481,55 @@ describe('Tokens', () => {
expect(keys).not.toContain('strokeWidthRight');
});
// Issue #12319: per-side widths are only supported on boards and
// rectangles. `applyToken` used to bind them on any shape, leaving an
// ellipse in the per-side state the `strokes` setter rejects.
test('applyToken rejects an explicit per-side width on an ellipse', (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'borderWidth',
name: unique('width.'),
value: '4',
});
const ellipse = ctx.penpot.createEllipse();
ctx.board.appendChild(ellipse);
expect(() => ellipse.applyToken(token, ['strokeWidthTop'])).toThrow();
expect(ellipse.tokens.strokeWidthTop).toBeUndefined();
});
test('applyToShapes rejects an explicit per-side width on an ellipse', (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'borderWidth',
name: unique('width.'),
value: '4',
});
const ellipse = ctx.penpot.createEllipse();
ctx.board.appendChild(ellipse);
expect(() =>
token.applyToShapes([ellipse], ['strokeWidthLeft']),
).toThrow();
expect(ellipse.tokens.strokeWidthLeft).toBeUndefined();
});
test('applyToken keeps accepting the uniform strokeWidth alias on an ellipse', async (ctx) => {
const set = activeSet(ctx, unique('set'));
const token = set.addToken({
type: 'borderWidth',
name: unique('width.'),
value: '4',
});
const ellipse = ctx.penpot.createEllipse();
ctx.board.appendChild(ellipse);
ellipse.applyToken(token, ['strokeWidth']);
await waitFor(() => ellipse.tokens.strokeWidth === token.name);
expect(ellipse.tokens.strokeWidth).toBe(token.name);
});
for (const side of [
'strokeWidthTop',
'strokeWidthRight',

View File

@ -4443,6 +4443,11 @@ export interface ShapeBase extends PluginData {
* several tokens with the same name in different sets, the actual token applied
* and the value set to the attributes will depend on which sets are active
* (and will change if different sets or themes are activated later).
*
* NOTE that the per-side stroke width properties (`strokeWidthTop`,
* `strokeWidthRight`, `strokeWidthBottom`, `strokeWidthLeft`) are only valid on
* boards and rectangles. Applying one to any other shape raises a validation
* error. The uniform `strokeWidth` property is accepted on every shape.
*/
applyToken(token: Token, properties?: TokenProperty[]): void;
@ -4931,6 +4936,10 @@ export interface TokenBase {
* several tokens with the same name in different sets, the actual token applied
* and the value set to the attributes will depend on which sets are active
* (and will change if different sets or themes are activated later).
*
* NOTE that the per-side stroke width properties are only valid on boards and
* rectangles; applying one to any other shape raises a validation error. The
* uniform `strokeWidth` property is accepted on every shape.
*/
applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void;