From d9bcc1431c087013ede28b8f5b0a631aeb4085cf Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 19 May 2026 08:51:17 +0200 Subject: [PATCH 01/13] :paperclip: Update the 'update-changelog' opencode skill --- .opencode/skills/update-changelog/SKILL.md | 54 ++++++++++------------ tools/gh.py | 21 +++++---- 2 files changed, 36 insertions(+), 39 deletions(-) diff --git a/.opencode/skills/update-changelog/SKILL.md b/.opencode/skills/update-changelog/SKILL.md index 5d89393c91..29c3d295b4 100644 --- a/.opencode/skills/update-changelog/SKILL.md +++ b/.opencode/skills/update-changelog/SKILL.md @@ -45,12 +45,12 @@ python3 tools/gh.py issues "2.16.0" --state all python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog" ``` -**Label exclusion rules:** -- `release blocker` — Internal release-blocking bugs not relevant to end users -- `no changelog` — Chore/refactor work that doesn't need a changelog entry +**Exclusion rules:** +- `no changelog` label — Chore/refactor work that doesn't need a changelog entry +- `Task` issue type — Internal chores are not user-facing; filter these out after fetching The script outputs JSON with each entry containing `number`, `title`, `state`, -`labels`, and `closing_prs` (the PRs that fix each issue). +`issue_type`, `labels`, and `closing_prs` (the PRs that fix each issue). ### 3. Identify missing entries (optional) @@ -84,34 +84,27 @@ The `prs` command returns JSON with `number`, `title`, `body`, `state`, `merged_at`, `author`, `labels`, and `closing_issues`. PRs are fetched in batches of 50 via GraphQL to stay within API limits. -### 5. Categorize entries +### 5. Categorize entries — strictly by issue type, never by labels or emoji -Use the **Issue Type** field (GitHub's native issue type, accessible via GraphQL -`issueType { name }`) to determine which section an entry belongs to. -**Do not** use labels or title emoji prefixes as the source of truth — they are -often inaccurate or missing. +Use the **Issue Type** field (GitHub's native issue type, exposed as +`issue_type` in the `gh.py` JSON output) to determine which section an entry +belongs to. -| Issue Type (`issueType.name`) | Changelog section | -|------------------------------|-------------------| +> **⚠️ CRITICAL: Never use labels or title emoji prefixes for categorization.** +> Labels like `bug` and `enhancement`, as well as title prefixes like `:bug:` +> and `:sparkles:`, are frequently inaccurate, missing, or contradictory to the +> actual issue type. The `issue_type` field from `gh.py` is the single source +> of truth. + +| `issue_type` value | Changelog section | +|--------------------|-------------------| | `Bug` | `### :bug: Bugs fixed` | | `Feature` or `Enhancement` | `### :sparkles: New features & Enhancements` | -| No type set | Fetch the issue and check its labels as a fallback: `bug` label → bugs section, otherwise default to enhancements | +| `Task` | **Exclude** — internal chores are not user-facing | +| `null` (not set) | Check labels as a fallback: `bug` label → bugs, otherwise enhancements | -To fetch Issue Types for all issues in a milestone efficiently, use a single -GraphQL query with aliases rather than N+1 REST calls: - -```graphql -query { - repository(owner: "penpot", name: "penpot") { - i123: issue(number: 123) { - number state milestone { number } issueType { name } - } - i456: issue(number: 456) { - number state milestone { number } issueType { name } - } - } -} -``` +The `gh.py` issues command already includes `issue_type` in every entry's +output. **No separate GraphQL query is needed.** **Community contribution attribution:** If the issue or its fix PR has the `community contribution` label, add an attribution `(by @)` @@ -224,7 +217,7 @@ Read the top of `CHANGES.md` and confirm: can find the code changes. - **Latest version first.** New sections are inserted at the top of the changelog, below the `# CHANGELOG` header. -- **Issue Type determines section.** Use GitHub's `issueType` field (Bug → `:bug:`, Feature/Enhancement → `:sparkles:`) to categorize entries. Ignore labels and title emoji prefixes — they are unreliable for categorization. +- **Issue Type determines section — exclusively.** Use the `issue_type` field from `gh.py` output (Bug → `:bug:`, Feature/Enhancement → `:sparkles:`). **Do not** use labels (`bug`, `enhancement`) or title emoji prefixes (`:bug:`, `:sparkles:`) — they are frequently wrong or contradictory. The `issue_type` is the single source of truth. - **User-facing descriptions.** Write from the user's perspective — describe what broke and what was fixed, not internal implementation details. - **Community attribution.** When the issue or fix PR has the @@ -233,8 +226,9 @@ Read the top of `CHANGES.md` and confirm: issue author) for the attribution. - **Only closed issues.** An issue must have `state: "closed"` to appear in the changelog. Open unresolved issues are omitted. -- **Excluded labels.** Issues with `release blocker` or `no changelog` labels - must be excluded from the changelog. +- **Excluded issues.** Issues with `no changelog` label must be excluded. + Issues with `issue_type: "Task"` must also be excluded — they are internal + chores, not user-facing changes. - **Multiple PRs per issue.** If multiple PRs fix the same issue, list them comma-separated inline: `(PR: [#A](url), [#B](url))`. - **Duplicate removal.** If an entry already exists in a prior version section, diff --git a/tools/gh.py b/tools/gh.py index 2aa7ac4da8..afd81da619 100755 --- a/tools/gh.py +++ b/tools/gh.py @@ -80,14 +80,15 @@ query($owner: String!, $repo: String!, $milestone: Int!, $cursor: String) { issues(first: 100, after: $cursor, states: __STATES__) { totalCount pageInfo { hasNextPage endCursor } - nodes { - ... on Issue { - number - title - state - labels(first: 20) { nodes { name } } - closedByPullRequestsReferences(first: 5) { nodes { number } } - } + nodes { + ... on Issue { + number + title + state + issueType { name } + labels(first: 20) { nodes { name } } + closedByPullRequestsReferences(first: 5) { nodes { number } } + } } } } @@ -120,7 +121,7 @@ def fetch_milestone_issues(milestone_num: int, states: str) -> list[dict]: states: GraphQL states enum array literal, e.g. ``"[CLOSED]"`` or ``"[OPEN CLOSED]"`` Returns: - List of {number, title, state, labels: [str], closing_prs: [int]} + List of {number, title, state, issue_type: str|None, labels: [str], closing_prs: [int]} """ query = GQL_ISSUES_QUERY.replace("__STATES__", states) all_nodes: list[dict] = [] @@ -140,10 +141,12 @@ def fetch_milestone_issues(milestone_num: int, states: str) -> list[dict]: for node in issues["nodes"]: if node is None: continue + issue_type = node.get("issueType") all_nodes.append({ "number": node["number"], "title": node["title"], "state": node["state"], + "issue_type": issue_type["name"] if issue_type else None, "labels": [lbl["name"] for lbl in node["labels"]["nodes"]], "closing_prs": [pr["number"] for pr in node["closedByPullRequestsReferences"]["nodes"]], }) From 46c35b01a84868187fb85fb289a4083b9c620c2b Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 19 May 2026 09:02:34 +0200 Subject: [PATCH 02/13] :paperclip: Update changelog --- CHANGES.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index b2cb668cd9..db73c522f2 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -55,6 +55,7 @@ - Restore deleted team files in bulk instead of per file (by @Dexterity104) [#9246](https://github.com/penpot/penpot/issues/9246) (PR: [#9248](https://github.com/penpot/penpot/pull/9248)) - Preserve Inkscape labels when pasting SVGs (by @jeffrey701) [#7869](https://github.com/penpot/penpot/issues/7869) (PR: [#9252](https://github.com/penpot/penpot/pull/9252)) - Add Alt+click to expand layer subtree (by @MilosM348) [#7736](https://github.com/penpot/penpot/issues/7736) (PR: [#9179](https://github.com/penpot/penpot/pull/9179)) +- Allow deleting the profile avatar after uploading (by @moorsecopers99) [#9067](https://github.com/penpot/penpot/issues/9067) (PR: [#9068](https://github.com/penpot/penpot/pull/9068)) ### :bug: Bugs fixed - Add Shift+Numpad aliases for zoom shortcuts (by @RenzoMXD) [#2457](https://github.com/penpot/penpot/issues/2457) (PR: [#9063](https://github.com/penpot/penpot/pull/9063)) @@ -62,7 +63,6 @@ - Add guide locking and fix locked element selection in viewer (by @Dexterity104) [#8358](https://github.com/penpot/penpot/issues/8358) (PR: [#8949](https://github.com/penpot/penpot/pull/8949)) - Add natural sorting on token names [#8635](https://github.com/penpot/penpot/issues/8635) (PR: [#8672](https://github.com/penpot/penpot/pull/8672)) - Fix warnings for unsupported token $type (by @Dexterity104) [#8790](https://github.com/penpot/penpot/issues/8790) (PR: [#8873](https://github.com/penpot/penpot/pull/8873)) -- Allow deleting the profile avatar after uploading (by @moorsecopers99) [#9067](https://github.com/penpot/penpot/issues/9067) (PR: [#9068](https://github.com/penpot/penpot/pull/9068)) - Apply styles to selection (by @AzazelN28) [#9661](https://github.com/penpot/penpot/issues/9661) (PR: [#8625](https://github.com/penpot/penpot/pull/8625)) - Fix Alt/Option to draw shapes from center point (by @offreal) [#8360](https://github.com/penpot/penpot/issues/8360) (PR: [#8361](https://github.com/penpot/penpot/pull/8361)) - Fix library update button freezing [#9330](https://github.com/penpot/penpot/issues/9330) (PR: [#9513](https://github.com/penpot/penpot/pull/9513)) @@ -131,6 +131,7 @@ - Fix numeric input changes not saved when clicking on viewport [#9491](https://github.com/penpot/penpot/issues/9491) (PR: [#9548](https://github.com/penpot/penpot/pull/9548)) - Fix resize cursor appearing on login and register buttons [#9505](https://github.com/penpot/penpot/issues/9505) (PR: [#9590](https://github.com/penpot/penpot/pull/9590)) - Fix version restore restoring first previewed version instead of selected one [#9588](https://github.com/penpot/penpot/issues/9588) (PR: [#9626](https://github.com/penpot/penpot/pull/9626)) +- Fix incorrect error message when applying tokens while editing text [#9620](https://github.com/penpot/penpot/issues/9620) (PR: [#9708](https://github.com/penpot/penpot/pull/9708)) ## 2.15.4 (Unreleased) From 44f4c43f15e6f76980a9fde72c18717b26b0819c Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 19 May 2026 09:40:10 +0200 Subject: [PATCH 03/13] :bug: Fix apply tokens on token creation (#9713) --- .../playwright/ui/specs/tokens/apply.spec.js | 126 +++++++++++++++++- .../tokens/management/forms/generic_form.cljs | 3 +- 2 files changed, 125 insertions(+), 4 deletions(-) diff --git a/frontend/playwright/ui/specs/tokens/apply.spec.js b/frontend/playwright/ui/specs/tokens/apply.spec.js index 4a49cbc1aa..76ad209b7a 100644 --- a/frontend/playwright/ui/specs/tokens/apply.spec.js +++ b/frontend/playwright/ui/specs/tokens/apply.spec.js @@ -1062,7 +1062,9 @@ test("BUG: 14136 Apply grid layout padding token to a shape from the sidebar doe await tokenDimensionMd.click(); // Expand padding to all sides - await layoutSection.getByRole('button', { name: 'Show 4 sided padding options' }).click(); + await layoutSection + .getByRole("button", { name: "Show 4 sided padding options" }) + .click(); const topPaddingSection = layoutSection.getByLabel("Top padding"); const bottomPaddingSection = layoutSection.getByLabel("Bottom padding"); await expect(topPaddingSection).toBeVisible(); @@ -1073,13 +1075,131 @@ test("BUG: 14136 Apply grid layout padding token to a shape from the sidebar doe // Check if the value of the attribute is still correct await expect( - await topPaddingSection.getByRole("button", { name: "dimension.md" }).textContent() + await topPaddingSection + .getByRole("button", { name: "dimension.md" }) + .textContent(), ).toBe("16"); await expect( - await bottomPaddingSection.getByRole("button", { name: "dimension.md" }).textContent() + await bottomPaddingSection + .getByRole("button", { name: "dimension.md" }) + .textContent(), ).toBe("16"); }); +test("BUG: 14200, Tokens in sets are applied when clicking on Save during creation", async ({ + page, +}) => { + // Setup the workspace with token features enabled + const { + workspacePage, + tokensSidebar, + tokenContextMenuForToken, + tokenThemesSetsSidebar, + tokenSetGroupItems, + tokensUpdateCreateModal, + } = await setupTokensFileRender(page, { + flags: ["enable-token-combobox", "enable-feature-token-input"], + }); + + const changeSetInput = async (sidebar, setName, finalKey = "Enter") => { + const setInput = sidebar.locator("input:focus"); + await expect(setInput).toBeVisible(); + await setInput.fill(setName); + await setInput.press(finalKey); + }; + + const createSet = async (sidebar, setName, finalKey = "Enter") => { + const tokensTabButton = sidebar + .getByRole("button", { name: "Add set" }) + .click(); + + await changeSetInput(sidebar, setName, (finalKey = "Enter")); + }; + + // Select rectangle layer + await page.getByRole("tab", { name: "Layers" }).click(); + + await workspacePage.layers + .getByTestId("layer-row") + .filter({ hasText: "Rectangle" }) + .first() + .click(); + + await page.getByRole("tab", { name: "Tokens" }).click(); + + // Create nested token set and activate it + await createSet(tokenThemesSetsSidebar, "set/first"); + + await tokenThemesSetsSidebar.getByRole("button", { name: "first" }).click(); + + await tokenThemesSetsSidebar + .getByRole("button", { name: "first" }) + .getByRole("checkbox") + .click(); + + // Create token in nested set + await unfoldTokenType(tokensSidebar, "Border radius"); + + // Create border token + const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); + await tokensTabPanel + .getByRole("button", { name: `Add Token: Border radius` }) + .click(); + await expect(tokensUpdateCreateModal).toBeVisible(); + + const nameField = tokensUpdateCreateModal.getByLabel("Name"); + await nameField.fill("border"); + + const valueField = tokensUpdateCreateModal.getByRole("combobox", { + name: "Value", + }); + await valueField.fill("20"); + + const submitButton = tokensUpdateCreateModal.getByRole("button", { + name: "Save", + }); + await submitButton.click(); + await expect(tokensUpdateCreateModal).not.toBeVisible(); + + // Check "border" token is not applied while creating. + + const borderRadiusSection = page.getByRole("region", { + name: "Border radius section", + }); + await expect(borderRadiusSection).toBeVisible(); + + // Check if token pill is visible on design tab on right sidebar + const borderTokenPill = borderRadiusSection.getByRole("button", { + name: "border", + exact: true, + }); + await expect(borderTokenPill).not.toBeVisible(); + + //Create new set and activate it + + await createSet(tokenThemesSetsSidebar, "set/other"); + + await tokenThemesSetsSidebar.getByRole("button", { name: "other" }).click(); + + await tokenThemesSetsSidebar + .getByRole("button", { name: "other" }) + .getByRole("checkbox") + .click(); + + //Create the same token in new set + await unfoldTokenType(tokensSidebar, "Border radius"); + await tokensTabPanel + .getByRole("button", { name: `Add Token: Border radius` }) + .click(); + await expect(tokensUpdateCreateModal).toBeVisible(); + await nameField.fill("border"); + await valueField.fill("50"); + await valueField.press("Enter"); + await expect(tokensUpdateCreateModal).not.toBeVisible(); + await expect(borderRadiusSection).toBeVisible(); + await expect(borderTokenPill).not.toBeVisible(); +}); + test("BUG: 14191, Apply tokens from different set", async ({ page }) => { const { workspacePage, diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs index a4cc813bbc..1606ba2eae 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs @@ -183,11 +183,12 @@ on-submit (mf/use-fn (mf/deps validate-token token tokens token-type value-subfield value-type active-tab on-remap-token on-rename-token is-create) - (fn [form _event] + (fn [form event] (let [name (get-in @form [:clean-data :name]) description (get-in @form [:clean-data :description]) value (get-in @form [:clean-data :value]) value-for-validation (get-value-for-validator active-tab value value-subfield value-type)] + (dom/stop-propagation event) (->> (validate-token {:token-value value-for-validation :token-name name :token-description description From 8dd4b486e74fb95761e4a9bdc90f32abb0ecdc2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Tue, 19 May 2026 09:44:58 +0200 Subject: [PATCH 04/13] :zap: Improve drag performance avoiding unnecessary modifiers --- .../app/main/data/workspace/modifiers.cljs | 66 +++++--------- .../src/app/main/ui/workspace/viewport.cljs | 2 +- .../app/main/ui/workspace/viewport/hooks.cljs | 11 ++- .../app/main/ui/workspace/viewport_wasm.cljs | 28 +++--- render-wasm/src/state/shapes_pool.rs | 85 ++++++++++++++++++- 5 files changed, 125 insertions(+), 67 deletions(-) diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index 62bc42b50a..9c5323ba66 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -629,34 +629,10 @@ (ptk/reify ::set-temporary-modifiers ptk/EffectEvent (effect [_ _ _] - (rx/push! ms/wasm-modifiers modifiers)))) + (rx/push! ms/wasm-modifiers (into {} modifiers))))) (def ^:private xf:map-key (map key)) -(defn- expand-translation-entry - "Expand one translation-only geometry entry into [descendant-id matrix] - pairs covering the moved shape's full subtree (every descendant gets - the same matrix)." - [[id data] objects subtree-ids-by-id] - (let [m (:transform data) - sub (or (get subtree-ids-by-id id) - (cfh/get-children-ids-with-self objects id))] - (map (fn [sid] [sid m]) sub))) - -(defn- expand-translation-modifiers - "Pure translation propagates as identity to descendants: every shape in - the subtree gets the same matrix. Builds the flat [id matrix] list - directly, skipping the WASM tree walk + FFI roundtrip used by - `propagate-modifiers` for the general (resize/rotate) case. - - Only safe when pixel-snap is off: WASM applies pixel correction - per-shape (different scale/translation per descendant), which we - can't replicate cheaply on the CLJS side." - [geometry-entries objects subtree-ids-by-id] - (into [] - (mapcat #(expand-translation-entry % objects subtree-ids-by-id)) - geometry-entries)) - (defn- translate-selrect "Shift `selrect`'s center by (tx, ty). Width/height/transform are invariant under pure translation, so only `:center` moves." @@ -688,11 +664,12 @@ (ptk/reify ::set-wasm-modifiers ptk/UpdateEvent (update [_ state] - (let [property-changes - (extract-property-changes modif-tree)] - (-> state - (assoc :prev-wasm-props (:wasm-props state)) - (assoc :wasm-props property-changes)))) + (let [property-changes (extract-property-changes modif-tree)] + (if (d/not-empty? property-changes) + (-> state + (assoc :prev-wasm-props (:wasm-props state)) + (assoc :wasm-props property-changes)) + state))) ptk/WatchEvent (watch [_ state _] @@ -702,29 +679,26 @@ ;; thread is not blocked. The pair is closed in ;; `clear-local-transform`. (ensure-interactive-transform-start!) - (wasm.api/clean-modifiers) - (let [prev-wasm-props (:prev-wasm-props state) - wasm-props (:wasm-props state) - objects (dsh/lookup-page-objects state) - snap-pixel? - (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) + (let [snap-pixel? (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) + translation? (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] - translation? - (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] - (set-wasm-props! objects prev-wasm-props wasm-props) + ;; Only geometry (transform matrix) changes during drag. (when-not translation? - (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))) + (let [objects (dsh/lookup-page-objects state)] + (set-wasm-props! objects (:prev-wasm-props state) (:wasm-props state)) + (wasm.api/clean-modifiers) + (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)))) (let [geometry-entries (parse-geometry-modifiers modif-tree) + root-modifiers (into [] (map (fn [[id data]] [id (:transform data)])) geometry-entries) modifiers (if (and translation? (not snap-pixel?)) - (expand-translation-modifiers geometry-entries objects subtree-ids-by-id) + root-modifiers (wasm.api/propagate-modifiers geometry-entries snap-pixel?))] (wasm.api/set-modifiers modifiers) - (let [ids (into [] xf:map-key geometry-entries) - selrect - (if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers)) - (cached-translation-selrect ids (second (first modifiers)) selection-rect-cache) - (wasm.api/get-selection-rect ids))] + (let [ids (into [] xf:map-key geometry-entries) + selrect (if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers)) + (cached-translation-selrect ids (second (first modifiers)) selection-rect-cache) + (wasm.api/get-selection-rect ids))] (rx/of (set-temporary-selrect selrect) (set-temporary-modifiers modifiers)))))))) diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 75cfbf4444..58b02a387e 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -310,7 +310,7 @@ (hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?) (hooks/setup-keyboard alt? mod? space? z? shift?) (hooks/setup-hover-shapes page-id move-stream base-objects selected mod? hover measure-hover - hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures? read-only?) + hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures? read-only? transform) (hooks/setup-viewport-modifiers modifiers base-objects) (hooks/setup-shortcuts path-editing? path-drawing? text-editing? grid-editing?) (hooks/setup-active-frames base-objects hover-ids selected active-frames zoom transform vbox) diff --git a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs index 140b5d5dd1..19b2b1b3fb 100644 --- a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs @@ -177,13 +177,14 @@ (dw/increase-zoom))))))) (defn setup-hover-shapes - [page-id move-stream objects selected mod? hover measure-hover hover-ids hover-top-frame-id hover-disabled? focus zoom show-measures? read-only?] + [page-id move-stream objects selected mod? hover measure-hover hover-ids hover-top-frame-id hover-disabled? focus zoom show-measures? read-only? transform] (let [;; We use ref so we don't recreate the stream on a change zoom-ref (mf/use-ref zoom) mod-ref (mf/use-ref @mod?) selected-ref (mf/use-ref selected) hover-disabled-ref (mf/use-ref hover-disabled?) focus-ref (mf/use-ref focus) + transform-ref (mf/use-ref transform) last-point-ref (mf/use-var nil) mod-str (mf/use-memo #(rx/subject)) @@ -251,6 +252,10 @@ (mf/deps focus) #(mf/set-ref-val! focus-ref focus)) + (mf/use-effect + (mf/deps transform) + #(mf/set-ref-val! transform-ref transform)) + (hooks/use-stream over-shapes-stream-debounced (mf/deps objects) @@ -361,7 +366,9 @@ (get objects)))] (reset! hover hover-shape) (reset! measure-hover measure-hover-shape) - (reset! hover-ids ids))) + ;; Skip hover-ids update during drag + (when (not= :move (mf/ref-val transform-ref)) + (reset! hover-ids ids)))) (fn [] ;; Clean the cache diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 150b2cd544..afe3543572 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -77,7 +77,7 @@ (defn apply-modifiers-to-selected [selected objects modifiers] - (apply-modifiers-to-objects objects (select-keys (into {} modifiers) selected))) + (apply-modifiers-to-objects objects (select-keys modifiers selected))) (defn- apply-wasm-modifiers-to-ids "Like `apply-modifiers-to-objects`, but only updates ids in `id-set`. During WASM @@ -87,13 +87,15 @@ (if (or (empty? wasm-modifiers) (empty? id-set)) objects (reduce - (fn [objs pair] - (let [[id t] pair] - (if (and (contains? id-set id) (contains? objs id)) + (fn [objs id] + (if-let [t (get wasm-modifiers id)] + (if (contains? objs id) (update objs id gsh/apply-transform t) - objs))) + objs) + objs)) objects - wasm-modifiers))) + id-set))) + (defn- outline-wasm-source-ids "Superset of shape ids that `shape-outlines` may look up (all outline usages here)." @@ -142,7 +144,6 @@ drawing (mf/deref refs/workspace-drawing) focus (mf/deref refs/workspace-focus-selected) wasm-modifiers (mf/deref refs/workspace-wasm-modifiers) - workspace-editor-state (mf/deref refs/workspace-editor-state) file-id (get file :id) @@ -162,7 +163,6 @@ selected-shapes (->> selected (into [] (keep (d/getf objects-modified))) (not-empty)) - ;; STATE alt? (mf/use-state false) shift? (mf/use-state false) @@ -370,6 +370,7 @@ offset-y (if selecting-first-level-frame? (:y first-shape) (:y selected-frame)) + rule-area-size (/ rulers/ruler-area-size zoom) preview-blend (-> refs/workspace-preview-blend (mf/deref)) @@ -495,7 +496,7 @@ (hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?) (hooks/setup-keyboard alt? mod? space? z? shift?) (hooks/setup-hover-shapes page-id move-stream base-objects selected mod? hover measure-hover - hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures? read-only?) + hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures? read-only? transform) (hooks/setup-shortcuts path-editing? path-drawing? text-editing? grid-editing?) (hooks/setup-active-frames base-objects hover-ids selected active-frames zoom transform vbox) @@ -613,11 +614,10 @@ :ref text-editor-ref}])) (when show-frame-outline? - (let [outlined-frame-id - (->> @hover-ids - (filter #(cfh/frame-shape? (get base-objects %))) - (remove selected) - (last)) + (let [outlined-frame-id (->> @hover-ids + (filter #(cfh/frame-shape? (get base-objects %))) + (remove selected) + (last)) outlined-frame (get objects outlined-frame-id)] [:* [:& outline/shape-outlines diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index d51ce1cabe..f57bbcb51b 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::collections::VecDeque; use std::iter; use crate::performance; @@ -191,6 +192,15 @@ impl ShapesPoolImpl { Some(shape) } } else { + if let Some(cell) = self.modified_shape_cache.get(&idx) { + return Some(cell.get_or_init(|| { + if let Some(m) = self.find_nearest_ancestor_modifier(idx) { + shape.transformed(Some(&m), None) + } else { + shape.clone() + } + })); + } Some(shape) } } @@ -230,9 +240,6 @@ impl ShapesPoolImpl { } pub fn set_modifiers(&mut self, modifiers: HashMap) { - // Convert HashMap to HashMap using indices - // Initialize the cache cells for affected shapes - let mut ids = Vec::::new(); let mut modifiers_with_idx = HashMap::with_capacity(modifiers.len()); @@ -242,12 +249,47 @@ impl ShapesPoolImpl { ids.push(uuid); } } + + // Expand every root modifier to its full descendant subtree. + // When CLJS sends only root shapes (translation on drag), descendants + // need the same matrix. + // For resize/rotate, propagate-modifiers already includes all descendants. + // Descendants are NOT pushed into `ids` / `modifier_uuids`: tile invalidation + // via rebuild_modifier_tiles only runs for roots, which is sufficient because + // descendants always lie inside the parent's bounding box and are therefore + // covered by the parent's old/new tile ranges. + let root_pairs: Vec<(usize, skia::Matrix)> = ids + .iter() + .filter_map(|uuid| { + let idx = self.uuid_to_idx.get(uuid).copied()?; + let matrix = modifiers_with_idx.get(&idx).copied()?; + Some((idx, matrix)) + }) + .collect(); + + let mut descendants_idxs: Vec = Vec::new(); + for (root_idx, matrix) in root_pairs { + for descendant_idx in self.collect_all_descendants(root_idx) { + if let std::collections::hash_map::Entry::Vacant(e) = + modifiers_with_idx.entry(descendant_idx) + { + e.insert(matrix); + descendants_idxs.push(descendant_idx); + } + } + } + self.modifiers = modifiers_with_idx; + for descendant_idx in descendants_idxs { + self.modified_shape_cache + .insert(descendant_idx, OnceCell::new()); + } + // Compute ancestors before consuming `ids` so we can move it into // `modifier_uuids` without a clone. let all_ids = shapes::all_with_ancestors(&ids, self, true); - // Keep modifier_uuids in sync so modifier_ids() is O(K) not O(N_shapes). + // rebuild_modifier_tiles doesn't process every descendant individually. self.modifier_uuids = ids; for uuid in all_ids { if let Some(idx) = self.uuid_to_idx.get(&uuid).copied() { @@ -363,6 +405,41 @@ impl ShapesPoolImpl { } } + fn collect_all_descendants(&self, idx: usize) -> Vec { + let mut result = Vec::new(); + let mut queue: VecDeque<&Uuid> = VecDeque::new(); + let shape = &self.shapes[idx]; + for child_id in shape.children_ids_iter(false) { + queue.push_back(child_id); + } + while let Some(child_id) = queue.pop_front() { + if let Some(&child_idx) = self.uuid_to_idx.get(child_id) { + result.push(child_idx); + let child_shape = &self.shapes[child_idx]; + for grandchild_id in child_shape.children_ids_iter(false) { + queue.push_back(grandchild_id); + } + } + } + result + } + + fn find_nearest_ancestor_modifier(&self, idx: usize) -> Option { + let mut current_idx = idx; + loop { + let shape = &self.shapes[current_idx]; + let parent_id = shape.parent_id?; + if parent_id == Uuid::nil() { + return None; + } + let &parent_idx = self.uuid_to_idx.get(&parent_id)?; + if let Some(matrix) = self.modifiers.get(&parent_idx) { + return Some(*matrix); + } + current_idx = parent_idx; + } + } + fn to_update_bool(&self, shape: &Shape) -> bool { if !shape.is_bool() { return false; From a9d0feb8fd72a4ec697946e25c08e49625b4aa64 Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Tue, 19 May 2026 09:56:16 +0200 Subject: [PATCH 05/13] :bug: Fix problem with caret color value (#9717) --- .../ui/workspace/shapes/text/v2_editor.cljs | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs index a92b0bf07b..b716336961 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs @@ -147,7 +147,13 @@ on-style-change (fn [event] - (let [styles (styles/get-styles-from-event event)] + (let [styles (styles/get-styles-from-event event) + fills (:fills styles) + fill-color (when (sequential? fills) (some :fill-color fills))] + ;; Dynamically update the caret color as the cursor moves between spans + (when-let [container-node (mf/ref-val container-ref)] + (dom/set-style! container-node "--text-editor-caret-color" + (or fill-color text-color))) (st/emit! (dwt/v2-update-text-editor-styles shape-id styles)))) on-needs-layout @@ -219,10 +225,18 @@ (= (:vertical-align content) "bottom")])) (defn get-color-from-content [content] - (let [fills (->> (tree-seq map? :children content) - (mapcat :fills) - (filter :fill-color))] - (some :fill-color fills))) + (let [nodes (tree-seq map? :children content) + get-color (fn [node] + ;; Handle both new format (:fills vector) and old/deprecated format + ;; (direct :fill-color on the content node — pre-fills-refactor files) + (or (some :fill-color (:fills node)) + (:fill-color node)))] + ;; Prefer inline (leaf) text nodes over paragraph nodes. The paragraph's :fills + ;; tracks the last-typed color, so using it directly would make the caret take + ;; the last span's color rather than the first visible span's color. + ;; Inline nodes have no :type; they are identified by the presence of :text. + (or (->> nodes (filter #(contains? % :text)) (some get-color)) + (->> nodes (some get-color))))) (defn get-default-text-color "Returns the appropriate text color based on fill, frame, and background." From ed746bb694881ec8787a0297bd1a45958e7b7118 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 19 May 2026 11:01:39 +0200 Subject: [PATCH 06/13] :bug: Fix no gap on token list --- .../src/app/main/ui/workspace/tokens/management/token_tree.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_tree.scss b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.scss index 9edd7caf40..9411ec58d9 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_tree.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.scss @@ -13,6 +13,7 @@ display: flex; flex-wrap: wrap; padding-inline-start: calc(var(--node-spacing)); + gap: var(--sp-xs); & .node-parent { flex: 1 0 100%; From d9ee28229c3a2ea3c5d2098434b8ffc49d61a4e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Tue, 19 May 2026 11:35:30 +0200 Subject: [PATCH 07/13] :bug: Toggle token path on token rename --- .../playwright/ui/specs/tokens/tree.spec.js | 43 +++++++++++++++++++ .../data/workspace/tokens/library_edit.cljs | 18 +++++++- .../tokens/management/forms/generic_form.cljs | 36 +++++++++------- 3 files changed, 80 insertions(+), 17 deletions(-) diff --git a/frontend/playwright/ui/specs/tokens/tree.spec.js b/frontend/playwright/ui/specs/tokens/tree.spec.js index 243a539432..9f8ef72cdf 100644 --- a/frontend/playwright/ui/specs/tokens/tree.spec.js +++ b/frontend/playwright/ui/specs/tokens/tree.spec.js @@ -141,6 +141,49 @@ test.describe("Tokens - node tree", () => { await expect(darkerNodeToken).toBeVisible(); }); + test("Renaming a token into a collapsed group auto-expands that group", async ({ + page, + }) => { + const { tokensSidebar, tokensUpdateCreateModal, tokenContextMenuForToken } = + await setupTokensFileRender(page); + + // Create tokens in two separate groups + await createToken(page, "Color", "dark.base", "Value", "#000000"); + await createToken(page, "Color", "light.accent", "Value", "#ffffff"); + + const lightGroup = tokensSidebar.getByRole("button", { + name: "light", + exact: true, + }); + + // Collapse the light group so its children are hidden + await lightGroup.click(); + + const lightAccentToken = tokensSidebar.getByRole("button", { + name: "accent", + }); + await expect(lightAccentToken).not.toBeVisible(); + + // Open the edit modal for the dark.base token + const darkBaseToken = tokensSidebar.getByRole("button", { name: "base" }); + await darkBaseToken.click({ button: "right" }); + await tokenContextMenuForToken.getByText("Edit token").click(); + + await expect(tokensUpdateCreateModal).toBeVisible(); + + // Rename to move it into the collapsed light group + const nameField = tokensUpdateCreateModal.getByLabel("Name"); + await nameField.fill("light.base"); + await tokensUpdateCreateModal + .getByRole("button", { name: "Save" }) + .click(); + + // After rename, light group should be auto-expanded and both tokens visible + await expect(lightGroup).toBeVisible(); + await expect(lightAccentToken).toBeVisible(); + await expect(tokensSidebar.getByRole("button", { name: "base" })).toBeVisible(); + }); + test("User removes node and all child tokens", async ({ page }) => { const { tokensSidebar } = await setupTokensFileRender(page); diff --git a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs index 363e796b1a..749bdd8bf4 100644 --- a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs +++ b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs @@ -203,7 +203,22 @@ (remove-path path paths) (add-path path paths)))))))) - +(defn toggle-nested-token-path + [token-type new-name] + (ptk/reify ::toggle-nested-token-path + ptk/UpdateEvent + (update [_ state] + (let [type-str (name token-type) + segments (str/split new-name ".") + n-groups (dec (count segments))] + (if (pos? n-groups) + (update-in state [:workspace-tokens :folded-token-paths] + (fn [paths] + (reduce (fn [ps i] + (remove-path (str type-str "." (str/join "." (take i segments))) ps)) + (or paths []) + (range 1 (inc n-groups))))) + state))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; TOKENS Actions @@ -582,6 +597,7 @@ (pcb/set-token (ctob/get-id token-set) id token'))] + (toggle-token-path (str (name token-type) "." (:name token))) (rx/of (dch/commit-changes changes) (ev/event (-> {::ev/name "edit-token" :type token-type} (merge (meta it)))))))))) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs index 1606ba2eae..0835b6c238 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs @@ -48,7 +48,6 @@ (if (= active-tab :reference) (get value :reference) value) - value)) (mf/defc form* @@ -158,9 +157,10 @@ on-remap-token (mf/use-fn - (mf/deps token) + (mf/deps token token-type) (fn [valid-token new-name old-name description] (st/emit! + (dwtl/toggle-nested-token-path token-type new-name) (dwtl/update-token (:id token) {:name new-name :value (:value valid-token) @@ -171,9 +171,10 @@ on-rename-token (mf/use-fn - (mf/deps token) + (mf/deps token token-type) (fn [valid-token name description] (st/emit! + (dwtl/toggle-nested-token-path token-type name) (dwtl/update-token (:id token) {:name name :value (:value valid-token) @@ -210,19 +211,22 @@ (st/emit! (modal/show :tokens/remapping-confirmation {:remap-data remap-data :on-remap on-remap :on-rename on-rename})) - (st/emit! - (if is-create - (dwtl/create-token (ctob/make-token {:name name - :type token-type - :value (:value valid-token) - :description description})) - (dwtl/update-token (:id token) - {:name name - :value (:value valid-token) - :description description})) - (dwtl/open-token-type (:type token)) - (dwtp/propagate-workspace-tokens) - (modal/hide!))))) + (do + (when is-rename + (st/emit! (dwtl/toggle-nested-token-path token-type name))) + (st/emit! + (if is-create + (dwtl/create-token (ctob/make-token {:name name + :type token-type + :value (:value valid-token) + :description description})) + (dwtl/update-token (:id token) + {:name name + :value (:value valid-token) + :description description})) + (dwtl/open-token-type (:type token)) + (dwtp/propagate-workspace-tokens) + (modal/hide!)))))) ;; WORKAROUND: display validation errors in the form instead of crashing (fn [{:keys [errors]}] (let [error-messages (wte/humanize-errors errors) From 8098250b232c140e05c1ce956a7e69b858c6a6b3 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Tue, 19 May 2026 00:05:12 +0200 Subject: [PATCH 08/13] :bug: Fix problem with grid child positions --- .../src/shapes/modifiers/grid_layout.rs | 108 ++++++++++++++++-- 1 file changed, 97 insertions(+), 11 deletions(-) diff --git a/render-wasm/src/shapes/modifiers/grid_layout.rs b/render-wasm/src/shapes/modifiers/grid_layout.rs index 3599f1e595..362d362851 100644 --- a/render-wasm/src/shapes/modifiers/grid_layout.rs +++ b/render-wasm/src/shapes/modifiers/grid_layout.rs @@ -651,6 +651,37 @@ pub fn grid_cell_data<'a>( ) } +// Returns `(h_min, v_min, h_size, v_size)` — the child's bounding box expressed in the +// layout frame's own coordinate system (projected onto its `hv`/`vv` unit vectors). +// +// Using the frame axes rather than screen x/y is necessary when the parent grid frame +// is itself rotated: in that case `max_x - min_x` is the screen-AABB width, which +// differs from the width measured along the frame's horizontal axis. +fn child_frame_aabb(child_bounds: &Bounds, hv: Vector, vv: Vector) -> (f32, f32, f32, f32) { + let corners = child_bounds.points(); + let mut h_min = f32::INFINITY; + let mut h_max = f32::NEG_INFINITY; + let mut v_min = f32::INFINITY; + let mut v_max = f32::NEG_INFINITY; + for p in &corners { + let h = hv.x * p.x + hv.y * p.y; + let v = vv.x * p.x + vv.y * p.y; + if h < h_min { + h_min = h; + } + if h > h_max { + h_max = h; + } + if v < v_min { + v_min = v; + } + if v > v_max { + v_max = v; + } + } + (h_min, v_min, h_max - h_min, v_max - v_min) +} + fn child_position( child: &Shape, layout_bounds: &Bounds, @@ -667,12 +698,17 @@ fn child_position( let margin_right = layout_item.map(|i| i.margin_right).unwrap_or(0.0); let margin_bottom = layout_item.map(|i| i.margin_bottom).unwrap_or(0.0); + // Project corners onto the frame's own axes so that both a rotated child *and* a + // rotated parent frame are handled correctly. For an axis-aligned frame this + // reduces to max_x-min_x / max_y-min_y, so non-rotated layouts are unaffected. + let (_, _, child_width, child_height) = child_frame_aabb(child_bounds, hv, vv); + let vpos = match (cell.align_self, layout_data.align_items) { (Some(AlignSelf::Start), _) => margin_top, - (Some(AlignSelf::Center), _) => (cell.height - child_bounds.height()) / 2.0, - (Some(AlignSelf::End), _) => margin_bottom + cell.height - child_bounds.height(), - (_, AlignItems::Center) => (cell.height - child_bounds.height()) / 2.0, - (_, AlignItems::End) => margin_bottom + cell.height - child_bounds.height(), + (Some(AlignSelf::Center), _) => (cell.height - child_height) / 2.0, + (Some(AlignSelf::End), _) => margin_bottom + cell.height - child_height, + (_, AlignItems::Center) => (cell.height - child_height) / 2.0, + (_, AlignItems::End) => margin_bottom + cell.height - child_height, _ => margin_top, }; @@ -684,10 +720,10 @@ fn child_position( let hpos = match (cell.justify_self, layout_data.justify_items) { (Some(JustifySelf::Start), _) => margin_left, - (Some(JustifySelf::Center), _) => (cell.width - child_bounds.width()) / 2.0, - (Some(JustifySelf::End), _) => margin_right + cell.width - child_bounds.width(), - (_, JustifyItems::Center) => (cell.width - child_bounds.width()) / 2.0, - (_, JustifyItems::End) => margin_right + cell.width - child_bounds.width(), + (Some(JustifySelf::Center), _) => (cell.width - child_width) / 2.0, + (Some(JustifySelf::End), _) => margin_right + cell.width - child_width, + (_, JustifyItems::Center) => (cell.width - child_width) / 2.0, + (_, JustifyItems::End) => margin_right + cell.width - child_width, _ => margin_left, }; @@ -747,11 +783,27 @@ pub fn reflow_grid_layout( let Some(child) = cell.shape else { continue }; let child_bounds = bounds.find(child); + // Compute frame-axis projections once; used for both sizing and positioning. + let hv = layout_bounds.hv(1.0); + let vv = layout_bounds.vv(1.0); + let (h_min, v_min, child_frame_w, child_frame_h) = child_frame_aabb(&child_bounds, hv, vv); + + // resize_matrix scales the child in the parent's local frame coordinate system + // by (new_width / child_bounds.width()) in the h-axis and + // (new_height / child_bounds.height()) in the v-axis. For a rotated child the + // frame-projected extent differs from the intrinsic bounds dimensions, so we + // back-calculate the intrinsic target that will produce the desired + // frame-projected extent. let mut new_width = child_bounds.width(); if child.is_layout_horizontal_fill() { let margin_left = child.layout_item.map(|i| i.margin_left).unwrap_or(0.0); let margin_right = child.layout_item.map(|i| i.margin_right).unwrap_or(0.0); - new_width = cell.width - margin_left - margin_right; + let target_frame_w = cell.width - margin_left - margin_right; + new_width = if child_frame_w > MIN_SIZE { + target_frame_w * child_bounds.width() / child_frame_w + } else { + target_frame_w + }; let min_width = child.layout_item.and_then(|i| i.min_w).unwrap_or(MIN_SIZE); let max_width = child.layout_item.and_then(|i| i.max_w).unwrap_or(MAX_SIZE); new_width = new_width.clamp(min_width, max_width); @@ -761,7 +813,12 @@ pub fn reflow_grid_layout( if child.is_layout_vertical_fill() { let margin_top = child.layout_item.map(|i| i.margin_top).unwrap_or(0.0); let margin_bottom = child.layout_item.map(|i| i.margin_bottom).unwrap_or(0.0); - new_height = cell.height - margin_top - margin_bottom; + let target_frame_h = cell.height - margin_top - margin_bottom; + new_height = if child_frame_h > MIN_SIZE { + target_frame_h * child_bounds.height() / child_frame_h + } else { + target_frame_h + }; let min_height = child.layout_item.and_then(|i| i.min_h).unwrap_or(MIN_SIZE); let max_height = child.layout_item.and_then(|i| i.max_h).unwrap_or(MAX_SIZE); new_height = new_height.clamp(min_height, max_height); @@ -792,7 +849,36 @@ pub fn reflow_grid_layout( cell, ); - let delta_v = Vector::new_points(&child_bounds.nw, &position); + // Compute the child's reference point in the frame's coordinate system. + // For a rotated parent frame, (min_x, min_y) is wrong because it is the + // screen-AABB corner, not the frame-axis-aligned corner. + // child_ref = h_min * hv + v_min * vv gives the world-space point whose + // projections onto hv/vv are the child's minima along those axes — + // the "top-left in frame coordinates". + // + // For fill axes, resize_matrix scales local-x/y by (new_w / child_bounds.width()) + // anchored at nw. This shifts h_min/v_min: the post-resize minimum is + // h_min_new = nw_h + (h_min - nw_h) * scale_w + // We must translate FROM this post-resize minimum, not the pre-resize one. + let nw_h = hv.x * child_bounds.nw.x + hv.y * child_bounds.nw.y; + let nw_v = vv.x * child_bounds.nw.x + vv.y * child_bounds.nw.y; + let h_anchor = if child.is_layout_horizontal_fill() && child_bounds.width() > MIN_SIZE { + let scale_w = new_width / child_bounds.width(); + nw_h + (h_min - nw_h) * scale_w + } else { + h_min + }; + let v_anchor = if child.is_layout_vertical_fill() && child_bounds.height() > MIN_SIZE { + let scale_h = new_height / child_bounds.height(); + nw_v + (v_min - nw_v) * scale_h + } else { + v_min + }; + let child_ref = Point::new( + h_anchor * hv.x + v_anchor * vv.x, + h_anchor * hv.y + v_anchor * vv.y, + ); + let delta_v = Vector::new_points(&child_ref, &position); if delta_v.x.abs() > MIN_SIZE || delta_v.y.abs() > MIN_SIZE { transform.post_concat(&Matrix::translate(delta_v)); From c53856b5a91c2e1cdb7fd64f7bab65ed2d980f9d Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Tue, 19 May 2026 12:45:14 +0200 Subject: [PATCH 09/13] :bug: Clean modifiers when needed --- .../src/app/main/data/workspace/modifiers.cljs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index 9c5323ba66..f767cc9409 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -40,6 +40,10 @@ (def ^:private xf:without-uuid-zero (remove #(= % uuid/zero))) +;; Lets set-wasm-modifiers call clean-modifiers only on the +;; non-translation→translation transition instead of every frame. +(def ^:private wasm-structure-modifiers-active? (volatile! false)) + ;; Tracks whether the WASM renderer is currently in "interactive ;; transform" mode (a drag / resize / rotate gesture in progress). ;; Paired with `set-modifiers-start` / `set-modifiers-end` so the @@ -305,6 +309,7 @@ ;; skip shadows / blur). (ensure-interactive-transform-end!) (wasm.api/clean-modifiers) + (vreset! wasm-structure-modifiers-active? false) (set-wasm-props! (dsh/lookup-page-objects state) (:wasm-props state) []))) ptk/UpdateEvent @@ -682,12 +687,19 @@ (let [snap-pixel? (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) translation? (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] - ;; Only geometry (transform matrix) changes during drag. - (when-not translation? + (if translation? + ;; Pure translation: no structure changes needed. If structure + ;; modifiers were active from a previous non-translation frame + ;; (e.g. shape hovered over a frame then dragged back out), + ;; clear them now so the shape is not clipped by the old frame. + (when @wasm-structure-modifiers-active? + (wasm.api/clean-modifiers) + (vreset! wasm-structure-modifiers-active? false)) (let [objects (dsh/lookup-page-objects state)] (set-wasm-props! objects (:prev-wasm-props state) (:wasm-props state)) (wasm.api/clean-modifiers) - (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)))) + (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)) + (vreset! wasm-structure-modifiers-active? true))) (let [geometry-entries (parse-geometry-modifiers modif-tree) root-modifiers (into [] (map (fn [[id data]] [id (:transform data)])) geometry-entries) modifiers From aa1fb718e01c28cc503d088b2775e6ce903507e6 Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Tue, 19 May 2026 13:13:11 +0200 Subject: [PATCH 10/13] :bug: Fix invalid token on anonymous session --- backend/src/app/rpc/commands/verify_token.clj | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj index fc5c5397c0..5061cc84f0 100644 --- a/backend/src/app/rpc/commands/verify_token.clj +++ b/backend/src/app/rpc/commands/verify_token.clj @@ -247,13 +247,21 @@ (:organization-id claims) (assoc :org-team-id accepted-team-id))))) - ;; If we have not logged-in user, and invitation comes with member-id we - ;; redirect user to login, if no memeber-id is present and in the invitation - ;; token and registration is enabled, we redirect user the the register page. - {:invitation-token token - :iss :team-invitation - :redirect-to (if (or member-id registration-disabled?) :auth-login :auth-register) - :state :pending}))) + (do + ;; If the user is not logged-in and the token is invalid we throw the error + ;; Taiga issue #14182 + (when (nil? invitation) + (ex/raise :type :validation + :code :invalid-token + :hint "no invitation associated with the token")) + + ;; If we have not logged-in user, and invitation comes with member-id we + ;; redirect user to login, if no member-id is present and in the invitation + ;; token and registration is enabled, we redirect user the the register page. + {:invitation-token token + :iss :team-invitation + :redirect-to (if (or member-id registration-disabled?) :auth-login :auth-register) + :state :pending})))) ;; --- Default From ee6489b20282cced1b90b218675edec0e3054e3d Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Tue, 19 May 2026 13:19:06 +0200 Subject: [PATCH 11/13] :bug: Fix problem with login shoing wrong credentials --- frontend/src/app/main/ui/static.cljs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs index 07fb10ea17..1b4b0e16b0 100644 --- a/frontend/src/app/main/ui/static.cljs +++ b/frontend/src/app/main/ui/static.cljs @@ -27,6 +27,7 @@ [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]] + [app.main.ui.ds.product.loader :refer [loader*]] [app.main.ui.icons :as deprecated-icon] [app.main.ui.viewer.header :as viewer.header] [app.util.dom :as dom] @@ -558,8 +559,10 @@ auth-error? (= type :authentication) not-found? (= type :not-found) - authenticated? - (is-authenticated? profile) + authenticated? (is-authenticated? profile) + + ;; Keeps whether the user was authenticated when this component first mounted. + initial-authenticated? (mf/with-memo [] authenticated?) request-access? (and @@ -575,13 +578,23 @@ (if (or auth-error? not-found?) - (if (not authenticated?) + (cond + (not authenticated?) [:> context-wrapper* {:is-workspace workspace? :is-dashboard dashboard? :is-viewer view? :profile profile} [:> login-modal* {}]] + + ;; The user was not authenticated when exception-page first + ;; mounted, but they have just logged in via the login modal. + ;; Show a loading indicator to prevent briefly flashing the + ;; "no permission" dialog. + (not initial-authenticated?) + [:> loader* {:title (tr "labels.loading") :overlay true}] + + :else (when (get info :loaded false) (if request-access? [:> context-wrapper* {:is-workspace workspace? From 29ad9aa0579e41f114304f953e35de00bc4abba8 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Tue, 19 May 2026 10:35:14 +0200 Subject: [PATCH 12/13] :bug: Fix redirect after leaving team --- frontend/src/app/main/data/team.cljs | 10 +++++++++- frontend/src/app/main/ui/static.cljs | 3 ++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index aefa6acc61..de2a0ae400 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -82,7 +82,15 @@ (watch [_ state _] (when-let [team-id (or team-id (:current-team-id state))] (->> (rp/cmd! :get-team-members {:team-id team-id}) - (rx/map (partial members-fetched team-id)))))))) + (rx/map (partial members-fetched team-id)) + (rx/catch (fn [cause] + (let [{:keys [type]} (ex-data cause)] + (if (= :not-found type) + (do + (log/warn :hint "fetch-members: team not found, skipping" + :team-id (str team-id)) + (rx/empty)) + (rx/throw cause))))))))))) (defn- invitations-fetched [team-id invitations] diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs index 1b4b0e16b0..49dfd92f20 100644 --- a/frontend/src/app/main/ui/static.cljs +++ b/frontend/src/app/main/ui/static.cljs @@ -229,7 +229,8 @@ (mf/deps profile) (fn [] (let [team-id (:default-team-id profile)] - (st/emit! (dcm/go-to-dashboard-recent :team-id team-id))))) + (st/emit! (rt/assign-exception nil) + (dcm/go-to-dashboard-recent :team-id team-id))))) on-success (mf/use-fn From 1d2c158ebe15efe892c790aa7813db836869592e Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 19 May 2026 16:30:36 +0200 Subject: [PATCH 13/13] :bug: Fix commit pending numeric input on unmount without blur side effects --- .../src/app/main/ui/ds/controls/numeric_input.cljs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs index 74e6b9c95a..d0e135aa83 100644 --- a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs +++ b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs @@ -332,6 +332,7 @@ (fn [event] (let [text (dom/get-target-val event)] (mf/set-ref-val! raw-value* text) + (mf/set-ref-val! dirty-ref true) (reset! filter-id* text)))) on-token-apply @@ -389,12 +390,21 @@ (reset! is-open* false))) (when (mf/ref-val dirty-ref) - (apply-value (mf/ref-val raw-value*))) + (apply-value (mf/ref-val raw-value*)) + (mf/set-ref-val! dirty-ref false)) (when (fn? on-blur) (on-blur event)) (dom/blur! (mf/ref-val ref)))) - handle-unmount (h/use-ref-callback handle-blur) + commit-pending-on-unmount + (mf/use-fn + (mf/deps apply-value) + (fn [] + (when (mf/ref-val dirty-ref) + (apply-value (mf/ref-val raw-value*)) + (mf/set-ref-val! dirty-ref false)))) + + handle-unmount (h/use-ref-callback commit-pending-on-unmount) on-key-down (mf/use-fn