From 99378dc02d2b7d19352fbdd111f20d797aaf9450 Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Tue, 1 Sep 2026 15:48:09 +0200 Subject: [PATCH 1/8] :bug: Fix font preview in assets breaks the font row (#11428) * :bug: Fix font preview in assets breaks the font row * :bug: Fix font height problem also in the font dropdown * :bug: Fix a small bug within the changes --------- Co-authored-by: Eva Marco --- frontend/src/app/main/ui/dashboard/grid.scss | 5 + .../sidebar/options/menus/typography.cljs | 150 +++++++++++++++--- .../sidebar/options/menus/typography.scss | 4 + frontend/src/app/util/dom.cljs | 30 ++++ 4 files changed, 169 insertions(+), 20 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/grid.scss b/frontend/src/app/main/ui/dashboard/grid.scss index a64e898019..832da19306 100644 --- a/frontend/src/app/main/ui/dashboard/grid.scss +++ b/frontend/src/app/main/ui/dashboard/grid.scss @@ -419,9 +419,14 @@ $thumbnail-default-height: px2rem(168); } .library-typography-sample { + display: flex; + justify-content: center; + align-items: center; block-size: px2rem(20); + line-height: 1; margin-inline-end: var(--sp-xs); inline-size: px2rem(20); + overflow: hidden; } // ─── MISC ────────────────────────────────────── diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs index 53a5a26b01..3d41372e2c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs @@ -90,6 +90,97 @@ (constantly nil))))) @loaded?)) +;; --- OPTICAL CENTERING OF SAMPLE TEXT -------------------------------------- + +;; Fonts with exaggerated vertical metrics (huge ascender/descender, small +;; caps) render their line box lower within a fixed-height row, so a plain +;; `align-items: center` leaves the visible glyphs sitting low. We measure the +;; font-wide vs glyph-ink bounding boxes once per font/sample and shift the +;; text by the computed offset so the visible glyphs are optically centered. +;; The offset is expressed in `em`, which makes it size-independent: the same +;; measurement corrects both the 16px `Ag` sample and the smaller font-name +;; labels in the font selector. + +(defonce ^:private optical-offset-cache (atom {})) + +(defn- optical-offset-key [family weight style text] + (dm/str family "|" weight "|" style "|" text)) + +(defn- optical-offset-em + "Vertical shift (in `em` units, i.e. relative to the font size) that centers + the ink of `text` within a single line box. + + For a centered line the shift reduces to the difference between the font-wide + and ink bounding boxes: + dy = ((ink-ascent - font-ascent) + (font-descent - ink-descent)) / 2. + Measuring at 16px and dividing the pixel shift by it yields the `em` value." + [family weight style text] + (when-some [{:keys [font-ascent font-descent ink-ascent ink-descent]} + (dom/measure-text-metrics family weight style text 16)] + (let [dy (/ (+ (- ink-ascent font-ascent) + (- font-descent ink-descent)) + 2) + em (/ dy 16)] + ;; Round to avoid float noise leaking into the transform string. + (/ (js/Math.round (* em 10000)) 10000)))) + +(defn- load-optical-offset + [font-id family weight style text] + (let [key (optical-offset-key family weight style text)] + (if-let [cached (get @optical-offset-cache key)] + (p/resolved cached) + (-> (fonts/ensure-loaded! font-id) + (p/then + (fn [_] + (let [em (or (optical-offset-em family weight style text) 0)] + (swap! optical-offset-cache assoc key em) + em))))))) + +(defn- use-optical-offset + "Lazily resolve the optical-centering offset (in `em`) for sample text in a + given font, measuring once per font/sample and caching it. Falls back to 0 + when the font isn't available or the metrics can't be measured." + [font-id family weight style text] + (let [offset* (mf/use-state 0)] + (mf/use-effect + (mf/deps font-id family weight style text) + (fn [] + (let [cancelled? (volatile! false) + key (optical-offset-key family weight style text)] + (if (contains? @optical-offset-cache key) + (reset! offset* (get @optical-offset-cache key)) + (let [task (tm/schedule-on-idle + (fn [] + (-> (load-optical-offset font-id family weight style text) + (p/then + (fn [em] + (when-not @cancelled? + (reset! offset* em)))))))] + (fn [] + (vreset! cancelled? true) + (tm/dispose! task))))) + nil)) + (deref offset*))) + +(defn- sample-container-style + "Inline style that applies the typography font to the (clipped, fixed-height) + sample container." + [typography] + {:font-family (:font-family typography) + :font-weight (:font-weight typography) + :font-style (:font-style typography)}) + +(defn- sample-text-style + "Inline style that optically centers the sample glyphs. Must be applied to + the text node itself, not to the clipped container: a transform on an + `overflow: hidden` element moves its own clip region along with it, so it + would shift the whole box relative to the row instead of the glyphs inside it." + [em] + (when-not (zero? em) + {:transform (dm/str "translateY(" em "em)")})) + +;; --- FONT SELECTOR -------------------------------------------------------- + (mf/defc font-item-preview* "Row content with previews: a vector preview from the shared sprite for catalog fonts, or the font's own name lazily loaded for custom fonts the sprite doesn't @@ -110,7 +201,18 @@ ;; we show the plain name rather than runtime-loading the whole catalog. in-sprite? (and attached? (contains? (:ids sprite) font-id)) fallback? (and (= :ready (:status sprite)) attached? (not in-sprite?)) - loaded? (use-font-lazy-load font-id fallback?)] + loaded? (use-font-lazy-load font-id fallback?) + + ;; Optical centering for the fallback name (custom fonts the sprite + ;; doesn't cover): extreme vertical metrics would push the name low in + ;; the row, so shift it by the measured offset once the font is known. + ;; The label renders at `body-medium` (400/normal), which is the weight + ;; and style we measure against. + label-offset (use-optical-offset font-id + (:family font) + "400" + "normal" + (:name font))] (if in-sprite? ;; `fill: currentColor` (scss) makes the sprite glyph follow the row color. [:svg {:class (stl/css :font-item-preview) @@ -118,8 +220,11 @@ :aria-label (:name font)} [:use {:href (dm/str "#" fonts/preview-sprite-prefix font-id)}]] [:span {:class (stl/css :font-item-label) - :style (when loaded? - #js {:fontFamily (dm/str "\"" (:family font) "\", sans-serif")})} + :style (cond-> {} + loaded? + (assoc :font-family (dm/str "\"" (:family font) "\", sans-serif")) + (not (zero? label-offset)) + (assoc :transform (dm/str "translateY(" label-offset "em)")))} (:name font)]))) (mf/defc font-item* @@ -590,6 +695,11 @@ font-data (fonts/get-font-data (:font-id typography)) typography-id (:id typography) show-actions? (and is-asset? is-editable) + offset (use-optical-offset (:font-id typography) + (:font-family typography) + (:font-weight typography) + (:font-style typography) + "Ag") on-delete (mf/use-fn @@ -624,10 +734,9 @@ [:* [:div {:class (stl/css :font-name-wrapper)} [:div {:class (stl/css :typography-sample-input) - :style {:font-family (:font-family typography) - :font-weight (:font-weight typography) - :font-style (:font-style typography)}} - (tr "workspace.assets.typography.sample")] + :style (sample-container-style typography)} + [:span {:style (sample-text-style offset)} + (tr "workspace.assets.typography.sample")]] [:input {:class (stl/css :adv-typography-name) @@ -661,11 +770,9 @@ [:div {:class (stl/css :typography-info-wrapper)} [:div {:class (stl/css :typography-name-wrapper)} [:div {:class (stl/css :typography-sample) - - :style {:font-family (:font-family typography) - :font-weight (:font-weight typography) - :font-style (:font-style typography)}} - (tr "workspace.assets.typography.sample")] + :style (sample-container-style typography)} + [:span {:style (sample-text-style offset)} + (tr "workspace.assets.typography.sample")]] [:div {:class (stl/css :typography-name) :title (:name typography)} @@ -712,6 +819,11 @@ open? (deref open*) font-data (fonts/get-font-data (:font-id typography)) name-only? (= (:name typography) (:name font-data)) + offset (use-optical-offset (:font-id typography) + (:font-family typography) + (:font-weight typography) + (:font-style typography) + "Ag") on-name-blur (mf/use-fn @@ -769,10 +881,9 @@ [:div {:class (stl/css :font-name-wrapper)} [:div {:class (stl/css :typography-sample-input) - :style {:font-family (:font-family typography) - :font-weight (:font-weight typography) - :font-style (:font-style typography)}} - (tr "workspace.assets.typography.sample")] + :style (sample-container-style typography)} + [:span {:style (sample-text-style offset)} + (tr "workspace.assets.typography.sample")]] [:input {:class (stl/css :adv-typography-name) @@ -789,10 +900,9 @@ :on-context-menu on-context-menu} [:div {:class (stl/css :typography-sample) - :style {:font-family (:font-family typography) - :font-weight (:font-weight typography) - :font-style (:font-style typography)}} - (tr "workspace.assets.typography.sample")] + :style (sample-container-style typography)} + [:span {:style (sample-text-style offset)} + (tr "workspace.assets.typography.sample")]] [:div {:class (stl/css :name-block) :title (if name-only? diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss index 967e9d7c0f..99fd0a398f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss @@ -119,6 +119,8 @@ $font-preview-box-height: 28px; inline-size: $sz-24; block-size: 100%; font-size: px2rem(16); + line-height: 1; + overflow: hidden; color: var(--color-foreground-primary); } @@ -171,8 +173,10 @@ $font-preview-box-height: 28px; align-items: center; min-inline-size: $sz-24; font-size: px2rem(16); + line-height: 1; block-size: $sz-32; padding: 0; + overflow: hidden; color: var(--color-foreground-primary); } diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index 5995a51264..8049af8a62 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -949,6 +949,36 @@ {:ascent (.-fontBoundingBoxAscent measure) :descent (.-fontBoundingBoxDescent measure)})) +(defn measure-text-metrics + "Measure the font-wide (bounding-box) and glyph-ink vertical metrics of `text` + at `font-size` px for the given font. + + Returns `{:font-ascent :font-descent :ink-ascent :ink-descent}` in px, or nil + when the browser doesn't expose the bounding-box metrics. The font-wide + values track what CSS uses for the line box, while the ink ones track the + visible glyphs, which is what an optical centering shift needs." + ([family weight style] + (measure-text-metrics family weight style "Ag" 16)) + ([family weight style text font-size] + (let [element (.createElement globals/document "canvas") + context (.getContext element "2d") + _ (set! (.-font context) + (dm/str (or weight "400") " " (or style "normal") " " + font-size "px \"" family "\"")) + measure ^js (.measureText context (str text)) + font-ascent (.-fontBoundingBoxAscent measure) + font-descent (.-fontBoundingBoxDescent measure) + ink-ascent (.-actualBoundingBoxAscent measure) + ink-descent (.-actualBoundingBoxDescent measure)] + (when (and (number? font-ascent) + (number? font-descent) + (number? ink-ascent) + (number? ink-descent)) + {:font-ascent font-ascent + :font-descent font-descent + :ink-ascent ink-ascent + :ink-descent ink-descent})))) + (defn clone-node ([^js node] (clone-node node true)) From b46ed37141665778a85f6aa18abbd5953c27efe9 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 2 Sep 2026 10:28:16 +0200 Subject: [PATCH 2/8] :bug: Fix size-limiting-stream read arity on v3 binfile import (#11468) The FilterInputStream proxy only implemented read() and read(byte[], int, int). Buffered reads call read(byte[]) (and read(byte[], int) via Clojure interop), causing ArityException while hashing storage objects and breaking v3 imports. Implement all read overloads and extract shared byte-count logic. --- backend/src/app/binfile/v3.clj | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/backend/src/app/binfile/v3.clj b/backend/src/app/binfile/v3.clj index 436be3b943..d8bab4d25c 100644 --- a/backend/src/app/binfile/v3.clj +++ b/backend/src/app/binfile/v3.clj @@ -436,25 +436,26 @@ Raises :validation :max-file-size-reached when the limit is exceeded." ^InputStream [^InputStream input ^long max-size] - (let [counter (atom 0)] + (let [counter (atom 0) + on-read (fn [n] + (when (pos? n) + (when (> (swap! counter + (long n)) max-size) + (ex/raise :type :validation + :code :max-file-size-reached + :hint (str "stream exceeded max size: " max-size)))) + n)] (proxy [FilterInputStream] [input] (read ([] (let [b (.read input)] - (when (pos? b) - (when (> (swap! counter inc) max-size) - (ex/raise :type :validation - :code :max-file-size-reached - :hint (str "stream exceeded max size: " max-size)))) + (when (pos? b) (on-read 1)) b)) - ([buf off len] - (let [n (.read input buf off len)] - (when (pos? n) - (when (> (swap! counter + (long n)) max-size) - (ex/raise :type :validation - :code :max-file-size-reached - :hint (str "stream exceeded max size: " max-size)))) - n)))))) + ([^bytes buf] + (on-read (.read input buf 0 (alength buf)))) + ([^bytes buf off] + (on-read (.read input buf (int off) (- (alength buf) (int off))))) + ([^bytes buf off len] + (on-read (.read input buf (int off) (int len)))))))) (defn- zip-entry-reader [^ZipFile input ^ZipEntry entry] From 76203862150492d84222950eaf882fb09968b5fc Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 2 Sep 2026 13:55:38 +0200 Subject: [PATCH 3/8] :bug: Fix font family preview sample (#11473) * :bug: Fix font family preview sample * :bug: Fix font line height inside font selector --- .../sidebar/options/menus/typography.cljs | 31 ++++++++++++------- .../sidebar/options/menus/typography.scss | 2 +- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs index 3d41372e2c..c397c24448 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs @@ -164,11 +164,13 @@ (defn- sample-container-style "Inline style that applies the typography font to the (clipped, fixed-height) - sample container." + sample container. Must be a real JS object (`#js`), not a ClojureScript map: + the `:style` value here is a runtime expression, not a literal recognized by + the hiccup macro, so it reaches React unconverted." [typography] - {:font-family (:font-family typography) - :font-weight (:font-weight typography) - :font-style (:font-style typography)}) + #js {:fontFamily (:font-family typography) + :fontWeight (:font-weight typography) + :fontStyle (:font-style typography)}) (defn- sample-text-style "Inline style that optically centers the sample glyphs. Must be applied to @@ -177,7 +179,7 @@ would shift the whole box relative to the row instead of the glyphs inside it." [em] (when-not (zero? em) - {:transform (dm/str "translateY(" em "em)")})) + #js {:transform (dm/str "translateY(" em "em)")})) ;; --- FONT SELECTOR -------------------------------------------------------- @@ -219,13 +221,18 @@ :role "img" :aria-label (:name font)} [:use {:href (dm/str "#" fonts/preview-sprite-prefix font-id)}]] - [:span {:class (stl/css :font-item-label) - :style (cond-> {} - loaded? - (assoc :font-family (dm/str "\"" (:family font) "\", sans-serif")) - (not (zero? label-offset)) - (assoc :transform (dm/str "translateY(" label-offset "em)")))} - (:name font)]))) + ;; The vertical correction goes on an INNER span, not on `.font-item-label` + ;; itself: that class carries its own `overflow: hidden` (from the + ;; text-ellipsis mixin, needed to truncate long font names), and a + ;; transform applied to a self-clipping element moves its clip region + ;; along with it — a no-op. The inner span has no overflow of its own, so + ;; the shift actually moves the ink within the outer's fixed clip area. + [:span {:class (stl/css :font-item-label)} + [:span {:style #js {:fontFamily (when loaded? + (dm/str "\"" (:family font) "\", sans-serif")) + :transform (when-not (zero? label-offset) + (dm/str "translateY(" label-offset "em)"))}} + (:name font)]]))) (mf/defc font-item* {::mf/wrap [mf/memo]} diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss index 99fd0a398f..b6159c2042 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss @@ -101,7 +101,7 @@ $font-preview-box-height: 28px; box-sizing: border-box; background-color: var(--font-name-wrapper-background-color); margin-block-end: var(--sp-s); - padding: var(--sp-s) 0 var(--sp-s) var(--sp-m); + padding-inline-start: var(--sp-m); &:focus-within { --font-name-wrapper-border-color: var(--color-accent-primary); From f633d82f51e44761b4e936bb69e1232906a453c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 2 Sep 2026 14:14:23 +0200 Subject: [PATCH 4/8] :recycle: Switch penpot images to sha- tagging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the content-hash build key (bundle_version + docker/images tree hash) used to tag and dedupe the backend/frontend/exporter/storybook/mcp image set with sha-, matching the scheme already used by admin-console, licenses-manager and payments across the org. The check→build→promote pattern with the S3 marker is unchanged; only the key used for the marker, the immutable tag and the local bundle cache filename moves from the composite build key to the git commit sha (the bundle cache now keys on bundle_version alone, which is what it actually caches). devenv is intentionally left out of this pass, it has no versioned tagging today. Signed-off-by: David Barragán Merino --- .github/workflows/build-docker.yml | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 0d03490194..ac900455e0 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -37,7 +37,7 @@ jobs: outputs: gh_ref: ${{ steps.vars.outputs.gh_ref }} bundle_version: ${{ steps.vars.outputs.bundle_version }} - build_key: ${{ steps.vars.outputs.build_key }} + sha: ${{ steps.vars.outputs.sha }} exists: ${{ steps.check.outputs.exists }} steps: @@ -55,6 +55,7 @@ jobs: run: | GH_REF="${{ inputs.gh_ref || github.ref_name }}" echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT + echo "sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT BUNDLE_VERSION=$(aws s3api head-object \ --bucket ${{ secrets.S3_BUCKET }} \ @@ -63,15 +64,10 @@ jobs: --output text) echo "bundle_version=$BUNDLE_VERSION" >> $GITHUB_OUTPUT - # Image content = bundle + docker build context, so the build key - # combines both. - CTX_HASH=$(git rev-parse "HEAD:docker/images" | cut -c1-12) - echo "build_key=${BUNDLE_VERSION}-${CTX_HASH}" >> $GITHUB_OUTPUT - # The image set is a single block, so a single set-level check is # enough: `promote` drops a marker object in S3 only after every # image was built AND every branch tag was moved. Marker present - # means there is nothing at all to do for this build key. + # means there is nothing at all to do for this commit. - name: Check if this image set is already built id: check env: @@ -81,13 +77,13 @@ jobs: run: | if aws s3api head-object \ --bucket ${{ secrets.S3_BUCKET }} \ - --key "markers/images-${{ steps.vars.outputs.build_key }}" \ + --key "markers/images-sha-${{ steps.vars.outputs.sha }}" \ > /dev/null 2>&1; then echo "exists=true" >> $GITHUB_OUTPUT { echo "### ⏭️ Image set build skipped" echo "" - echo "The whole set was already built and promoted for \`${{ steps.vars.outputs.build_key }}\`." + echo "The whole set was already built and promoted for \`sha-${{ steps.vars.outputs.sha }}\`." } >> "$GITHUB_STEP_SUMMARY" else echo "exists=false" >> $GITHUB_OUTPUT @@ -97,7 +93,7 @@ jobs: # prune stale bundles while at it. mkdir -p "$BUNDLE_CACHE" find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true - ZIP="$BUNDLE_CACHE/penpot-${{ steps.vars.outputs.build_key }}.zip" + ZIP="$BUNDLE_CACHE/penpot-${{ steps.vars.outputs.bundle_version }}.zip" if [ ! -f "$ZIP" ]; then aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-${{ steps.vars.outputs.gh_ref }}.zip" "$ZIP.$$.tmp" mv "$ZIP.$$.tmp" "$ZIP" @@ -169,7 +165,7 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} run: | - ZIP="$BUNDLE_CACHE/penpot-${{ needs.prepare.outputs.build_key }}.zip" + ZIP="$BUNDLE_CACHE/penpot-${{ needs.prepare.outputs.bundle_version }}.zip" if [ ! -f "$ZIP" ]; then echo "Bundle not found in host cache; falling back to S3." mkdir -p "$BUNDLE_CACHE" @@ -209,7 +205,7 @@ jobs: sbom: true # Immutable tag only; branch tags are moved atomically for the # whole image set by the `promote` job. - tags: ${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:build-${{ needs.prepare.outputs.build_key }} + tags: ${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:sha-${{ needs.prepare.outputs.sha }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache,mode=max @@ -245,7 +241,7 @@ jobs: for image in $ALL_IMAGES; do docker buildx imagetools create \ -t "${{ secrets.DOCKER_REGISTRY }}/$image:${{ needs.prepare.outputs.gh_ref }}" \ - "${{ secrets.DOCKER_REGISTRY }}/$image:build-${{ needs.prepare.outputs.build_key }}" + "${{ secrets.DOCKER_REGISTRY }}/$image:sha-${{ needs.prepare.outputs.sha }}" done # The marker is written LAST: its presence certifies that all five @@ -257,11 +253,11 @@ jobs: AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} run: | echo "${{ github.run_id }}" | aws s3 cp - \ - "s3://${{ secrets.S3_BUCKET }}/markers/images-${{ needs.prepare.outputs.build_key }}" + "s3://${{ secrets.S3_BUCKET }}/markers/images-sha-${{ needs.prepare.outputs.sha }}" { echo "### ✅ Image set promoted" echo "" - echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`build-${{ needs.prepare.outputs.build_key }}\`." + echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`sha-${{ needs.prepare.outputs.sha }}\`." } >> "$GITHUB_STEP_SUMMARY" # ── 4. Single failure notification for the whole workflow ───────────── From c6a32a2f5aca0e72ca0ee28adb77bce0c7c424ba Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 2 Sep 2026 17:19:37 +0200 Subject: [PATCH 5/8] :bug: Fix list on registration toast notification (#11479) --- frontend/src/app/main/ui/auth/register.cljs | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/app/main/ui/auth/register.cljs b/frontend/src/app/main/ui/auth/register.cljs index 41acc9e841..da3438640f 100644 --- a/frontend/src/app/main/ui/auth/register.cljs +++ b/frontend/src/app/main/ui/auth/register.cljs @@ -114,6 +114,7 @@ (str "
    " items "
"))] (st/emit! (ntf/show {:content (tr "errors.weak-password") :detail detail + :is-html true :type :toast :level :error}))) From 15dff4a9e1f5791ff2c8ee546244a24b18009273 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Thu, 3 Sep 2026 08:46:52 +0200 Subject: [PATCH 6/8] :lipstick: Fix sales email (#11478) --- .../app/main/ui/nitrate/nitrate_code_activation_modal.cljs | 4 ++-- frontend/src/app/main/ui/nitrate/nitrate_form.cljs | 4 ++-- frontend/src/app/main/ui/settings/subscription.cljs | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs b/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs index 876852198e..4bc0f1f490 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs +++ b/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs @@ -120,7 +120,7 @@ [:div (tr "nitrate.code-activation.footer-after") " " [:a {:class (stl/css :link) - :href "mailto:sales@nitrate.com"} - "sales@nitrate.com"] + :href "mailto:sales@penpot.app"} + "sales@penpot.app"] " " (tr "nitrate.code-activation.footer-before")]]]]])) diff --git a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs index c9c34bfb73..fc962e472c 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs +++ b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs @@ -118,8 +118,8 @@ (tr "nitrate.form.contact-upgrade") (tr "nitrate.form.contact-trial"))] [:p {:class (stl/css :modal-text-large)} - [:a {:class (stl/css :link) :href "mailto:sales@penpot.app"} - "sales@penpot.app"]] + [:a {:class (stl/css :link) :href "mailto:sales@penpot.net"} + "sales@penpot.net"]] [:div {:class (stl/css :activation-code)} [:p {:class (stl/css :modal-text-large)} [:a {:class (stl/css :link) diff --git a/frontend/src/app/main/ui/settings/subscription.cljs b/frontend/src/app/main/ui/settings/subscription.cljs index a8a7f3fed9..6163c90d41 100644 --- a/frontend/src/app/main/ui/settings/subscription.cljs +++ b/frontend/src/app/main/ui/settings/subscription.cljs @@ -848,8 +848,8 @@ [:div {:class (stl/css :modal-text)} (tr "nitrate.form.enterprise-intro" ".") " " (if nitrate-license (tr "nitrate.form.contact-us-upgrade") (tr "nitrate.form.contact-us-free-trial"))] [:div {:class (stl/css :modal-text)} - [:a {:class (stl/css :cta-button) :href "mailto:sales@penpot.app"} - "sales@penpot.app"]]])]])) + [:a {:class (stl/css :cta-button) :href "mailto:sales@penpot.net"} + "sales@penpot.net"]]])]])) (mf/defc nitrate-contact-sales-dialog {::mf/register modal/components @@ -898,7 +898,7 @@ :on-click handle-close-dialog} (tr "ds.confirm-cancel")] [:> button* {:variant "primary" :type "button" - :on-click #(dom/open-new-window (dm/str "mailto:sales@penpot.app?subject=Switch%20to%20the%20" subscription-type "%20plan"))} (tr "nitrate.contact-sales.button")]]])]]])) + :on-click #(dom/open-new-window (dm/str "mailto:sales@penpot.net?subject=Switch%20to%20the%20" subscription-type "%20plan"))} (tr "nitrate.contact-sales.button")]]])]]])) (mf/defc nitrate-cancel-contact-sales-dialog {::mf/register modal/components From 7c762d8a9816bca99d86b330a96916a590e7ecef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Thu, 3 Sep 2026 11:59:20 +0200 Subject: [PATCH 7/8] :bug: Fix recommended plan (#11488) --- frontend/src/app/main/ui/settings/subscription.cljs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/settings/subscription.cljs b/frontend/src/app/main/ui/settings/subscription.cljs index 6163c90d41..ff38ffb1c6 100644 --- a/frontend/src/app/main/ui/settings/subscription.cljs +++ b/frontend/src/app/main/ui/settings/subscription.cljs @@ -745,7 +745,6 @@ :cta-link (if (and (contains? cf/flags :admin-console) nitrate?) #(open-contact-sales-modal subscription-type "Unlimited") #(open-subscription-modal "unlimited" subscription)) :cta-text-with-icon (tr "subscription.settings.more-information") :cta-link-with-icon go-to-pricing-page - :recommended (= subscription-type "professional") :show-button-cta (= subscription-type "professional") :current-plan false}]) @@ -783,6 +782,7 @@ :cta-text-with-icon (tr "subscription.settings.more-information") :cta-link-with-icon go-to-pricing-page :code-action :activate + :recommended (= subscription-type "professional") :show-button-cta (not nitrate-license) :current-plan false :inline-error nitrate-start-error-message}])]]])) From 80dea409c623504dd5197410eec58b4a8cf2dd56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Thu, 3 Sep 2026 12:15:35 +0200 Subject: [PATCH 8/8] :bug: Fix share-link viewers unable to load file fragments (#11484) --- backend/src/app/rpc/commands/files.clj | 18 ++++++++++++++---- frontend/src/app/main/data/viewer.cljs | 11 ++++++----- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 435b83afff..b7d04a29a3 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -241,6 +241,18 @@ (some-> (db/get cfg :file-data {:file-id file-id :id fragment-id :type "fragment"}) (update :data blob/decode))) +(defn- check-fragment-scope! + "Checks that the fragment is reachable from the pages authorized by + the share-link. Raises a :not-found exception if the fragment is not reachable." + [cfg file-id fragment-id pages] + (let [fdata (-> (bfc/get-file cfg file-id :read-only? true) + (get :data) + (update :pages-index select-keys pages))] + (when-not (contains? (feat.fdata/get-used-pointer-ids fdata) fragment-id) + (ex/raise :type :not-found + :code :object-not-found + :hint "object not found")))) + (sv/defmethod ::get-file-fragment "Retrieve a file fragment by its ID. Only authenticated users." {::doc/added "1.17" @@ -250,11 +262,9 @@ [cfg {:keys [::rpc/profile-id file-id fragment-id share-id]}] (db/run! cfg (fn [cfg] (let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)] - (when (= :share-link (:type perms)) - (ex/raise :type :not-found - :code :object-not-found - :hint "object not found")) (check-read-permissions! perms) + (when (= :share-link (:type perms)) + (check-fragment-scope! cfg file-id fragment-id (:pages perms))) (-> (get-file-fragment cfg file-id fragment-id) (rph/with-http-cache long-cache-duration)))))) diff --git a/frontend/src/app/main/data/viewer.cljs b/frontend/src/app/main/data/viewer.cljs index f847cd862d..c5f20ce24d 100644 --- a/frontend/src/app/main/data/viewer.cljs +++ b/frontend/src/app/main/data/viewer.cljs @@ -326,11 +326,12 @@ (filter #(= page-id (:page-id %))) (d/index-by :id) (assoc state :comment-threads))) - (on-error [{:keys [type] :as err}] - (if (or (= :authentication type) - (= :not-found type)) - (rx/empty) - (rx/throw err)))] + (on-error [cause] + (let [{:keys [type]} (ex-data cause)] + (if (or (= :authentication type) + (= :not-found type)) + (rx/empty) + (rx/throw cause))))] (ptk/reify ::fetch-comment-threads ptk/WatchEvent