Compare commits

..

9 Commits

Author SHA1 Message Date
Andrey Antukh
f07495ae95 🐛 Fix incorrect handling of numeric values layout padding and gap
Fixes https://github.com/penpot/penpot/issues/8113
2026-01-26 11:01:25 +01:00
Andrey Antukh
23d5fc7408 🐛 Prevent exception on open-new-window when no window is returned
Fixes https://github.com/penpot/penpot/issues/7787
2026-01-26 11:01:25 +01:00
Andrey Antukh
8632b18eec 🐛 Avoid json decoder liner limit exception by chunking
Happens only when we send large binary data serialized with transit
(mainly used for upload fonts data).
2026-01-26 11:01:25 +01:00
Andrey Antukh
33e650242c Add slugify to the filename on assets exportation
Fixes https://github.com/penpot/penpot/issues/8017
2026-01-26 11:01:25 +01:00
Andrey Antukh
e03ad25118 🔧 Backport CI workflow from develop 2026-01-26 10:18:24 +01:00
David Barragán Merino
5d7e6afd76 🔧 Fix a typo in an interpolation 2026-01-23 19:52:20 +01:00
Alonso Torres
15d369493b 🐛 Fix problem with z-index modal in dashboard (#8178) 2026-01-23 12:48:01 +01:00
Eva Marco
9c9b672e3e 🐛 Fix spanish translations on import export token modal (#8172) 2026-01-23 10:05:20 +01:00
Eva Marco
5146221513 🐛 Fix allow negative spread values on shadow token creation (#8167)
* 🐛 Fix allow negative spread values on shadow token creation

* 🎉 Add test
2026-01-23 09:50:36 +01:00
13 changed files with 287 additions and 33 deletions

View File

@@ -33,7 +33,7 @@ jobs:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
TEXT: |
🐳 *[PENPOT] Docker image available: {{ github.ref_name }}*
🐳 *[PENPOT] Docker image available: ${{ github.ref_name }}*
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
@infra

View File

@@ -21,7 +21,7 @@ concurrency:
jobs:
lint:
name: "Linter"
runs-on: ubuntu-24.04
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
steps:
@@ -34,7 +34,7 @@ jobs:
test-common:
name: "Common Tests"
runs-on: ubuntu-24.04
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
steps:
@@ -53,7 +53,8 @@ jobs:
test-plugins:
name: Plugins Runtime Linter & Tests
runs-on: ubuntu-24.04
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
steps:
- uses: actions/checkout@v4
@@ -98,7 +99,7 @@ jobs:
test-frontend:
name: "Frontend Tests"
runs-on: ubuntu-24.04
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
steps:
@@ -119,7 +120,7 @@ jobs:
test-render-wasm:
name: "Render WASM Tests"
runs-on: ubuntu-24.04
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
steps:
@@ -143,7 +144,7 @@ jobs:
test-backend:
name: "Backend Tests"
runs-on: ubuntu-24.04
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
services:
@@ -182,7 +183,7 @@ jobs:
test-library:
name: "Library Tests"
runs-on: ubuntu-24.04
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
steps:
@@ -196,7 +197,7 @@ jobs:
build-integration:
name: "Build Integration Bundle"
runs-on: ubuntu-24.04
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
steps:
@@ -217,7 +218,7 @@ jobs:
test-integration-1:
name: "Integration Tests 1/4"
runs-on: ubuntu-24.04
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
needs: build-integration
@@ -247,7 +248,7 @@ jobs:
test-integration-2:
name: "Integration Tests 2/4"
runs-on: ubuntu-24.04
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
needs: build-integration
@@ -277,7 +278,7 @@ jobs:
test-integration-3:
name: "Integration Tests 3/4"
runs-on: ubuntu-24.04
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
needs: build-integration
@@ -307,7 +308,7 @@ jobs:
test-integration-4:
name: "Integration Tests 4/4"
runs-on: ubuntu-24.04
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
needs: build-integration

View File

@@ -30,6 +30,13 @@
- Fix dropdown option width in Guides columns dropdown [Taiga #12959](https://tree.taiga.io/project/penpot/issue/12959)
- Fix typos on download modal [Taiga #12865](https://tree.taiga.io/project/penpot/issue/12865)
- Fix unhandled exception tokens creation dialog [Github #8110](https://github.com/penpot/penpot/issues/8110)
- Fix allow negative spread values on shadow token creation [Taiga #13167](https://tree.taiga.io/project/penpot/issue/13167)
- Fix spanish translations on import export token modal [Taiga #13171](https://tree.taiga.io/project/penpot/issue/13171)
- Remove whitespaces from asset export filename [Github #8133](https://github.com/penpot/penpot/pull/8133)
- Fix exception on uploading large fonts [Github #8135](https://github.com/penpot/penpot/pull/8135)
- Fix unhandled exception on open-new-window helper [Github #7787](https://github.com/penpot/penpot/issues/7787)
- Fix incorrect handling of input values on layout gap and padding inputs [Github #8113](https://github.com/penpot/penpot/issues/8113)
## 2.12.1

View File

@@ -27,7 +27,17 @@
[app.rpc.helpers :as rph]
[app.rpc.quotes :as quotes]
[app.storage :as sto]
[app.util.services :as sv]))
[app.storage.tmp :as tmp]
[app.util.services :as sv]
[datoteka.io :as io])
(:import
java.io.InputStream
java.io.OutputStream
java.io.SequenceInputStream
java.util.Collections))
(set! *warn-on-reflection* true)
(def valid-weight #{100 200 300 400 500 600 700 800 900 950})
(def valid-style #{"normal" "italic"})
@@ -105,7 +115,7 @@
(defn create-font-variant
[{:keys [::sto/storage ::db/conn]} {:keys [data] :as params}]
(letfn [(generate-missing! [data]
(letfn [(generate-missing [data]
(let [data (media/run {:cmd :generate-fonts :input data})]
(when (and (not (contains? data "font/otf"))
(not (contains? data "font/ttf"))
@@ -116,8 +126,26 @@
:hint "invalid font upload, unable to generate missing font assets"))
data))
(process-chunks [chunks]
(let [tmp (tmp/tempfile :prefix "penpot.tempfont." :suffix "")
streams (map io/input-stream chunks)
streams (Collections/enumeration streams)]
(with-open [^OutputStream output (io/output-stream tmp)
^InputStream input (SequenceInputStream. streams)]
(io/copy input output))
tmp))
(join-chunks [data]
(reduce-kv (fn [data mtype content]
(if (vector? content)
(assoc data mtype (process-chunks content))
data))
data
data))
(prepare-font [data mtype]
(when-let [resource (get data mtype)]
(let [hash (sto/calculate-hash resource)
content (-> (sto/content resource)
(sto/wrap-with-hash hash))]
@@ -156,7 +184,8 @@
:otf-file-id (:id otf)
:ttf-file-id (:id ttf)}))]
(let [data (generate-missing! data)
(let [data (join-chunks data)
data (generate-missing data)
assets (persist-fonts-files! data)
result (insert-font-variant! assets)]
(vary-meta result assoc ::audit/replace-props (update params :data (comp vec keys))))))

View File

@@ -36,7 +36,7 @@
{:path path
:mtype (mime/get type)
:name name
:filename (str/concat name (mime/get-extension type))
:filename (str/concat (str/slug name) (mime/get-extension type))
:id task-id}))
(defn create-zip

View File

@@ -1256,6 +1256,192 @@ test.describe("Tokens: Tokens Tab", () => {
).toBeEnabled();
});
test("User creates shadow token with negative spread", async ({ page }) => {
const emptyNameError = "Name should be at least 1 character";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page, {flags: ["enable-token-shadow"]});
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
const addTokenButton = tokensTabPanel.getByRole("button", {
name: `Add Token: Shadow`,
});
await addTokenButton.click();
await expect(tokensUpdateCreateModal).toBeVisible();
await expect(
tokensUpdateCreateModal.getByPlaceholder(
"Enter a value or alias with {alias}",
),
).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
const colorField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Color",
});
const offsetXField = tokensUpdateCreateModal.getByRole("textbox", {
name: "X",
});
const offsetYField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Y",
});
const blurField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Blur",
});
const spreadField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Spread",
});
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
// 1. Check default values
await expect(offsetXField).toHaveValue("4");
await expect(offsetYField).toHaveValue("4");
await expect(blurField).toHaveValue("4");
await expect(spreadField).toHaveValue("0");
// 2. Name filled + empty value → disabled
await nameField.fill("my-token");
await expect(submitButton).toBeDisabled();
// 3. Invalid color → disabled + error message
await colorField.fill("1");
await expect(
tokensUpdateCreateModal.getByText("Invalid color value: 1"),
).toBeVisible();
await expect(submitButton).toBeDisabled();
await colorField.fill("{missing-reference}");
await expect(
tokensUpdateCreateModal.getByText(
"Missing token references: missing-reference",
),
).toBeVisible();
// 4. Empty name → disabled + error message
await nameField.fill("");
const emptyNameErrorNode =
tokensUpdateCreateModal.getByText(emptyNameError);
await expect(emptyNameErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
//
// ------- SUCCESSFUL FIELDS -------
//
// 5. Valid color → resolved
await colorField.fill("red");
await expect(
tokensUpdateCreateModal.getByText("Resolved value: #ff0000"),
).toBeVisible();
const colorSwatch = tokensUpdateCreateModal.getByTestId(
"token-form-color-bullet",
);
await colorSwatch.click();
const rampSelector = tokensUpdateCreateModal.getByTestId(
"value-saturation-selector",
);
await expect(rampSelector).toBeVisible();
await rampSelector.click({ position: { x: 50, y: 50 } });
await expect(
tokensUpdateCreateModal.getByText("Resolved value:"),
).toBeVisible();
const sliderOpacity = tokensUpdateCreateModal.getByTestId("slider-opacity");
await sliderOpacity.click({ position: { x: 50, y: 0 } });
await expect(
tokensUpdateCreateModal.getByRole("textbox", { name: "Color" }),
).toHaveValue(/rgba\s*\([^)]*\)/);
// 6. Valid offset → resolved
await offsetXField.fill("3 + 3");
await expect(
tokensUpdateCreateModal.getByText("Resolved value: 6"),
).toBeVisible();
await offsetYField.fill("3 + 7");
await expect(
tokensUpdateCreateModal.getByText("Resolved value: 10"),
).toBeVisible();
// 7. Valid blur → resolved
await blurField.fill("3 + 1");
await expect(
tokensUpdateCreateModal.getByText("Resolved value: 4"),
).toBeVisible();
// 8. Valid spread → resolved
await spreadField.fill("3 - 3");
await expect(
tokensUpdateCreateModal.getByText("Resolved value: 0"),
).toBeVisible();
await spreadField.fill("1 - 3");
await expect(
tokensUpdateCreateModal.getByText("Resolved value: -2"),
).toBeVisible();
await nameField.fill("my-token");
await expect(submitButton).toBeEnabled();
await submitButton.click();
await expect(
tokensTabPanel.getByRole("button", { name: "my-token" }),
).toBeEnabled();
//
// ------- SECOND TOKEN WITH VALID REFERENCE -------
//
await addTokenButton.click();
await nameField.fill("my-token-2");
const referenceToggle =
tokensUpdateCreateModal.getByTestId("reference-opt");
const compositeToggle =
tokensUpdateCreateModal.getByTestId("composite-opt");
await referenceToggle.click();
const referenceInput = tokensUpdateCreateModal.getByPlaceholder(
"Enter a token shadow alias",
);
await expect(referenceInput).toBeVisible();
await compositeToggle.click();
await expect(colorField).toBeVisible();
await referenceToggle.click();
const referenceField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Reference",
});
await referenceField.fill("{my-token}");
await expect(
tokensUpdateCreateModal.getByText(
"Resolved value: - X: 6 - Y: 10 - Blur: 4 - Spread: -2",
),
).toBeVisible();
await expect(submitButton).toBeEnabled();
await submitButton.click();
await expect(
tokensTabPanel.getByRole("button", { name: "my-token-2" }),
).toBeEnabled();
});
test("User creates typography token", async ({ page }) => {
const emptyNameError = "Name should be at least 1 character";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =

View File

@@ -24,6 +24,20 @@
[cuerdas.core :as str]
[potok.v2.core :as ptk]))
(def ^:const default-chunk-size
(* 1024 1024 4)) ;; 4MiB
(defn- chunk-array
[data chunk-size]
(let [total-size (alength data)]
(loop [offset 0
chunks []]
(if (< offset total-size)
(let [end (min (+ offset chunk-size) total-size)
chunk (.subarray ^js data offset end)]
(recur end (conj chunks chunk)))
chunks))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; General purpose events & IMPL
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -116,9 +130,9 @@
(not= hhea-descender win-descent)
(and f-selection (or
(not= hhea-ascender os2-ascent)
(not= hhea-descender os2-descent))))]
{:content {:data (js/Uint8Array. data)
(not= hhea-descender os2-descent))))
data (js/Uint8Array. data)]
{:content {:data (chunk-array data default-chunk-size)
:name name
:type type}
:font-family (or family "")

View File

@@ -364,7 +364,7 @@
"Parses shadow spread value (non-negative number)."
[value]
(let [parsed (parse-sd-token-general-value value)
valid? (and (:value parsed) (>= (:value parsed) 0))]
valid? (:value parsed)]
(cond
valid?
parsed

View File

@@ -628,6 +628,7 @@
width: $sz-400;
padding: var(--sp-xxxl);
background-color: var(--color-background-primary);
z-index: var(--z-index-set);
&.hero {
top: px2rem(216);

View File

@@ -375,7 +375,7 @@
(mf/use-fn
(mf/deps on-change ids)
(fn [value attr event]
(if (or (string? value) (int? value))
(if (or (string? value) (number? value))
(on-change :simple attr value event)
(do
(let [resolved-value (:resolved-value (first value))
@@ -489,7 +489,7 @@
(mf/use-fn
(mf/deps on-change ids)
(fn [value attr event]
(if (or (string? value) (int? value))
(if (or (string? value) (number? value))
(on-change :multiple attr value event)
(do
(let [resolved-value (:resolved-value (first value))]
@@ -724,7 +724,7 @@
(mf/use-fn
(mf/deps on-change wrap-type ids)
(fn [value event attr]
(if (or (string? value) (int? value))
(if (or (string? value) (number? value))
(on-change (= "nowrap" wrap-type) attr value event)
(do
(let [resolved-value (:resolved-value (first value))]

View File

@@ -282,12 +282,7 @@
(let [n (d/parse-double blur)]
(or (nil? n) (not (< n 0)))))]]]
[:spread {:optional true}
[:and
[:maybe :string]
[:fn {:error/fn #(tr "workspace.tokens.shadow-token-spread-value-error")}
(fn [spread]
(let [n (d/parse-double spread)]
(or (nil? n) (not (< n 0)))))]]]
[:maybe :string]]
[:color {:optional true} [:maybe :string]]
[:color-result {:optional true} ::sm/any]
[:inset {:optional true} [:maybe :boolean]]]]]

View File

@@ -802,9 +802,10 @@
([uri name]
(open-new-window uri name "noopener,noreferrer"))
([uri name features]
(let [new-window (.open js/window (str uri) name features)]
(when-let [new-window (.open js/window (str uri) name features)]
(when (not= name "_blank")
(.reload (.-location new-window))))))
(when-let [location (.-location new-window)]
(.reload location))))))
(defn browser-back
[]

View File

@@ -7594,6 +7594,10 @@ msgstr "Error al importar: No se pudo procesar el JSON."
msgid "workspace.tokens.export"
msgstr "Exportar"
#: src/app/main/ui/workspace/tokens/export/modal.cljs:125
msgid "workspace.tokens.export-tokens"
msgstr "Exportar tokens"
#: src/app/main/ui/workspace/tokens/export/modal.cljs:118
msgid "workspace.tokens.export.multiple-files"
msgstr "Múltiples ficheros"
@@ -7638,10 +7642,26 @@ msgstr "Nombre del grupo"
msgid "workspace.tokens.grouping-set-alert"
msgstr "La agrupación de sets aun no está soportada."
#: src/app/main/ui/workspace/tokens/import/modal.cljs:233
msgid "workspace.tokens.import-button-prefix"
msgstr "Importar %s"
#: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37
msgid "workspace.tokens.import-error"
msgstr "Error al importar:"
#: src/app/main/ui/workspace/tokens/import/modal.cljs:273
msgid "workspace.tokens.import-menu-folder-option"
msgstr "Carpeta"
#: src/app/main/ui/workspace/tokens/import/modal.cljs:271
msgid "workspace.tokens.import-menu-json-option"
msgstr "Archivo JSON único"
#: src/app/main/ui/workspace/tokens/import/modal.cljs:272
msgid "workspace.tokens.import-menu-zip-option"
msgstr "Archivo ZIP"
#: src/app/main/ui/workspace/tokens/import/modal.cljs:241
msgid "workspace.tokens.import-multiple-files"
msgstr ""
@@ -7656,7 +7676,7 @@ msgstr ""
#: src/app/main/ui/workspace/tokens/import/modal.cljs:237
msgid "workspace.tokens.import-tokens"
msgstr "Import tokens"
msgstr "Importar tokens"
#: src/app/main/ui/workspace/tokens/sidebar.cljs:414, src/app/main/ui/workspace/tokens/sidebar.cljs:415
#, unused