From 8bf411c347949a3340fb268f78164ef3f44e34b6 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 23 Jul 2026 12:58:20 +0200 Subject: [PATCH 1/4] :bug: Fix viewer wasm position data init (#10805) --- frontend/src/app/main/data/viewer.cljs | 14 +++++++++----- frontend/src/app/render_wasm/api.cljs | 3 +++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/main/data/viewer.cljs b/frontend/src/app/main/data/viewer.cljs index 87b9c1007b..1d9c49f9d3 100644 --- a/frontend/src/app/main/data/viewer.cljs +++ b/frontend/src/app/main/data/viewer.cljs @@ -222,7 +222,12 @@ (watch [_ state _] (if (and (features/active-feature? state "render-wasm/v1") (contains? cf/flags :available-viewer-wasm)) - (let [objects (dsh/lookup-page-objects state file-id page-id) + ;; Fallback matches the viewer UI when the URL omits page-id. + (let [page-id (or page-id + (-> (dsh/lookup-file-data state file-id) + :pages + first)) + objects (dsh/lookup-page-objects state file-id page-id) shapes (reduce-kv @@ -233,13 +238,12 @@ [] objects) - ;; Creates a stream from the async callback. This stream will only - ;; emit one single value after the objects have finished loading - ;; in the wasm memory. + ;; Positive size required: OffscreenCanvas(0, 0) crashes + ;; `_set_render_options` on some browsers. set-objects-stream (rx/create (fn [subs] - (wasm.api/init-canvas-context (js/OffscreenCanvas. 0 0)) + (wasm.api/init-canvas-context (js/OffscreenCanvas. 64 64)) (wasm.api/set-objects-callback shapes #(rx/push! subs :done)) nil))] diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index a96353dea4..388c3611d7 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -2175,6 +2175,9 @@ browser (sr/translate-browser cf/browser) dpr (get-dpr) [css-w css-h] (canvas-css-size canvas dpr) + ;; Avoid 0×0 Skia/GL surfaces (crashes on some browsers). + css-w (mth/max 1 css-w) + css-h (mth/max 1 css-h) can-listen? (fn? (.-addEventListener ^js canvas))] (when-not (nil? context) (let [handle (.registerContext ^js gl context #js {"majorVersion" 2})] From d94b1390711852fb0d3ad4535646c23727faed8f Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Fri, 24 Jul 2026 12:15:16 +0200 Subject: [PATCH 2/4] :bug: Fix text edition state when relesecting (#10798) * :bug: Fix text edition state when relesecting * :bug: Fix CI --- .../ui/specs/text-editor-v2.spec.js | 1 + .../playwright/ui/specs/tokens/text.spec.js | 47 +++++++++++++++++++ .../app/main/data/workspace/selection.cljs | 6 +-- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/frontend/playwright/ui/specs/text-editor-v2.spec.js b/frontend/playwright/ui/specs/text-editor-v2.spec.js index a58c71dba4..2219a4ea8c 100644 --- a/frontend/playwright/ui/specs/text-editor-v2.spec.js +++ b/frontend/playwright/ui/specs/text-editor-v2.spec.js @@ -344,6 +344,7 @@ test("Preserves empty fill after editing text without changes", async ({ page }) await workspace.createTextShape(190, 150, 300, 200, initialText); await workspace.textEditor.stopEditing(); + await workspace.page.getByRole('button', { name: 'Fill', exact: true }).click(); const fillColorButton = workspace.page.getByRole("button", { name: "#000000", diff --git a/frontend/playwright/ui/specs/tokens/text.spec.js b/frontend/playwright/ui/specs/tokens/text.spec.js index 3c34376934..d7b633fd34 100644 --- a/frontend/playwright/ui/specs/tokens/text.spec.js +++ b/frontend/playwright/ui/specs/tokens/text.spec.js @@ -1,4 +1,7 @@ import { test, expect } from "@playwright/test"; +import { + unfoldTokenType, +} from "./helpers"; import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage"; test.beforeEach(async ({ page }) => { @@ -27,3 +30,47 @@ test("BUG 13958 - Fill token gets detached when editing text shape", async ({ pa // Assert token is still attached to the shape await expect(workspacePage.rightSidebar.getByLabel("xx.alias.color.text.default", { exact: true })).toBeVisible(); }); + +test("Selection change clears text-edition mode so tokens can be applied", async ({ page }) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.mockGetFile("workspace/get-file-13958.json"); + await workspacePage.goToWorkspace(); + await workspacePage.rectShapeButton.click(); + await workspacePage.clickWithDragViewportAt(128, 128, 200, 100); + await workspacePage.clickLeafLayer("Rectangle"); + + // Enter text editing on the text layer + await workspacePage.clickLeafLayer("Design tokens are a set"); + await workspacePage.page.keyboard.press("Enter"); + await expect(workspacePage.page.getByTestId("text-editor")).toBeVisible(); + + // Shift-click another layer to change selection while editing + await workspacePage.layers + .getByTestId("layer-row") + .filter({ hasText: "Rectangle" }) + .click({ modifiers: ["Shift"] }); + + // Text editor should close because selection change emits :interrupt + await expect(workspacePage.page.getByTestId("text-editor")).not.toBeAttached(); + + // Open tokens tab and try to apply a fill token — should succeed without warning + await page.getByRole("tab", { name: "Tokens" }).click(); + + const tokensSidebar = page.getByTestId("tokens-sidebar"); + + await unfoldTokenType(tokensSidebar, "color"); + + // Right-click a color token and apply as fill + await tokensSidebar + .getByRole('button', { name: '#934846 xx.global.color.red.' }) + .click({ button: "right" }); + await workspacePage.tokenContextMenuForToken.getByText("Fill").click(); + + // Verify no warning toast appeared about text editing + await expect( + page.getByRole("alert").filter({ + hasText: /Tokens can't be applied while editing text/i, + }), + ).not.toBeVisible(); +}); diff --git a/frontend/src/app/main/data/workspace/selection.cljs b/frontend/src/app/main/data/workspace/selection.cljs index 4798517122..3eab8b2d6b 100644 --- a/frontend/src/app/main/data/workspace/selection.cljs +++ b/frontend/src/app/main/data/workspace/selection.cljs @@ -221,7 +221,7 @@ (ptk/reify ::deselect-shape ptk/WatchEvent (watch [_ _ _] - (rx/of ::dwsp/interrupt)) + (rx/of :interrupt ::dwsp/interrupt)) ptk/UpdateEvent (update [_ state] (-> state @@ -236,7 +236,7 @@ (ptk/reify ::shift-select-shapes ptk/WatchEvent (watch [_ _ _] - (rx/of ::dwsp/interrupt)) + (rx/of :interrupt ::dwsp/interrupt)) ptk/UpdateEvent (update [_ state] (let [objects (or objects (dsh/lookup-page-objects state)) @@ -275,7 +275,7 @@ ;; the event loop expand-s (->> (rx/of (dwc/expand-all-parents ids objects)) (rx/observe-on :async)) - interrupt-s (rx/of ::dwsp/interrupt)] + interrupt-s (rx/of :interrupt ::dwsp/interrupt)] (rx/merge expand-s interrupt-s))))) (defn select-all From b54c1f316a8f4e8e56138c87989f4e5371781818 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Sat, 25 Jul 2026 09:56:12 +0200 Subject: [PATCH 3/4] :sparkles: Add minor improvements for error report script --- .serena/memories/scripts/error-reports.md | 29 ++-- scripts/error-reports.mjs | 177 +++++++++++++++++++--- 2 files changed, 176 insertions(+), 30 deletions(-) diff --git a/.serena/memories/scripts/error-reports.md b/.serena/memories/scripts/error-reports.md index 1978cd0eb0..126aae7903 100644 --- a/.serena/memories/scripts/error-reports.md +++ b/.serena/memories/scripts/error-reports.md @@ -7,7 +7,7 @@ - Querying error reports from the database for debugging or analysis - Filtering errors by source, kind, tenant, or backend version - Exporting error data in JSON, NDJSON, or table format -- Computing error statistics (top signatures, per-host breakdown, hourly distribution) +- Computing error statistics (top signatures, version, source, audit-log kind, hourly distribution, bursts, heatmap) - Investigating specific error reports by ID ## Prerequisites @@ -70,7 +70,7 @@ WHERE id = ''; | `--env ` | Custom .env file path | `.env` | | `-h, --help` | Show help message | — | -**Streaming behavior:** With `--all` or `--format ndjson`, items are printed as they arrive (no buffering). `--all` + `table` prints rows immediately. `--all` + `json` streams NDJSON (one JSON object per line). +**Streaming behavior:** With `--all`, output must be `ndjson` or `table`; `--all --format json` is rejected because `--all` streams output. `--all --format table` prints rows immediately. `--format ndjson` always streams one JSON object per line. #### `get` - Get a single error report by ID @@ -94,7 +94,7 @@ WHERE id = ''; ./scripts/error-reports.mjs stats [options] ``` -Reads from `--input `, stdin (piped), or fetches from API. Computes aggregations by signature, host, tenant, version, source, kind, and hour. +Reads from `--input `, stdin (piped), or fetches from API. Computes aggregations by signature, version, source, audit-log kind, hour, optional 5-minute bursts, and optional day-of-week × hour heatmap. **Options:** @@ -104,6 +104,8 @@ Reads from `--input `, stdin (piped), or fetches from API. Computes aggreg | `--to ` | End of interval (ISO timestamp) | — | | `--limit ` | Items per page when fetching from API | `200` | | `--input ` | Read from local JSON/NDJSON file instead of API | — | +| `--burst` | Detect 5-minute windows above 3× the average rate | `false` | +| `--heatmap` | Show day-of-week × hour-of-day heatmap | `false` | | `-f, --format ` | Output format: `json` or `table` | `table` | | `--env ` | Custom .env file path | `.env` | @@ -119,10 +121,12 @@ The `--source` filter accepts these values: With `--normalize-hints` (or always in `stats`), hints are normalized by stripping dynamic values: -1. URIs (`https://...`) → `` +1. File IDs in file-id context → `` 2. UUIDs (8-4-4-4-12 hex) → `` -3. Elapsed times (`7.5s`, `2m3.027s`) → `` -4. Numeric IDs in parentheses `(12345)` → `()` +3. Numeric IDs in parentheses `(12345)` → `()` +4. Elapsed times (`7.5s`, `2m3.027s`) → `` +5. URIs (`https://...`) → `` +6. Unicode quotes and whitespace normalized ## Examples @@ -143,8 +147,8 @@ With `--normalize-hints` (or always in `stats`), hints are normalized by strippi ### Save to file with --output ```bash -./scripts/error-reports.mjs list --all --format json -o errors.json ./scripts/error-reports.mjs list --all --format ndjson -o errors.ndjson +./scripts/error-reports.mjs list --format json -o errors.json ``` ### Filter by source @@ -192,9 +196,9 @@ With `--normalize-hints` (or always in `stats`), hints are normalized by strippi ./scripts/error-reports.mjs list --source audit-log --kind exception-page --tenant production --limit 50 ``` -### Stats from API +### Stats with burst and heatmap analysis ```bash -./scripts/error-reports.mjs stats --from 2026-07-23T00:00:00Z --to 2026-07-23T23:59:59Z +./scripts/error-reports.mjs stats --from 2026-07-23T00:00:00Z --to 2026-07-23T23:59:59Z --burst --heatmap ``` ### Stats from file @@ -213,7 +217,7 @@ With `--normalize-hints` (or always in `stats`), hints are normalized by strippi Human-readable table format for terminal display. With `--all`, rows stream as they arrive. ### JSON -Single page: `{items: [...], nextSince, nextId}`. With `--all`: NDJSON (one JSON object per line). +Single page: `{items: [...], nextSince, nextId}`. `--all` cannot be combined with `--format json`; use `--format ndjson` for streaming. ### NDJSON One JSON object per line, always streaming. Pipe-friendly: `| jq -c '.hint'`, `| wc -l`. @@ -250,7 +254,7 @@ Use `--from` and `--to` to bound the query. These map to the server's `--since` - **Authentication required** - Uses access token with `error-reports:read` permission - **API endpoint configurable** - Set via `PENPOT_API_URI` in `.env` file - **Table is default format** - Use `--format json` for structured JSON, `--format ndjson` for streaming -- **Streaming with --all** - Items print as they arrive, no buffering +- **Streaming with --all** - Items print as they arrive, no buffering. Use `--format ndjson` or `--format table`; `--all --format json` is rejected. - **Filters are combinable** - All filter options can be used together - **Both flag formats supported** - `--option=value` and `--option value` both work - **Ascending order** - Server returns oldest items first (changed from DESC) @@ -272,8 +276,7 @@ The tool provides helpful error messages for common issues: ``` - **stats from pipe**: Fetch data once, compute stats ```bash - ./scripts/error-reports.mjs list --all --format json -o errors.json - ./scripts/error-reports.mjs stats --input errors.json + ./scripts/error-reports.mjs list --all --format ndjson | ./scripts/error-reports.mjs stats ``` - **stats from NDJSON pipe**: Works with NDJSON format too ```bash diff --git a/scripts/error-reports.mjs b/scripts/error-reports.mjs index 31ba4f0154..241c608987 100755 --- a/scripts/error-reports.mjs +++ b/scripts/error-reports.mjs @@ -146,13 +146,23 @@ async function rpcCall(config, method, params = {}) { function normalizeHint(hint) { if (!hint) return hint; - let h = hint; + let h = String(hint) + .replace(/[“”]/g, '"') + .replace(/[‘’]/g, "'") + .replace(/\\"/g, '"') + .replace(/\s+/g, ' ') + .trim(); + h = h.replace(/https?:\/\/"[^"]*"/g, ''); h = h.replace(/https?:\/\/\S+/g, ''); + + h = h.replace(/\b(?:file[-_]?id|file_id|file-id)\b\s*[=:]\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gi, (match, id) => match.replace(id, '')); h = h.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ''); - h = h.replace(/[\d.]+[smh](?:[\d.]+[smh])?/g, ''); h = h.replace(/\(\d+\)/g, '()'); - return h.trim(); + h = h.replace(/\b\d+(?:\.\d+)?(?:ms|s|m|h|d)(?:\s*\d+(?:\.\d+)?(?:ms|s|m|h|d))*\b/g, ''); + h = h.replace(/\s+/g, ' ').trim(); + + return h || '(empty)'; } // ============================================================================ @@ -296,6 +306,12 @@ async function cmdList(config, args) { // --to maps to server's --until (newest boundary) if (args.to) params.until = args.to; + if (args.all && args.format === "json") { + console.error("Error: --all cannot be combined with --format json because --all streams output."); + console.error("Use --format ndjson for streaming, --format table for human-readable streaming, or omit --all for a single JSON page."); + process.exit(1); + } + // Output target const out = args.output ? createWriteStream(args.output) @@ -452,10 +468,10 @@ async function cmdStats(config, args) { item._normHint = normalizeHint(item.hint) || "(empty)"; } + const auditLogItems = items.filter((item) => item.source === "audit-log"); + // Aggregations const byHint = {}; - const byHost = {}; - const byTenant = {}; const byVersion = {}; const bySource = {}; const byKind = {}; @@ -464,11 +480,8 @@ async function cmdStats(config, args) { for (const item of items) { count(byHint, item._normHint); - count(byHost, item.host || item.tenant || "(unknown)"); - count(byTenant, item.tenant || "(unknown)"); count(byVersion, item.version || "(unknown)"); count(bySource, item.source || "(unknown)"); - count(byKind, item.kind || "(none)"); const hour = item.createdAt ? item.createdAt.substring(11, 13) : "??"; count(byHour, hour); @@ -476,18 +489,41 @@ async function cmdStats(config, args) { if (item.profileId) profiles.add(item.profileId); } + for (const item of auditLogItems) { + count(byKind, item.kind || "(none)"); + } + + const burstWindows = buildBurstWindows(items, 5 * 60 * 1000); + const heatmap = buildHeatmap(items); + if (args.format === "json") { - console.log(formatJson({ + const stats = { total: items.length, uniqueProfiles: profiles.size, byHint: sortDesc(byHint), - byHost: sortDesc(byHost), - byTenant: sortDesc(byTenant), byVersion: sortDesc(byVersion), bySource: sortDesc(bySource), byKind: sortDesc(byKind), - byHour: sortDesc(byHour) - })); + byHour: sortDesc(byHour), + kindScope: "audit-log" + }; + + if (args.burst) { + stats.bursts = burstWindows.map((burst) => ({ + start: burst.start, + end: burst.end, + count: burst.count, + averagePerWindow: Number(burst.averagePerWindow.toFixed(2)), + ratio: Number(burst.ratio.toFixed(2)), + ids: burst.ids + })); + } + + if (args.heatmap) { + stats.heatmap = heatmap; + } + + console.log(formatJson(stats)); } else { console.log(`=== Error Stats ===`); console.log(`Total: ${items.length}`); @@ -496,11 +532,17 @@ async function cmdStats(config, args) { printTable("By Signature (normalized hint)", byHint, items.length); printTable("By Source", bySource, items.length); - printTable("By Kind", byKind, items.length); - printTable("By Host", byHost, items.length); - printTable("By Tenant", byTenant, items.length); + printTable("By Kind (audit-log only)", byKind, auditLogItems.length || 1); printTable("By Version", byVersion, items.length); printTable("By Hour (UTC)", byHour, items.length); + + if (args.burst) { + printBurstTable(burstWindows); + } + + if (args.heatmap) { + printHeatmapTable(heatmap); + } } } @@ -525,6 +567,105 @@ function parseItems(raw) { } } +function buildBurstWindows(items, windowMs) { + const validItems = items + .filter((item) => item.createdAt && !Number.isNaN(Date.parse(item.createdAt))) + .map((item) => ({ ...item, _createdAtMs: Date.parse(item.createdAt) })) + .sort((a, b) => a._createdAtMs - b._createdAtMs); + + if (validItems.length === 0) return []; + + const startMs = validItems[0]._createdAtMs; + const endMs = validItems[validItems.length - 1]._createdAtMs; + const windowCount = Math.max(1, Math.ceil((endMs - startMs) / windowMs)); + const averagePerWindow = validItems.length / windowCount; + const threshold = Math.max(1, averagePerWindow * 3); + const byWindow = new Map(); + + for (const item of validItems) { + const idx = Math.max(0, Math.min(windowCount - 1, Math.floor((item._createdAtMs - startMs) / windowMs))); + if (!byWindow.has(idx)) byWindow.set(idx, []); + byWindow.get(idx).push(item); + } + + return Array.from(byWindow.entries()) + .filter(([, windowItems]) => windowItems.length > threshold) + .map(([idx, windowItems]) => { + const start = new Date(startMs + idx * windowMs).toISOString(); + const end = new Date(startMs + (idx + 1) * windowMs).toISOString(); + return { + start, + end, + count: windowItems.length, + averagePerWindow, + ratio: windowItems.length / averagePerWindow, + ids: windowItems.map((item) => item.id).slice(0, 12) + }; + }) + .sort((a, b) => b.count - a.count || a.start.localeCompare(b.start)); +} + +function buildHeatmap(items) { + const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + const heatmap = {}; + for (const item of items) { + if (!item.createdAt || Number.isNaN(Date.parse(item.createdAt))) continue; + const date = new Date(item.createdAt); + const day = days[(date.getUTCDay() + 6) % 7]; + const hour = String(date.getUTCHours()).padStart(2, "0"); + const key = `${day}-${hour}`; + heatmap[key] = (heatmap[key] || 0) + 1; + } + return heatmap; +} + +function printBurstTable(bursts) { + console.log("5-minute bursts (>3x average):"); + if (!bursts.length) { + console.log(" No bursts detected."); + console.log(""); + return; + } + + const rows = bursts.map((burst) => [ + `${burst.start} → ${burst.end}`, + burst.count, + burst.averagePerWindow.toFixed(2), + `${burst.ratio.toFixed(2)}x`, + burst.ids.join(", ") + ]); + const widths = computeColWidths(["Window", "Count", "Avg", "Ratio", "Error IDs"], rows); + console.log(padRow(["Window", "Count", "Avg", "Ratio", "Error IDs"], widths)); + console.log(widths.map((w) => "-".repeat(w)).join("-+-")); + rows.forEach((row) => console.log(padRow(row, widths))); + console.log(""); +} + +function printHeatmapTable(heatmap) { + const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + const hours = Array.from({ length: 24 }, (_, hour) => String(hour).padStart(2, "0")); + const max = Math.max(...hours.flatMap((hour) => days.map((day) => heatmap[`${day}-${hour}`] || 0)), 1); + + console.log("Heatmap (day-of-week × hour UTC):"); + console.log(`Max bucket: ${max}`); + console.log(""); + console.log(" " + hours.map((hour) => hour.slice(0, 1)).join(" ")); + for (const day of days) { + const cells = hours.map((hour) => { + const value = heatmap[`${day}-${hour}`] || 0; + if (value === 0) return "·"; + if (value / max >= 0.75) return "4"; + if (value / max >= 0.5) return "3"; + if (value / max >= 0.25) return "2"; + return "1"; + }); + console.log(`${day.padEnd(6)} ${cells.join(" ")}`); + } + console.log(""); + console.log("Legend: ·=0 1=low 2=medium 3=high 4=peak"); + console.log(""); +} + function count(map, key) { map[key] = (map[key] || 0) + 1; } @@ -577,7 +718,7 @@ program .option("--version ", "Filter by version") .option("--hint ", "Filter by hint (ILIKE match)") .option("-a, --all", "Fetch all pages automatically (streams output)", false) - .option("-f, --format ", "Output format (json|table|ndjson)", "table") + .option("-f, --format ", "Output format (json for one page, table, or ndjson for streaming)", "table") .option("--normalize-hints", "Normalize hints by stripping dynamic values", false) .option("-o, --output ", "Write output to file instead of stdout") .option("--env ", "Custom .env file path") @@ -605,6 +746,8 @@ program .option("--to ", "End of interval (ISO timestamp)") .option("--limit ", "Items per page (default: 200)", (value) => parseInt(value, 10), 200) .option("--input ", "Read from local JSON/NDJSON file instead of API") + .option("--burst", "Detect 5-minute bursts above 3x the average window rate", false) + .option("--heatmap", "Show day-of-week × hour-of-day heatmap", false) .option("-f, --format ", "Output format (json|table)", "table") .option("--env ", "Custom .env file path") .action(async (options) => { From 0b072b22b03cd8f81d0d191625019098c3e86992 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 27 Jul 2026 09:01:43 +0200 Subject: [PATCH 4/4] :books: Update changelog --- CHANGES.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 82e256b627..293ebff0c5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,16 @@ # CHANGELOG +## 2.17.1 (Unreleased) + +### :bug: Bugs fixed + +- Fix malformed get-font-variants request when team-id is missing from dashboard URL [#10644](https://github.com/penpot/penpot/issues/10644) (PR: [#10645](https://github.com/penpot/penpot/pull/10645)) +- Fix malformed get-profiles-for-file-comments request when file-id is missing from workspace URL [#10652](https://github.com/penpot/penpot/issues/10652) (PR: [#10655](https://github.com/penpot/penpot/pull/10655)) +- Fix workspace crash when holding an arrow key on a selection due to excessive re-renders [#10726](https://github.com/penpot/penpot/issues/10726) (PR: [#10736](https://github.com/penpot/penpot/pull/10736)) +- Fix asset download failing with S3 auth conflict when using access token [#10776](https://github.com/penpot/penpot/issues/10776) (PR: [#10777](https://github.com/penpot/penpot/pull/10777)) +- Fix internal error when dragging inner layout with Boolean operations [#10647](https://github.com/penpot/penpot/issues/10647) (PR: [#10778](https://github.com/penpot/penpot/pull/10778)) +- Fix viewer crash with WASM panic when opening URL with page-id [#10800](https://github.com/penpot/penpot/issues/10800) (PR: [#10805](https://github.com/penpot/penpot/pull/10805)) + ## 2.17.0 ### :rocket: Epics and highlights @@ -43,6 +54,8 @@ ### :bug: Bugs fixed +- Fix Plugin API variant creation failing due to undocumented multi-step workflow [#10075](https://github.com/penpot/penpot/issues/10075) (PR: [#10149](https://github.com/penpot/penpot/pull/10149)) +- Fix workspace crash when editing text shapes with degenerate selrect [#10617](https://github.com/penpot/penpot/issues/10617) (PR: [#10618](https://github.com/penpot/penpot/pull/10618)) - Fix SVG stroke line join not applied when pasting strokes [#4836](https://github.com/penpot/penpot/issues/4836) (PR: [#9982](https://github.com/penpot/penpot/pull/9982), [#10019](https://github.com/penpot/penpot/pull/10019)) - Fix blend-mode hover preview on canvas not reverted when dismissing dropdown (by @davidv399) [#9235](https://github.com/penpot/penpot/issues/9235) (PR: [#9237](https://github.com/penpot/penpot/pull/9237)) - Fix View Mode mouse-leave and click in combination not working [#4855](https://github.com/penpot/penpot/issues/4855) (PR: [#9991](https://github.com/penpot/penpot/pull/9991))