From 56be383ce2535b1cefcdaeac1ec48b10a4841346 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Sat, 8 Aug 2026 09:52:47 +0000 Subject: [PATCH 01/19] Keep runs tray polling while run is active --- .changeset/runs-tray-active-poll.md | 7 + .../src/client/progress/RunsTray.spec.tsx | 75 ++++- .../core/src/client/progress/RunsTray.tsx | 15 +- plans/slides-feedback-2026-08-07.md | 259 ++++++++++++++++++ .../app/components/editor/ExportMenu.tsx | 8 +- ...now-tells-you-why-a-deck-fell-back-to-a.md | 6 + 6 files changed, 365 insertions(+), 5 deletions(-) create mode 100644 .changeset/runs-tray-active-poll.md create mode 100644 plans/slides-feedback-2026-08-07.md create mode 100644 templates/slides/changelog/2026-08-08-google-slides-export-now-tells-you-why-a-deck-fell-back-to-a.md diff --git a/.changeset/runs-tray-active-poll.md b/.changeset/runs-tray-active-poll.md new file mode 100644 index 0000000000..9bbdedf091 --- /dev/null +++ b/.changeset/runs-tray-active-poll.md @@ -0,0 +1,7 @@ +--- +"@agent-native/core": patch +--- + +Keep the runs tray refreshing while a run still reads as active, so a run +abandoned mid-flight (budget exhausted, dead worker) can no longer spin +indefinitely in hosts that disable idle polling with `pollMs={0}`. diff --git a/packages/core/src/client/progress/RunsTray.spec.tsx b/packages/core/src/client/progress/RunsTray.spec.tsx index f1a6b14c34..bfee8ab682 100644 --- a/packages/core/src/client/progress/RunsTray.spec.tsx +++ b/packages/core/src/client/progress/RunsTray.spec.tsx @@ -8,7 +8,7 @@ import { DropdownMenu, DropdownMenuContent, } from "../components/ui/dropdown-menu.js"; -import { RunsTrayMenuItem } from "./RunsTray.js"; +import { RunsTray, RunsTrayMenuItem } from "./RunsTray.js"; vi.mock("../api-path.js", () => ({ agentNativePath: (path: string) => path, @@ -75,3 +75,76 @@ describe("RunsTrayMenuItem", () => { expect(document.body.textContent).toContain("No recent runs"); }); }); + +describe("RunsTray polling", () => { + let container: HTMLDivElement; + let root: Root; + + const runningRun = { + id: "run-1", + owner: "user@example.com", + title: "Build deck", + percent: null, + status: "running", + startedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + completedAt: null, + }; + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "ResizeObserver", + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + document.body.innerHTML = ""; + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("keeps refreshing an active run even when idle polling is disabled", async () => { + const fetchMock = vi.fn(async () => Response.json([runningRun])); + vi.stubGlobal("fetch", fetchMock); + + await act(async () => { + root.render(); + }); + + const afterMount = fetchMock.mock.calls.length; + expect(afterMount).toBeGreaterThan(0); + + await act(async () => { + await vi.waitFor( + () => expect(fetchMock.mock.calls.length).toBeGreaterThan(afterMount), + { timeout: 15_000, interval: 250 }, + ); + }); + }, 20_000); + + it("does not poll when nothing is running and idle polling is disabled", async () => { + const fetchMock = vi.fn(async () => Response.json([])); + vi.stubGlobal("fetch", fetchMock); + + await act(async () => { + root.render(); + }); + + const afterMount = fetchMock.mock.calls.length; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 200)); + }); + expect(fetchMock.mock.calls.length).toBe(afterMount); + }); +}); diff --git a/packages/core/src/client/progress/RunsTray.tsx b/packages/core/src/client/progress/RunsTray.tsx index d2143569d2..da83e0fb9c 100644 --- a/packages/core/src/client/progress/RunsTray.tsx +++ b/packages/core/src/client/progress/RunsTray.tsx @@ -30,6 +30,15 @@ import { cn } from "../utils.js"; type AgentRunDto = AgentRun; type RunsTrayTriggerVariant = "icon" | "pill"; const RUN_CHANGE_SETTLE_MS = 250; +/** + * Cadence used while a run still reads as active, even for hosts that opted + * out of idle polling with `pollMs={0}`. Those hosts only refresh on mount and + * on a `runs` change event, and a run abandoned mid-flight (budget exhausted, + * dead worker) emits neither — so the spinner has no path back to a terminal + * status. Polling only while something looks active keeps the idle cost at + * zero and still lets the server's stale sweep terminalize the row. + */ +const ACTIVE_RUN_POLL_MS = 5000; interface RunsTrayProps { /** Poll interval in ms. 0 disables. Default 3000. */ @@ -108,7 +117,11 @@ function useRunsTrayState({ return () => window.clearTimeout(timeout); }, [refresh, runsVersion]); - usePollLoop(refresh, { intervalMs: pollMs, enabled: pollMs > 0 }); + const hasActiveRun = runs.some((run) => run.status === "running"); + usePollLoop(refresh, { + intervalMs: pollMs > 0 ? pollMs : ACTIVE_RUN_POLL_MS, + enabled: pollMs > 0 || hasActiveRun, + }); const dismissRun = useCallback( async (runId: string) => { diff --git a/plans/slides-feedback-2026-08-07.md b/plans/slides-feedback-2026-08-07.md new file mode 100644 index 0000000000..6e328eea97 --- /dev/null +++ b/plans/slides-feedback-2026-08-07.md @@ -0,0 +1,259 @@ +# Slides feedback triage — 2026-08-07 + +Source: user feedback deck (6 screenshots) covering the first-run onboarding, +new-deck wizard, a failed generation run, and the Google Slides export. + +Each item below was checked against real code. Status is one of +**Fixed**, **Confirmed / not fixed**, **Not reproducible from code**, or +**Product decision**. + +--- + +## 1. Model selection defaults to the cheapest OpenAI model with no explanation + +**Report.** "How is the model chosen? As a regular Claude user, I'm curious why +the system defaults to Luna." Wants task-aware defaults (the slides app should +default to whichever model is best at decks), plus an info bubble explaining the +choice or an explicit Good/Better/Best tier. + +**Status: Product decision — behaves as designed, the design is the complaint.** + +- The default is a hardcoded framework constant, not a heuristic: + `FRAMEWORK_DEFAULT_OPENAI_MODEL = "gpt-5.6-luna"` in + `packages/core/src/agent/model-config.ts:245-257`, surfaced as `DEFAULT_MODEL` + (`:357-359`). +- Resolution order at the composer is: persisted user selection → + `DEFAULT_MODEL` (`packages/core/src/client/use-chat-models.ts:97-109`, + `:233-295`). Nothing looks at the task or the app. +- The `$ / $$ / $$$` badges are a hardcoded name-substring heuristic, not real + cost data: `MODEL_COST_TIERS` in + `packages/toolkit/src/composer/TiptapComposer.tsx:984-1014`, mirrored by + `MODEL_COST_ORDER` in `packages/core/src/client/chat-model-groups.ts:60-83`. +- **A per-app override already exists and slides does not use it.** + `packages/core/src/agent/app-model-defaults.ts:80-201` stores an org-then-user + default keyed `agent-app-model-default:`, and the request path already + consults it ahead of the global engine setting + (`packages/core/src/agent/engine/registry.ts:1160-1194`). It is editable over + `/_agent-native/agent-model-defaults` + (`packages/core/src/server/agent-chat-plugin.ts:3921-4010`). No slides code + references it. +- No tooltip or info affordance exists on the picker + (`TiptapComposer.tsx:1186-1205`, dropdown `:1328-1482`). + +**Proposed (needs approval — this is UX, and the picker is already dense).** +Set a slides app default through the existing `app-model-defaults` store rather +than building new machinery, and attach the "why" to the *existing* selected-row +affordance instead of adding a visible info bubble. Tradeoff: an app-level +default silently overrides the framework default for everyone in the org, so it +needs to be visible somewhere in settings. + +--- + +## 2. "Connect" on the Notion card in first-run onboarding did nothing + +**Report.** On "This app is an agent." → Agent integrations, clicking **Connect** +on Notion produced no visible response. + +**Status: Not reproducible from code — needs a browser repro.** + +- Step: `packages/core/src/client/onboarding/FirstRunOnboarding.tsx:535-638`. +- Button + handler: `:853-903` (button) and `:197-266` (`connectIntegration`). +- Notion is catalogued as `authMode: "oauth"`, `connectionMode: "oauth"`, + `availability: "ready"`, with no `supportsOrganizationScope` + (`packages/core/src/client/resources/mcp-integration-catalog.ts:173-189`), so + it takes the OAuth branch and calls `navigateToMcpOAuthStart(...)`, which + schedules `window.location.assign(url)` (`:957-968`). + +The only silent paths are the early return when the card is already connected or +busy (`:204-209`, with the button disabled in that state at `:893-895`). What +the code *cannot* rule out is the failure the screenshot is consistent with: +`navigateToMcpOAuthStart` gives no spinner, no toast, and no error, so if the +OAuth start URL 4xxs or the navigation is blocked, the click is +indistinguishable from a no-op. **That absence of feedback is the defect worth +fixing even if the underlying navigation usually works.** Not fixed here because +the right fix (pending state + surfaced error) is a UX change on a screen the +user already found busy. + +--- + +## 3. Reference decks: no variety, no "no reference deck" option + +**Report.** Expected more reference deck choices or an explicit "none". + +**Status: Confirmed, partially a data problem — not fixed.** + +- Step: `templates/slides/app/components/editor/NewDeckReferenceStep.tsx:82-105`, + `:271-383`, `:492-505`. +- The design-system list is **not** hardcoded to Builder.io Official. It comes + from `useDesignSystems()` + (`templates/slides/app/hooks/use-design-systems.ts:15-24`) → the + `list-design-systems` action + (`templates/slides/actions/list-design-systems.ts:36-156`), which returns + accessible DB rows. "Builder.io Official" was the only option because it was + the only row in that workspace, not because the UI restricts it. +- The reference-deck select is deck-backed (starred decks, then other decks, + then recents, `NewDeckReferenceStep.tsx:298-383`). There is **no explicit + "none" item.** +- "None" *is* reachable, just not labelled: **Skip** passes explicit nulls + (`FirstDeckOnboardingFlow.tsx:252-257` → + `startGeneration(promptFiles, { designSystemId: null, referenceDeckId: null })`), + and the defaults only kick in when the field is `undefined` + (`actions/create-deck-generation.ts:217-228`, `actions/create-deck.ts:286-292`). + +**Proposed.** Add a "No reference deck" item to the existing select rather than +another button — the capability is already wired, only the label is missing. +Tradeoff: it makes Skip and "None + Continue" two paths to the same outcome. + +--- + +## 4a. Run failed with `run_budget_exhausted` after 12m 20s + +**Status: Working as designed.** The message is deliberate and the failure is +honest — `packages/core/src/agent/run-loop-with-resume.ts:584-615` sends +`{ type: "error", errorCode: RUN_BUDGET_EXHAUSTED_ERROR_CODE, recoverable: false }`, +mirrored in `packages/core/src/agent/production-agent.ts:6626-6632`. The chat +row is terminalized correctly by +`packages/core/src/agent/run-manager.ts:1385-1510`. No bug here. + +## 4b. The run indicator kept spinning ("1 active run") for about an hour + +**Report.** "If it hits an error it can't solve I expect it to stop or time out. +It ran for like an hour just spinning." + +**Status: FIXED.** + +Two independent systems: the chat's `agent_runs` row (correctly terminalized, +above) and the tray's `progress_runs` row, which the agent opens with +`manage-progress start` and is expected to close itself. A run that dies on +budget exhaustion never reaches its `complete` call, so the `progress_runs` row +stays `running`. + +There *is* a server-side stale sweep — `cancelStaleRunsForOwner` +(`packages/core/src/progress/store.ts:240-273`) cancels rows untouched for 5 +minutes — but it only runs inside `listRuns` +(`packages/core/src/progress/store.ts:308-339`). Slides mounts the tray with +`` +(`templates/slides/app/components/layout/Header.tsx:71`, same in +`EditorToolbar.tsx:1108`, and in analytics and design), so `listRuns` is only +called on mount and on a `runs` change event — and an abandoned run emits +neither. The spinner had no path back to a terminal state short of a reload. + +**Fix** (`packages/core/src/client/progress/RunsTray.tsx`): poll while a run +still reads as active, even when idle polling is disabled. Idle cost stays zero, +which is what `pollMs={0}` was protecting; within ~5 minutes the server sweep +cancels the row and the spinner stops. Fixed for every template at once rather +than by flipping `pollMs` in slides alone. + +Regression cover: two tests in `RunsTray.spec.tsx` — one asserts an active run +keeps refetching under `pollMs={0}`, one asserts an idle tray does not poll. The +first was verified to fail against the pre-fix code. + +--- + +## 5. "Export to Google Slides" downloaded a PPTX instead of creating a Drive file + +**Report.** Chose Export → Export to Google Slides; got a macOS Save As dialog +for a `.pptx`. Google Workspace was connected earlier in the flow. Tried twice. + +**Status: FIXED (the reporting; the underlying Drive failure still needs the +user's account state).** + +Note this is a **repeat-shaped report**: native Drive creation shipped +`changelog/2026-07-28-export-to-google-slides-now-creates-the-deck-directly-in-you.md` +and connect-from-the-export-menu shipped `2026-07-29-...`. Nine days later a +user reports the pre-July-28 behavior. That is the signature of a silent +fallback, not a missing feature. + +The Drive path is real and is wired up: +`templates/slides/app/pages/DeckEditor.tsx:1073-1082` → +`exportDeckToGoogleSlides` (`app/lib/export-google-slides-client.ts:29-66`) → +`server/routes/api/exports/google-slides.post.ts:22-89`, which uploads with +`mimeType: "application/vnd.google-apps.presentation"` and returns a +`webViewLink`. + +The defect was the reporting. On *any* non-OK response the client downloads the +PPTX and returns `{ url: null, downloaded: true, reason }` +(`export-google-slides-client.ts:59-66`) — and `ExportMenu.tsx:126-131` then +logged the reason to the console and raised a **success** toast, "Downloaded for +Google Slides". The server's real diagnostics never reached the user: 401 +unauthorized, 409 `"No connected Google account."`, or a 502 carrying the actual +Google Drive error message +(`google-slides.post.ts:22-27`, `:48-54`, `:80-87`). + +This is exactly the failure mode `AGENTS.md` names: a coercion that returns a +value the caller cannot distinguish from success, so every layer above it +reports something confidently wrong. + +**Fix** (`templates/slides/app/components/editor/ExportMenu.tsx`): the fallback +now raises a warning toast carrying the server's reason alongside the existing +import hint. A user whose Google account is not actually connected will now be +told so, and the export menu already has a **Connect Google** action +(`ExportMenu.tsx:145-194`). + +--- + +## 6. Title slide: title text overlaps the subtitle + +**Report.** "ARR Data Infrastructure" overlapping "From customer grain to +product-grain growth accounting", consistently in both the app and Google +Slides. + +**Status: Confirmed in one export path, NOT reproduced in the renderer — not +fixed, needs the actual deck.** + +Skeptical read of the two halves of this report: + +- **In-app overlap: not explained by the renderer.** Title slides are ordinary + centered flex, not absolutely positioned + (`app/components/deck/SlideRenderer.tsx:46-49`, `:677`, `:847-873`), and + autofit measures real bounds before scaling + (`SlideRenderer.tsx:247-320`). Normal flow cannot self-overlap. The likely + source is the generated slide HTML itself (absolute positioning or negative + margins the agent emitted), which cannot be confirmed without the deck. +- **Exported overlap: a real, provable bug exists** in the *server* export + action, `templates/slides/actions/export-pptx.ts:335-369`: + `const lineCount = Math.max(1, text.split("\n").length)` counts only explicit + newlines, never soft wraps. A long title that visually wraps to two lines is + exported as a one-line box, `yPos` advances by one line, and the subtitle is + placed on top of the second line. The centered-layout pre-pass has the same + assumption (`:265-284`, `totalHeight += fontSize * 1.3 + marginBottom`). + +**Why it is not fixed here.** The user's Google Slides export does *not* go +through `export-pptx.ts` — it goes through the browser path +(`app/lib/export-pptx-client.ts`), which serializes measured DOM. So patching +the server estimator would not address the reported case and would be a fix +claimed against the wrong path. The server-side wrap bug should be filed +separately. + +**Needed to close this:** the deck id, or the slide HTML for slide 1. + +--- + +## 7. Ask for the intended output format up front + +**Report.** "Could the agent ask for the intended output format (PPTX, GSlides, +PDF) at the start and optimize the build accordingly?" — cites Claude picking +`INDEX/MATCH` for Excel vs `XLOOKUP` for Sheets once told the target. + +**Status: Product decision — reasonable, out of scope for a bug pass.** + +This is an instruction change, not a code change: it belongs in +`templates/slides/.agents/skills/create-deck/` and the export guidance in +`AGENTS.md`, which currently documents the Google Slides path as a PPTX import +workflow. Worth noting that item 6 is the concrete cost of *not* knowing the +target: geometry that survives one renderer and breaks in another. + +--- + +## Summary + +| # | Item | Status | +|---|------|--------| +| 1 | Model default / task-aware selection | Product decision — per-app override exists, unused | +| 2 | Notion Connect no-op | Not reproducible; no-feedback path is the real gap | +| 3 | Reference deck variety / "none" | Confirmed — "none" reachable via Skip, unlabelled | +| 4a | `run_budget_exhausted` | Working as designed | +| 4b | Spinner stuck for an hour | **Fixed** (+ regression tests) | +| 5 | Google Slides export downloaded PPTX | **Fixed** (silent success → real reason) | +| 6 | Title/subtitle overlap | Confirmed in server export only; needs the deck | +| 7 | Ask for output format up front | Product decision | diff --git a/templates/slides/app/components/editor/ExportMenu.tsx b/templates/slides/app/components/editor/ExportMenu.tsx index 89a4a30ad1..6b89d23da9 100644 --- a/templates/slides/app/components/editor/ExportMenu.tsx +++ b/templates/slides/app/components/editor/ExportMenu.tsx @@ -123,11 +123,13 @@ export function ExportMenu({ }); return; } - console.warn("Google Slides upload unavailable:", result.reason); if (target) target.location.href = GOOGLE_SLIDES_IMPORT_URL; setGoogleSlidesImportOpen(true); - toast.success(t("editorExport.googleSlidesDownloaded"), { - description: t("editorExport.googleSlidesImportHint"), + // The deck did not reach Drive. Saying "success" here is why users read + // the .pptx download as the intended result and never learn that their + // Google account is unconnected or that Drive rejected the upload. + toast.warning(t("editorExport.googleSlidesDownloaded"), { + description: `${result.reason} ${t("editorExport.googleSlidesImportHint")}`, }); } catch (err) { googleSlidesImportTarget.current = null; diff --git a/templates/slides/changelog/2026-08-08-google-slides-export-now-tells-you-why-a-deck-fell-back-to-a.md b/templates/slides/changelog/2026-08-08-google-slides-export-now-tells-you-why-a-deck-fell-back-to-a.md new file mode 100644 index 0000000000..c145b242f4 --- /dev/null +++ b/templates/slides/changelog/2026-08-08-google-slides-export-now-tells-you-why-a-deck-fell-back-to-a.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-08-08 +--- + +Google Slides export now tells you why a deck fell back to a .pptx download instead of reporting success. From 711a8f9028f3db547ccaa9e4701d1aeae20737bc Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Sat, 8 Aug 2026 09:53:42 +0000 Subject: [PATCH 02/19] fix: format markdown file with proper emphasis and table alignment --- plans/slides-feedback-2026-08-07.md | 36 ++++++++++++++--------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/plans/slides-feedback-2026-08-07.md b/plans/slides-feedback-2026-08-07.md index 6e328eea97..21eaf345b5 100644 --- a/plans/slides-feedback-2026-08-07.md +++ b/plans/slides-feedback-2026-08-07.md @@ -42,7 +42,7 @@ choice or an explicit Good/Better/Best tier. **Proposed (needs approval — this is UX, and the picker is already dense).** Set a slides app default through the existing `app-model-defaults` store rather -than building new machinery, and attach the "why" to the *existing* selected-row +than building new machinery, and attach the "why" to the _existing_ selected-row affordance instead of adding a visible info bubble. Tradeoff: an app-level default silently overrides the framework default for everyone in the org, so it needs to be visible somewhere in settings. @@ -66,7 +66,7 @@ on Notion produced no visible response. The only silent paths are the early return when the card is already connected or busy (`:204-209`, with the button disabled in that state at `:893-895`). What -the code *cannot* rule out is the failure the screenshot is consistent with: +the code _cannot_ rule out is the failure the screenshot is consistent with: `navigateToMcpOAuthStart` gives no spinner, no toast, and no error, so if the OAuth start URL 4xxs or the navigation is blocked, the click is indistinguishable from a no-op. **That absence of feedback is the defect worth @@ -94,7 +94,7 @@ user already found busy. - The reference-deck select is deck-backed (starred decks, then other decks, then recents, `NewDeckReferenceStep.tsx:298-383`). There is **no explicit "none" item.** -- "None" *is* reachable, just not labelled: **Skip** passes explicit nulls +- "None" _is_ reachable, just not labelled: **Skip** passes explicit nulls (`FirstDeckOnboardingFlow.tsx:252-257` → `startGeneration(promptFiles, { designSystemId: null, referenceDeckId: null })`), and the defaults only kick in when the field is `undefined` @@ -128,7 +128,7 @@ above) and the tray's `progress_runs` row, which the agent opens with budget exhaustion never reaches its `complete` call, so the `progress_runs` row stays `running`. -There *is* a server-side stale sweep — `cancelStaleRunsForOwner` +There _is_ a server-side stale sweep — `cancelStaleRunsForOwner` (`packages/core/src/progress/store.ts:240-273`) cancels rows untouched for 5 minutes — but it only runs inside `listRuns` (`packages/core/src/progress/store.ts:308-339`). Slides mounts the tray with @@ -171,7 +171,7 @@ The Drive path is real and is wired up: `mimeType: "application/vnd.google-apps.presentation"` and returns a `webViewLink`. -The defect was the reporting. On *any* non-OK response the client downloads the +The defect was the reporting. On _any_ non-OK response the client downloads the PPTX and returns `{ url: null, downloaded: true, reason }` (`export-google-slides-client.ts:59-66`) — and `ExportMenu.tsx:126-131` then logged the reason to the console and raised a **success** toast, "Downloaded for @@ -210,7 +210,7 @@ Skeptical read of the two halves of this report: (`SlideRenderer.tsx:247-320`). Normal flow cannot self-overlap. The likely source is the generated slide HTML itself (absolute positioning or negative margins the agent emitted), which cannot be confirmed without the deck. -- **Exported overlap: a real, provable bug exists** in the *server* export +- **Exported overlap: a real, provable bug exists** in the _server_ export action, `templates/slides/actions/export-pptx.ts:335-369`: `const lineCount = Math.max(1, text.split("\n").length)` counts only explicit newlines, never soft wraps. A long title that visually wraps to two lines is @@ -218,7 +218,7 @@ Skeptical read of the two halves of this report: placed on top of the second line. The centered-layout pre-pass has the same assumption (`:265-284`, `totalHeight += fontSize * 1.3 + marginBottom`). -**Why it is not fixed here.** The user's Google Slides export does *not* go +**Why it is not fixed here.** The user's Google Slides export does _not_ go through `export-pptx.ts` — it goes through the browser path (`app/lib/export-pptx-client.ts`), which serializes measured DOM. So patching the server estimator would not address the reported case and would be a fix @@ -240,20 +240,20 @@ PDF) at the start and optimize the build accordingly?" — cites Claude picking This is an instruction change, not a code change: it belongs in `templates/slides/.agents/skills/create-deck/` and the export guidance in `AGENTS.md`, which currently documents the Google Slides path as a PPTX import -workflow. Worth noting that item 6 is the concrete cost of *not* knowing the +workflow. Worth noting that item 6 is the concrete cost of _not_ knowing the target: geometry that survives one renderer and breaks in another. --- ## Summary -| # | Item | Status | -|---|------|--------| -| 1 | Model default / task-aware selection | Product decision — per-app override exists, unused | -| 2 | Notion Connect no-op | Not reproducible; no-feedback path is the real gap | -| 3 | Reference deck variety / "none" | Confirmed — "none" reachable via Skip, unlabelled | -| 4a | `run_budget_exhausted` | Working as designed | -| 4b | Spinner stuck for an hour | **Fixed** (+ regression tests) | -| 5 | Google Slides export downloaded PPTX | **Fixed** (silent success → real reason) | -| 6 | Title/subtitle overlap | Confirmed in server export only; needs the deck | -| 7 | Ask for output format up front | Product decision | +| # | Item | Status | +| --- | ------------------------------------ | -------------------------------------------------- | +| 1 | Model default / task-aware selection | Product decision — per-app override exists, unused | +| 2 | Notion Connect no-op | Not reproducible; no-feedback path is the real gap | +| 3 | Reference deck variety / "none" | Confirmed — "none" reachable via Skip, unlabelled | +| 4a | `run_budget_exhausted` | Working as designed | +| 4b | Spinner stuck for an hour | **Fixed** (+ regression tests) | +| 5 | Google Slides export downloaded PPTX | **Fixed** (silent success → real reason) | +| 6 | Title/subtitle overlap | Confirmed in server export only; needs the deck | +| 7 | Ask for output format up front | Product decision | From 56b6bc5debae5a78838592a139bd227dee7a0283 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Sun, 9 Aug 2026 01:14:40 +0000 Subject: [PATCH 03/19] Increase import action timeout to prevent silent failures on large files --- templates/slides/app/pages/Index.tsx | 67 +++++++++++++++++++--------- 1 file changed, 45 insertions(+), 22 deletions(-) diff --git a/templates/slides/app/pages/Index.tsx b/templates/slides/app/pages/Index.tsx index ee4c10169e..5bc9aabbee 100644 --- a/templates/slides/app/pages/Index.tsx +++ b/templates/slides/app/pages/Index.tsx @@ -71,6 +71,16 @@ import { TAB_ID } from "@/lib/tab-id"; const NEW_DECK_DRAFT_SCOPE = "slides-new-deck"; const PENDING_PROMPT_KEY = "slides:pending-deck-prompt"; +/** + * PDF/PPTX import renders every page (image extraction, per-page fidelity + * parsing) and can run well past the client's default 60s action timeout on + * large or image-heavy files. A timeout here only aborts the *client's wait* + * — the server keeps importing and the deck still ends up with slides — so + * the old default made the editor silently fail to open on a deck that had + * (or was about to have) real content. + */ +const IMPORT_ACTION_TIMEOUT_MS = 5 * 60 * 1000; + /** Router-state payload for recovering the new-deck prompt after a failed * generation kickoff forces a navigate away from and back to this route. */ interface DeckGenerationRetryState { @@ -781,9 +791,11 @@ export default function Index() { } if (selection.kind === "google-slides") { - const imported = (await callAction("import-google-slides-reference", { - presentationUrl: selection.url, - })) as { id?: unknown }; + const imported = (await callAction( + "import-google-slides-reference", + { presentationUrl: selection.url }, + { timeoutMs: IMPORT_ACTION_TIMEOUT_MS }, + )) as { id?: unknown }; if (typeof imported.id !== "string" || !imported.id) { throw new Error( "The Google Slides presentation did not create a deck.", @@ -799,10 +811,11 @@ export default function Index() { if (!file) throw new Error("The selected file could not be uploaded."); if (selection.kind === "pptx") { - const imported = (await callAction("import-pptx", { - filePath: file.path, - designSystemId: initialDesignSystemId, - })) as { id?: unknown }; + const imported = (await callAction( + "import-pptx", + { filePath: file.path, designSystemId: initialDesignSystemId }, + { timeoutMs: IMPORT_ACTION_TIMEOUT_MS }, + )) as { id?: unknown }; if (typeof imported.id !== "string" || !imported.id) { throw new Error("The PowerPoint presentation did not create a deck."); } @@ -832,12 +845,16 @@ export default function Index() { } try { - const imported = (await callAction("import-file", { - filePath: file.path, - format: "pdf", - deckId: deck.id, - importIntoDeck: true, - })) as { imported?: unknown; deckId?: unknown }; + const imported = (await callAction( + "import-file", + { + filePath: file.path, + format: "pdf", + deckId: deck.id, + importIntoDeck: true, + }, + { timeoutMs: IMPORT_ACTION_TIMEOUT_MS }, + )) as { imported?: unknown; deckId?: unknown }; if (imported.imported !== true || imported.deckId !== deck.id) { throw new Error("The PDF could not be imported into the new deck."); } @@ -903,9 +920,11 @@ export default function Index() { referenceDeckId: null, }; if (pptxReference) { - const imported = (await callAction("import-pptx", { - filePath: pptxReference.path, - })) as { id?: unknown }; + const imported = (await callAction( + "import-pptx", + { filePath: pptxReference.path }, + { timeoutMs: IMPORT_ACTION_TIMEOUT_MS }, + )) as { id?: unknown }; if (typeof imported.id !== "string" || !imported.id) { throw new Error("The imported presentation did not create a deck."); } @@ -932,12 +951,16 @@ export default function Index() { ); } try { - const imported = (await callAction("import-file", { - filePath: pdfReference.path, - format: "pdf", - deckId: referenceDeck.id, - importIntoDeck: true, - })) as { imported?: unknown; deckId?: unknown }; + const imported = (await callAction( + "import-file", + { + filePath: pdfReference.path, + format: "pdf", + deckId: referenceDeck.id, + importIntoDeck: true, + }, + { timeoutMs: IMPORT_ACTION_TIMEOUT_MS }, + )) as { imported?: unknown; deckId?: unknown }; if ( imported.imported !== true || imported.deckId !== referenceDeck.id From a03d5f0c25ba0daa165313b46b26866741338ce4 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Sun, 9 Aug 2026 01:45:13 +0000 Subject: [PATCH 04/19] Fix non-16:9 slide imports rendering with distorted positioning --- ...size-powerpoint-imports-rendering-with-.md | 6 ++ .../handlers/import/html-converter.test.ts | 63 +++++++++++++ .../server/handlers/import/html-converter.ts | 88 +++++++++++++++---- 3 files changed, 141 insertions(+), 16 deletions(-) create mode 100644 templates/slides/changelog/2026-08-09-fixed-pdf-and-custom-size-powerpoint-imports-rendering-with-.md create mode 100644 templates/slides/server/handlers/import/html-converter.test.ts diff --git a/templates/slides/changelog/2026-08-09-fixed-pdf-and-custom-size-powerpoint-imports-rendering-with-.md b/templates/slides/changelog/2026-08-09-fixed-pdf-and-custom-size-powerpoint-imports-rendering-with-.md new file mode 100644 index 0000000000..acd1c01065 --- /dev/null +++ b/templates/slides/changelog/2026-08-09-fixed-pdf-and-custom-size-powerpoint-imports-rendering-with-.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-08-09 +--- + +Fixed PDF and custom-size PowerPoint imports rendering with distorted, mispositioned images and text on non-16:9 pages. diff --git a/templates/slides/server/handlers/import/html-converter.test.ts b/templates/slides/server/handlers/import/html-converter.test.ts new file mode 100644 index 0000000000..7c1303d969 --- /dev/null +++ b/templates/slides/server/handlers/import/html-converter.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { convertToSlideHtml } from "./html-converter.js"; +import type { ParsedElement, ParsedSlide } from "./pptx-parser.js"; + +/** + * Real numbers from a portrait PDF page (10287000 x 12852400 EMU, ratio + * 0.8) that reproduced the reported bug: a square background photo + * rendered squashed into the top ~50% of the slide, and the title text sat + * in the middle of the canvas instead of near the bottom. + */ +function portraitSlide(): ParsedSlide { + const widthEmu = 10287000; + const heightEmu = 12852400; + const image: ParsedElement = { + id: "img-1", + kind: "image", + x: -1294848, + y: -89725, + width: 12948475, + height: 12948475, + }; + return { + texts: [], + images: [], + elements: [image], + widthEmu, + heightEmu, + }; +} + +function styleAttr(html: string, dataAttr: string): string { + const marker = `data-pptx-element-kind="${dataAttr}"`; + const start = html.indexOf(marker); + const styleStart = html.indexOf('style="', start) + 'style="'.length; + const styleEnd = html.indexOf('"', styleStart); + return html.slice(styleStart, styleEnd); +} + +function pxValue(style: string, prop: string): number { + const match = style.match(new RegExp(`${prop}:\\s*([\\d.]+)px`)); + if (!match) throw new Error(`missing ${prop} in ${style}`); + return Number(match[1]); +} + +describe("convertToSlideHtml fidelity positioning", () => { + it("scales a portrait/non-16:9 slide's elements against its own aspect ratio, not a fixed 16:9 box", () => { + const html = convertToSlideHtml(portraitSlide()); + const imageStyle = styleAttr(html, "image"); + + const width = pxValue(imageStyle, "width"); + const height = pxValue(imageStyle, "height"); + + // The source image is square in EMU (width === height): isotropic + // scaling must keep it square in the rendered px box too. + expect(width).toBeCloseTo(height, -1); + + // The nearest aspect-ratio preset for a 0.8 ratio slide is "4:5" + // (864x1080) — the image should span (near) the full 1080px canvas + // height, not the old fixed 540px reference that squashed it in half. + expect(height).toBeGreaterThan(1000); + }); +}); diff --git a/templates/slides/server/handlers/import/html-converter.ts b/templates/slides/server/handlers/import/html-converter.ts index 4f844899c6..933154a4bd 100644 --- a/templates/slides/server/handlers/import/html-converter.ts +++ b/templates/slides/server/handlers/import/html-converter.ts @@ -1,3 +1,5 @@ +import { ASPECT_RATIOS } from "@shared/aspect-ratios"; + import type { ParsedElement, ParsedParagraph, @@ -169,6 +171,34 @@ const CSS_PX_PER_POINT = 96 / 72; const DEFAULT_PPTX_BACKGROUND = "#000000"; // guard:allow-raw-color - preserve PPTX black when no background is declared const DEFAULT_PPTX_FOREGROUND = "#ffffff"; // guard:allow-raw-color - preserve PPTX white when no run color is declared +/** + * The absolute px box `toSlidePxX`/`toSlidePxY` scale positions and sizes + * against. It must match the aspect-ratio preset the deck actually renders + * into (`ASPECT_RATIOS`, chosen by the import actions' own + * `nearestAspectRatio`) rather than a fixed 16:9 box: a PDF page or a custom + * PPTX slide size is routinely portrait or square, and scaling its elements + * against a 960x540 reference while the deck itself renders in an 864x1080 + * (or other) box stretches every element by the ratio between the two + * boxes, most visibly squashing everything into the top fraction of a + * taller-than-540 canvas. + */ +function referenceBoxForSlide( + widthEmu: number, + heightEmu: number, +): { width: number; height: number } { + const target = widthEmu / heightEmu; + let best: { width: number; height: number } = ASPECT_RATIOS["16:9"]; + let bestDiff = Infinity; + for (const preset of Object.values(ASPECT_RATIOS)) { + const diff = Math.abs(preset.width / preset.height - target); + if (diff < bestDiff) { + bestDiff = diff; + best = preset; + } + } + return { width: best.width, height: best.height }; +} + function buildFidelitySlide( slide: ParsedSlide, imageUrls: string | Record | undefined, @@ -176,9 +206,10 @@ function buildFidelitySlide( ): string { const widthEmu = slide.widthEmu || DEFAULT_SLIDE_WIDTH_EMU; const heightEmu = slide.heightEmu || DEFAULT_SLIDE_HEIGHT_EMU; + const refBox = referenceBoxForSlide(widthEmu, heightEmu); const background = slide.backgroundColor ?? DEFAULT_PPTX_BACKGROUND; const gridStyle = slide.backgroundGrid - ? `background-image:linear-gradient(to right, ${esc(slide.backgroundGrid.color)} 0 ${Math.max(0.5, toSlidePxX(slide.backgroundGrid.lineWidthEmu, widthEmu))}px, transparent ${Math.max(0.5, toSlidePxX(slide.backgroundGrid.lineWidthEmu, widthEmu))}px),linear-gradient(to bottom, ${esc(slide.backgroundGrid.color)} 0 ${Math.max(0.5, toSlidePxY(slide.backgroundGrid.lineWidthEmu, heightEmu))}px, transparent ${Math.max(0.5, toSlidePxY(slide.backgroundGrid.lineWidthEmu, heightEmu))}px);background-size:${toSlidePxX(slide.backgroundGrid.stepXEmu, widthEmu)}px ${toSlidePxY(slide.backgroundGrid.stepYEmu, heightEmu)}px;background-position:${toSlidePxX(slide.backgroundGrid.offsetXEmu, widthEmu)}px ${toSlidePxY(slide.backgroundGrid.offsetYEmu, heightEmu)}px;background-repeat:repeat;` + ? `background-image:linear-gradient(to right, ${esc(slide.backgroundGrid.color)} 0 ${Math.max(0.5, toSlidePxX(slide.backgroundGrid.lineWidthEmu, widthEmu, refBox.width))}px, transparent ${Math.max(0.5, toSlidePxX(slide.backgroundGrid.lineWidthEmu, widthEmu, refBox.width))}px),linear-gradient(to bottom, ${esc(slide.backgroundGrid.color)} 0 ${Math.max(0.5, toSlidePxY(slide.backgroundGrid.lineWidthEmu, heightEmu, refBox.height))}px, transparent ${Math.max(0.5, toSlidePxY(slide.backgroundGrid.lineWidthEmu, heightEmu, refBox.height))}px);background-size:${toSlidePxX(slide.backgroundGrid.stepXEmu, widthEmu, refBox.width)}px ${toSlidePxY(slide.backgroundGrid.stepYEmu, heightEmu, refBox.height)}px;background-position:${toSlidePxX(slide.backgroundGrid.offsetXEmu, widthEmu, refBox.width)}px ${toSlidePxY(slide.backgroundGrid.offsetYEmu, heightEmu, refBox.height)}px;background-repeat:repeat;` : ""; const elements = slide.elements ?? []; const html = elements @@ -188,6 +219,7 @@ function buildFidelitySlide( index, widthEmu, heightEmu, + refBox, imageUrls, themeFont, ), @@ -203,10 +235,11 @@ function buildFidelityElement( index: number, widthEmu: number, heightEmu: number, + refBox: { width: number; height: number }, imageUrls: string | Record | undefined, themeFont: string | undefined, ): string { - const position = `position: absolute; left: ${toSlidePxX(element.x, widthEmu)}px; top: ${toSlidePxY(element.y, heightEmu)}px; width: ${toSlidePxX(element.width, widthEmu)}px; height: ${toSlidePxY(element.height, heightEmu)}px; z-index: ${index}; box-sizing: border-box;`; + const position = `position: absolute; left: ${toSlidePxX(element.x, widthEmu, refBox.width)}px; top: ${toSlidePxY(element.y, heightEmu, refBox.height)}px; width: ${toSlidePxX(element.width, widthEmu, refBox.width)}px; height: ${toSlidePxY(element.height, heightEmu, refBox.height)}px; z-index: ${index}; box-sizing: border-box;`; const rotation = element.rotation ? ` transform: rotate(${element.rotation}deg); transform-origin: center center;` : ""; @@ -218,12 +251,18 @@ function buildFidelityElement( return `
${url ? `` : `
Imported image: ${esc(element.image?.name ?? "image")}
`}
`; } - const decoration = shapeDecoration(element, widthEmu); + const decoration = shapeDecoration(element, widthEmu, refBox.width); if (element.kind === "shape") { return `
`; } - const textStyle = textBoxStyle(element, widthEmu, heightEmu, themeFont); + const textStyle = textBoxStyle( + element, + widthEmu, + heightEmu, + refBox, + themeFont, + ); const defaultFontWeight = element.placeholderType === "title" ? 700 : 400; const paragraphs = (element.paragraphs ?? []) .map((paragraph, paragraphIndex) => @@ -231,6 +270,7 @@ function buildFidelityElement( paragraph, paragraphIndex, widthEmu, + refBox.width, themeFont, defaultFontWeight, ), @@ -239,12 +279,20 @@ function buildFidelityElement( return `
${paragraphs}
`; } -function toSlidePxX(valueEmu: number, slideWidthEmu: number): number { - return Math.round((valueEmu / slideWidthEmu) * 960 * 1000) / 1000; +function toSlidePxX( + valueEmu: number, + slideWidthEmu: number, + refWidthPx: number, +): number { + return Math.round((valueEmu / slideWidthEmu) * refWidthPx * 1000) / 1000; } -function toSlidePxY(valueEmu: number, slideHeightEmu: number): number { - return Math.round((valueEmu / slideHeightEmu) * 540 * 1000) / 1000; +function toSlidePxY( + valueEmu: number, + slideHeightEmu: number, + refHeightPx: number, +): number { + return Math.round((valueEmu / slideHeightEmu) * refHeightPx * 1000) / 1000; } function imageUrlForElement( @@ -263,10 +311,14 @@ function imageRenderStyle(element: ParsedElement): string { return `display:block;position:absolute;left:${(-crop.left / visibleWidth) * 100}%;top:${(-crop.top / visibleHeight) * 100}%;width:${(1 / visibleWidth) * 100}%;height:${(1 / visibleHeight) * 100}%;object-fit:fill;`; } -function shapeDecoration(element: ParsedElement, widthEmu: number): string { +function shapeDecoration( + element: ParsedElement, + widthEmu: number, + refWidthPx: number, +): string { const fill = element.fill ? `background: ${esc(element.fill)};` : ""; const line = element.lineColor - ? `border: ${Math.max(1, toSlidePxX(element.lineWidth ?? 12700, widthEmu))}px solid ${esc(element.lineColor)};` + ? `border: ${Math.max(1, toSlidePxX(element.lineWidth ?? 12700, widthEmu, refWidthPx))}px solid ${esc(element.lineColor)};` : ""; const radius = element.shapeType === "roundRect" ? "border-radius: 6px;" : ""; return `${fill}${line}${radius}`; @@ -276,13 +328,16 @@ function textBoxStyle( element: ParsedElement, widthEmu: number, heightEmu: number, + refBox: { width: number; height: number }, themeFont: string | undefined, ): string { const padding = element.padding; - const left = padding ? toSlidePxX(padding.left, widthEmu) : 0; - const right = padding ? toSlidePxX(padding.right, widthEmu) : 0; - const top = padding ? toSlidePxY(padding.top, heightEmu) : 0; - const bottom = padding ? toSlidePxY(padding.bottom, heightEmu) : 0; + const left = padding ? toSlidePxX(padding.left, widthEmu, refBox.width) : 0; + const right = padding ? toSlidePxX(padding.right, widthEmu, refBox.width) : 0; + const top = padding ? toSlidePxY(padding.top, heightEmu, refBox.height) : 0; + const bottom = padding + ? toSlidePxY(padding.bottom, heightEmu, refBox.height) + : 0; const align = element.paragraphs?.[0]?.alignment ?? "left"; const vertical = element.verticalAlign === "middle" @@ -297,6 +352,7 @@ function buildFidelityParagraph( paragraph: ParsedParagraph, paragraphIndex: number, widthEmu: number, + refWidthPx: number, themeFont: string | undefined, defaultFontWeight: number, ): string { @@ -307,10 +363,10 @@ function buildFidelityParagraph( ? `` : ""; const marginLeft = paragraph.marginLeftEmu - ? toSlidePxX(paragraph.marginLeftEmu, widthEmu) + ? toSlidePxX(paragraph.marginLeftEmu, widthEmu, refWidthPx) : 0; const indent = paragraph.indentEmu - ? toSlidePxX(paragraph.indentEmu, widthEmu) + ? toSlidePxX(paragraph.indentEmu, widthEmu, refWidthPx) : 0; const spacingBefore = paragraph.spaceBeforePt ?? 0; const spacingAfter = paragraph.spaceAfterPt ?? 0; From c03939fc37d30ee46be162f6939e11a611cf6e29 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Sun, 9 Aug 2026 02:10:30 +0000 Subject: [PATCH 05/19] Fix imported slide text sizing and agent patch caller detection --- templates/slides/actions/patch-deck.test.ts | 15 ++++++ templates/slides/actions/patch-deck.ts | 20 +++++++- ...n-imported-deck-no-longer-fails-to-save.md | 6 +++ ...no-longer-renders-too-large-for-its-box.md | 6 +++ .../handlers/import/html-converter.test.ts | 39 +++++++++++++++ .../server/handlers/import/html-converter.ts | 49 ++++++++++++++++--- 6 files changed, 127 insertions(+), 8 deletions(-) create mode 100644 templates/slides/changelog/2026-08-09-editing-a-slide-in-an-imported-deck-no-longer-fails-to-save.md create mode 100644 templates/slides/changelog/2026-08-09-imported-slide-text-no-longer-renders-too-large-for-its-box.md diff --git a/templates/slides/actions/patch-deck.test.ts b/templates/slides/actions/patch-deck.test.ts index c3bec6b6ba..ce9c4ad0b6 100644 --- a/templates/slides/actions/patch-deck.test.ts +++ b/templates/slides/actions/patch-deck.test.ts @@ -4,6 +4,7 @@ import { buildSourceImportMetadata } from "../server/lib/source-import.js"; import { applyOperation, assertSourceImportOperationsPreserved, + isAgentPatchCaller, resolveDeckColumnUpdates, withDeckLock, type Operation, @@ -278,6 +279,20 @@ describe("source-imported deck structure", () => { }); }); +describe("isAgentPatchCaller", () => { + it("treats tool, mcp, and a2a callers as agent callers", () => { + expect(isAgentPatchCaller("tool")).toBe(true); + expect(isAgentPatchCaller("mcp")).toBe(true); + expect(isAgentPatchCaller("a2a")).toBe(true); + }); + + it("treats the browser editor and unset callers as non-agent", () => { + expect(isAgentPatchCaller("frontend")).toBe(false); + expect(isAgentPatchCaller("http")).toBe(false); + expect(isAgentPatchCaller(undefined)).toBe(false); + }); +}); + describe("patch-deck agent schema", () => { it("advertises only bounded deck and slide patch operations", () => { const parameters = patchDeckAction.tool.parameters as any; diff --git a/templates/slides/actions/patch-deck.ts b/templates/slides/actions/patch-deck.ts index f34811f607..201dffe912 100644 --- a/templates/slides/actions/patch-deck.ts +++ b/templates/slides/actions/patch-deck.ts @@ -370,6 +370,18 @@ export function resolveDeckColumnUpdates( }; } +/** + * The source-preservation guards (`assertSourceImportOperationsPreserved`, + * `assertSourceSlidePreserved`) exist for one failure mode: an agent asked to + * "make it prettier" silently dropping the original PDF/PPTX artwork or + * factual copy. A human editing their own imported deck in the browser isn't + * that failure mode, and the browser editor has no way to pass + * `preserveSource` — so these guards must only run for agent callers. + */ +export function isAgentPatchCaller(caller: string | undefined): boolean { + return caller === "tool" || caller === "mcp" || caller === "a2a"; +} + // --------------------------------------------------------------------------- // Action definition // --------------------------------------------------------------------------- @@ -402,8 +414,9 @@ export default defineAction({ ), }), agentInputSchema: AgentPatchDeckInputSchema, - run: async ({ deckId, operations, creativeContext }) => { + run: async ({ deckId, operations, creativeContext }, ctx) => { await assertAccess("deck", deckId, "editor"); + const isAgentCaller = isAgentPatchCaller(ctx?.caller); return withDeckLock(deckId, async () => { const db = getDb(); @@ -435,9 +448,12 @@ export default defineAction({ } const sourceImport = sourceImportForDeck(deck.sourceImport); - assertSourceImportOperationsPreserved(sourceImport, operations); + if (isAgentCaller) { + assertSourceImportOperationsPreserved(sourceImport, operations); + } for (const op of operations) { if ( + !isAgentCaller || op.op !== "patch-slide" || (op.fields.content === undefined && op.fields.notes === undefined) ) { diff --git a/templates/slides/changelog/2026-08-09-editing-a-slide-in-an-imported-deck-no-longer-fails-to-save.md b/templates/slides/changelog/2026-08-09-editing-a-slide-in-an-imported-deck-no-longer-fails-to-save.md new file mode 100644 index 0000000000..0f5c2d1323 --- /dev/null +++ b/templates/slides/changelog/2026-08-09-editing-a-slide-in-an-imported-deck-no-longer-fails-to-save.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-08-09 +--- + +Fixed direct edits to a PDF/PPTX-imported deck's slide text sometimes failing to save with a generic "Internal server error" — the source-preservation guard meant to stop an agent from silently dropping the original artwork or copy was also blocking ordinary human edits, which have no way to opt out of it. diff --git a/templates/slides/changelog/2026-08-09-imported-slide-text-no-longer-renders-too-large-for-its-box.md b/templates/slides/changelog/2026-08-09-imported-slide-text-no-longer-renders-too-large-for-its-box.md new file mode 100644 index 0000000000..8edda8a7fb --- /dev/null +++ b/templates/slides/changelog/2026-08-09-imported-slide-text-no-longer-renders-too-large-for-its-box.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-08-09 +--- + +Fixed imported PDF/PowerPoint slide text rendering larger than its original box (often overlapping neighboring text) whenever the source page's physical size didn't match the deck canvas's assumed size — font sizes now scale by the same factor as element positions instead of a fixed point-to-pixel conversion. diff --git a/templates/slides/server/handlers/import/html-converter.test.ts b/templates/slides/server/handlers/import/html-converter.test.ts index 7c1303d969..48ddcc9572 100644 --- a/templates/slides/server/handlers/import/html-converter.test.ts +++ b/templates/slides/server/handlers/import/html-converter.test.ts @@ -29,6 +29,34 @@ function portraitSlide(): ParsedSlide { }; } +/** + * Real numbers for a standard 13.33in x 7.5in widescreen PPTX slide + * (12192000 x 6858000 EMU, exactly 16:9) — the common case, not an edge + * case. `toSlidePxX`/`toSlidePxY` scale this down to the 960x540 reference + * box; font sizes must scale by the same factor instead of a fixed pt->px + * conversion, or every run renders larger than its box expects. + */ +function widescreenTextSlide(fontSizePt: number): ParsedSlide { + const widthEmu = 12192000; + const heightEmu = 6858000; + const text: ParsedElement = { + id: "text-1", + kind: "text", + x: 0, + y: 0, + width: widthEmu, + height: heightEmu, + paragraphs: [{ runs: [{ content: "Hi", fontSize: fontSizePt }] }], + }; + return { + texts: [], + images: [], + elements: [text], + widthEmu, + heightEmu, + }; +} + function styleAttr(html: string, dataAttr: string): string { const marker = `data-pptx-element-kind="${dataAttr}"`; const start = html.indexOf(marker); @@ -61,3 +89,14 @@ describe("convertToSlideHtml fidelity positioning", () => { expect(height).toBeGreaterThan(1000); }); }); + +describe("convertToSlideHtml fidelity text sizing", () => { + it("scales run font size by the same EMU-relative factor as element positions", () => { + const html = convertToSlideHtml(widescreenTextSlide(24)); + const match = html.match(/font-size:([\d.]+)px/); + if (!match) throw new Error("missing font-size in rendered run"); + // 24pt -> 304800 EMU -> * (960 / 12192000) = 24px, not the fixed + // 24 * 96/72 = 32px a source-size-blind pt->px conversion would give. + expect(Number(match[1])).toBeCloseTo(24, 0); + }); +}); diff --git a/templates/slides/server/handlers/import/html-converter.ts b/templates/slides/server/handlers/import/html-converter.ts index 933154a4bd..f54b81dfba 100644 --- a/templates/slides/server/handlers/import/html-converter.ts +++ b/templates/slides/server/handlers/import/html-converter.ts @@ -167,7 +167,6 @@ export function convertToSlideHtml( const DEFAULT_SLIDE_WIDTH_EMU = 9144000; const DEFAULT_SLIDE_HEIGHT_EMU = 5143500; -const CSS_PX_PER_POINT = 96 / 72; const DEFAULT_PPTX_BACKGROUND = "#000000"; // guard:allow-raw-color - preserve PPTX black when no background is declared const DEFAULT_PPTX_FOREGROUND = "#ffffff"; // guard:allow-raw-color - preserve PPTX white when no run color is declared @@ -295,6 +294,27 @@ function toSlidePxY( return Math.round((valueEmu / slideHeightEmu) * refHeightPx * 1000) / 1000; } +const EMU_PER_POINT = 12700; + +/** + * A run's font size (and paragraph spacing) is stored in points, a physical + * unit independent of the source slide's own canvas size — unlike + * position/size EMUs, a fixed `pt * 96/72` conversion doesn't know how far + * `toSlidePxX`/`toSlidePxY` scaled that canvas down (or up) to fit the + * deck's aspect-ratio box. Converting the point value to EMU first and + * running it through the same `toSlidePxX` scale keeps text sized + * proportionally to its box on every source slide size, not just the one + * physical size (10in wide) that happens to make the fixed conversion agree + * with the 16:9 preset's box. + */ +function ptToSlidePx( + valuePt: number, + widthEmu: number, + refWidthPx: number, +): number { + return toSlidePxX(valuePt * EMU_PER_POINT, widthEmu, refWidthPx); +} + function imageUrlForElement( element: ParsedElement, imageUrls: string | Record | undefined, @@ -357,10 +377,15 @@ function buildFidelityParagraph( defaultFontWeight: number, ): string { const firstRun = paragraph.runs[0]; - const fontSize = (firstRun?.fontSize ?? 18) * CSS_PX_PER_POINT; + const fontSize = ptToSlidePx(firstRun?.fontSize ?? 18, widthEmu, refWidthPx); const lineHeight = paragraph.lineSpacing ?? 1.2; + const bulletFontSize = ptToSlidePx( + paragraph.bulletSize ?? firstRun?.fontSize ?? 18, + widthEmu, + refWidthPx, + ); const bullet = paragraph.bulletChar - ? `` + ? `` : ""; const marginLeft = paragraph.marginLeftEmu ? toSlidePxX(paragraph.marginLeftEmu, widthEmu, refWidthPx) @@ -371,19 +396,31 @@ function buildFidelityParagraph( const spacingBefore = paragraph.spaceBeforePt ?? 0; const spacingAfter = paragraph.spaceAfterPt ?? 0; const bulletMargin = paragraph.bulletChar ? `margin-left:${indent}px;` : ""; + const marginBefore = ptToSlidePx(spacingBefore, widthEmu, refWidthPx); + const marginAfter = ptToSlidePx(spacingAfter, widthEmu, refWidthPx); const text = paragraph.runs - .map((run) => formatFidelityRun(run, themeFont, defaultFontWeight)) + .map((run) => + formatFidelityRun( + run, + widthEmu, + refWidthPx, + themeFont, + defaultFontWeight, + ), + ) .join(""); - return `

${bullet.replace("display:inline-block;", `display:inline-block;${bulletMargin}`)}${text}

`; + return `

${bullet.replace("display:inline-block;", `display:inline-block;${bulletMargin}`)}${text}

`; } function formatFidelityRun( run: ParsedTextRun, + widthEmu: number, + refWidthPx: number, themeFont: string | undefined, defaultFontWeight = 400, ): string { const styles = [ - `font-size:${(run.fontSize ?? 18) * CSS_PX_PER_POINT}px`, + `font-size:${ptToSlidePx(run.fontSize ?? 18, widthEmu, refWidthPx)}px`, `font-family:${cssFontFamily(run.fontFamily ?? themeFont)}`, `color:${esc(run.color ?? DEFAULT_PPTX_FOREGROUND)}`, `font-weight:${run.bold ? 700 : fontWeightForFamily(run.fontFamily, defaultFontWeight)}`, From efd5df7e901a26fb96328144fc5a74cfa17877ce Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Sun, 9 Aug 2026 03:53:22 +0000 Subject: [PATCH 06/19] Fix PDF import text losing spaces at style boundaries --- ...-no-longer-lose-the-space-between-color.md | 6 ++++ .../import/pdf-fidelity-parser.spec.ts | 34 +++++++++++++++++++ .../handlers/import/pdf-fidelity-parser.ts | 13 +++++-- 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 templates/slides/changelog/2026-08-09-imported-pdf-headings-no-longer-lose-the-space-between-color.md diff --git a/templates/slides/changelog/2026-08-09-imported-pdf-headings-no-longer-lose-the-space-between-color.md b/templates/slides/changelog/2026-08-09-imported-pdf-headings-no-longer-lose-the-space-between-color.md new file mode 100644 index 0000000000..5f4409318f --- /dev/null +++ b/templates/slides/changelog/2026-08-09-imported-pdf-headings-no-longer-lose-the-space-between-color.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-08-09 +--- + +Fixed imported PDF text losing the space between words when a line changes color or weight mid-sentence, which ran headings like "7 Air purifying house plants" together into "7 Airpurifying". diff --git a/templates/slides/server/handlers/import/pdf-fidelity-parser.spec.ts b/templates/slides/server/handlers/import/pdf-fidelity-parser.spec.ts index 1a9dbb20a1..082eb758a2 100644 --- a/templates/slides/server/handlers/import/pdf-fidelity-parser.spec.ts +++ b/templates/slides/server/handlers/import/pdf-fidelity-parser.spec.ts @@ -193,6 +193,40 @@ describe("mergeLineRuns", () => { ]); expect(runs).toHaveLength(2); }); + + it("keeps the word gap across a style change so the words don't jam together", () => { + const runs = mergeLineRuns([ + box({ + text: "7 Air", + left: 0, + right: 60, + fontSize: 40, + color: "#ffffff", + }), + box({ + text: "purifying", + left: 80, + right: 200, + fontSize: 40, + color: "#18b6f6", + }), + ]); + expect(runs.map((r) => r.text).join("")).toBe("7 Air purifying"); + }); + + it("does not double a space that either side already carries", () => { + const runs = mergeLineRuns([ + box({ text: "Nike NYC: ", left: 0, right: 60, fontSize: 40 }), + box({ + text: "Event Details", + left: 80, + right: 200, + fontSize: 40, + color: "#18b6f6", + }), + ]); + expect(runs.map((r) => r.text).join("")).toBe("Nike NYC: Event Details"); + }); }); describe("groupIntoStyledLines", () => { diff --git a/templates/slides/server/handlers/import/pdf-fidelity-parser.ts b/templates/slides/server/handlers/import/pdf-fidelity-parser.ts index 4c8c6b7714..cba0457630 100644 --- a/templates/slides/server/handlers/import/pdf-fidelity-parser.ts +++ b/templates/slides/server/handlers/import/pdf-fidelity-parser.ts @@ -380,14 +380,23 @@ export function mergeLineRuns(items: TextRunBox[]): TextRunBox[] { prev.italic === item.italic && prev.underline === item.underline && prev.href === item.href; + const needsSpace = + prev !== undefined && item.left - prev.right > item.fontSize * 0.25; if (prev && sameStyle) { - const needsSpace = item.left - prev.right > item.fontSize * 0.25; prev.text += (needsSpace ? " " : "") + item.text; prev.right = Math.max(prev.right, item.right); prev.top = Math.min(prev.top, item.top); prev.bottom = Math.max(prev.bottom, item.bottom); } else { - runs.push({ ...item }); + // A word-sized gap has to survive a style change too. Only the + // same-style branch used to re-add it, so a heading whose colour + // changed mid-line ("7 Air " + "purifying") lost the space at the + // boundary and rendered as one jammed-together word. + const separator = + needsSpace && !/\s$/.test(prev?.text ?? "") && !/^\s/.test(item.text) + ? " " + : ""; + runs.push({ ...item, text: separator + item.text }); } } return runs; From ec3d6a265ff8692d85934950db336990ee842248 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Sun, 9 Aug 2026 04:13:08 +0000 Subject: [PATCH 07/19] Fix deck editor dimming overlay persisting on window resize --- templates/slides/app/pages/DeckEditor.tsx | 12 ++++++++++++ ...-no-longer-stays-dimmed-after-the-window-narro.md | 6 ++++++ 2 files changed, 18 insertions(+) create mode 100644 templates/slides/changelog/2026-08-09-the-deck-editor-no-longer-stays-dimmed-after-the-window-narro.md diff --git a/templates/slides/app/pages/DeckEditor.tsx b/templates/slides/app/pages/DeckEditor.tsx index dcf5503bbc..b7704f2372 100644 --- a/templates/slides/app/pages/DeckEditor.tsx +++ b/templates/slides/app/pages/DeckEditor.tsx @@ -342,6 +342,18 @@ export default function DeckEditor() { if (!generating) setAddSlideGenerating(false); }, [generating]); + // Below `md` the rail is a drawer behind a full-viewport dimming scrim; at + // `md` and up it's docked with no scrim. `sidebarOpen` is seeded from the + // width at mount only, so a window that starts wide and is then narrowed + // (or an editor opened in a resizable preview pane) keeps `sidebarOpen` + // true while the scrim stops being `md:hidden` — dimming the whole editor + // with no way to dismiss it. + useEffect(() => { + const onResize = () => setSidebarOpen(window.innerWidth >= 768); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); + const previousSlideCountRef = useRef(slideCount); useEffect(() => { if (previousSlideCountRef.current === slideCount) return; diff --git a/templates/slides/changelog/2026-08-09-the-deck-editor-no-longer-stays-dimmed-after-the-window-narro.md b/templates/slides/changelog/2026-08-09-the-deck-editor-no-longer-stays-dimmed-after-the-window-narro.md new file mode 100644 index 0000000000..a4be982cca --- /dev/null +++ b/templates/slides/changelog/2026-08-09-the-deck-editor-no-longer-stays-dimmed-after-the-window-narro.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-08-09 +--- + +Fixed the deck editor staying covered by the mobile slide-rail dimming overlay after the window was narrowed, which washed the whole editor dark with no way to dismiss it. From bbcbb0111dd25cf55ae97a27e8fa6b4a01ee63a7 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Sun, 9 Aug 2026 04:37:11 +0000 Subject: [PATCH 08/19] Fix slide content jumping out of view during text editing --- templates/slides/app/global.css | 11 +++++++++++ ...-no-longer-jumps-out-of-view-when-you-edit-text.md | 6 ++++++ 2 files changed, 17 insertions(+) create mode 100644 templates/slides/changelog/2026-08-09-slide-content-no-longer-jumps-out-of-view-when-you-edit-text.md diff --git a/templates/slides/app/global.css b/templates/slides/app/global.css index d538b64741..9412d655a1 100644 --- a/templates/slides/app/global.css +++ b/templates/slides/app/global.css @@ -258,6 +258,17 @@ button[title="Open agent sidebar"] { font-family: "Poppins", sans-serif; } +/* A slide is a fixed stage: it must clip, but it must never become a scroll + container. Imported slide roots carry inline `overflow: hidden`, which the + browser can still scroll programmatically -- focusing a text box that + extends past the slide edge makes it "reveal" the caret by scrolling the + stage, shifting the top of the slide out of view until the edit ends. + `clip` produces the same visual clipping with no scrollport, so there is + nothing to scroll. Overrides the imported inline style by necessity. */ +.slide-content .fmd-slide { + overflow: clip !important; +} + /* Per-element inline text editing — highlight the text leaf that is currently being edited so the user can see which block is active. */ .slide-content [data-editing-block="true"] { diff --git a/templates/slides/changelog/2026-08-09-slide-content-no-longer-jumps-out-of-view-when-you-edit-text.md b/templates/slides/changelog/2026-08-09-slide-content-no-longer-jumps-out-of-view-when-you-edit-text.md new file mode 100644 index 0000000000..08e7ced8df --- /dev/null +++ b/templates/slides/changelog/2026-08-09-slide-content-no-longer-jumps-out-of-view-when-you-edit-text.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-08-09 +--- + +Fixed part of a slide sliding out of view while editing text near the slide edge, which made the top of the slide look like it had disappeared until the edit ended. From 61ec9d732546c8e53ab7f3be9b78c2d6a77fc142 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Sun, 9 Aug 2026 04:49:18 +0000 Subject: [PATCH 09/19] Revert overflow clip override causing rendering issues --- templates/slides/app/global.css | 11 ----------- ...-no-longer-jumps-out-of-view-when-you-edit-text.md | 6 ------ 2 files changed, 17 deletions(-) delete mode 100644 templates/slides/changelog/2026-08-09-slide-content-no-longer-jumps-out-of-view-when-you-edit-text.md diff --git a/templates/slides/app/global.css b/templates/slides/app/global.css index 9412d655a1..d538b64741 100644 --- a/templates/slides/app/global.css +++ b/templates/slides/app/global.css @@ -258,17 +258,6 @@ button[title="Open agent sidebar"] { font-family: "Poppins", sans-serif; } -/* A slide is a fixed stage: it must clip, but it must never become a scroll - container. Imported slide roots carry inline `overflow: hidden`, which the - browser can still scroll programmatically -- focusing a text box that - extends past the slide edge makes it "reveal" the caret by scrolling the - stage, shifting the top of the slide out of view until the edit ends. - `clip` produces the same visual clipping with no scrollport, so there is - nothing to scroll. Overrides the imported inline style by necessity. */ -.slide-content .fmd-slide { - overflow: clip !important; -} - /* Per-element inline text editing — highlight the text leaf that is currently being edited so the user can see which block is active. */ .slide-content [data-editing-block="true"] { diff --git a/templates/slides/changelog/2026-08-09-slide-content-no-longer-jumps-out-of-view-when-you-edit-text.md b/templates/slides/changelog/2026-08-09-slide-content-no-longer-jumps-out-of-view-when-you-edit-text.md deleted file mode 100644 index 08e7ced8df..0000000000 --- a/templates/slides/changelog/2026-08-09-slide-content-no-longer-jumps-out-of-view-when-you-edit-text.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -type: fixed -date: 2026-08-09 ---- - -Fixed part of a slide sliding out of view while editing text near the slide edge, which made the top of the slide look like it had disappeared until the edit ended. From 962a6f0916d8c42c3e4122bcb98b1f9ba358e138 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Sun, 9 Aug 2026 07:59:53 +0000 Subject: [PATCH 10/19] Fix slow rendering and glitches in large slide decks --- .../components/deck/SlideRenderer.test.tsx | 40 ++++++++++++++++ .../app/components/deck/SlideRenderer.tsx | 46 +++++++++++++++++++ ...r-render-slowly-or-glitch-in-the-editor.md | 6 +++ 3 files changed, 92 insertions(+) create mode 100644 templates/slides/changelog/2026-08-09-large-decks-no-longer-render-slowly-or-glitch-in-the-editor.md diff --git a/templates/slides/app/components/deck/SlideRenderer.test.tsx b/templates/slides/app/components/deck/SlideRenderer.test.tsx index b974341003..1b8ef1f60b 100644 --- a/templates/slides/app/components/deck/SlideRenderer.test.tsx +++ b/templates/slides/app/components/deck/SlideRenderer.test.tsx @@ -362,4 +362,44 @@ describe("SlideInner autofit", () => { ); }); }); + + it("defers measuring an off-screen slide until it scrolls into view", async () => { + let notify: ((entries: { isIntersecting: boolean }[]) => void) | undefined; + vi.stubGlobal( + "IntersectionObserver", + class { + constructor(cb: (entries: { isIntersecting: boolean }[]) => void) { + notify = cb; + } + observe() {} + disconnect() {} + }, + ); + // Far below the viewport, the way most thumbnails in a long deck are. + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + () => rect(0, 100_000, 740, 380), + ); + + const slide: Slide = { + id: "raw-offscreen", + layout: "blank", + notes: "", + content: + '

Flow title

', + }; + render(); + + // The fit layer is only ever created by a measure pass, so its absence + // proves the expensive per-descendant measurement never ran. + await new Promise((resolve) => window.setTimeout(resolve, 20)); + expect(document.querySelector("[data-fmd-autofit-content]")).toBeNull(); + + notify?.([{ isIntersecting: true }]); + + await waitFor(() => { + expect( + document.querySelector("[data-fmd-autofit-content]"), + ).not.toBeNull(); + }); + }); }); diff --git a/templates/slides/app/components/deck/SlideRenderer.tsx b/templates/slides/app/components/deck/SlideRenderer.tsx index 5cd979d9eb..2d0083a86b 100644 --- a/templates/slides/app/components/deck/SlideRenderer.tsx +++ b/templates/slides/app/components/deck/SlideRenderer.tsx @@ -385,6 +385,29 @@ function useSlideAutofit( let raf = 0; let disposed = false; + // Measuring costs a full-document reflow per slide (every descendant is + // read with getBoundingClientRect, interleaved with style writes). A deck + // with dozens of slides mounts that many renderers at once, so off-screen + // thumbnails are left unmeasured until they scroll into view. Without an + // IntersectionObserver there is nothing to defer against, so measure + // eagerly as before. + const canDefer = typeof IntersectionObserver !== "undefined"; + // Resolved synchronously rather than waiting for the observer's first + // callback: a slide that is already on screen must be measured on this + // pass, and nothing may depend on a callback that a given environment + // might never deliver. This reads one rect, not one per descendant. + const isNearViewport = () => { + const rect = root.getBoundingClientRect(); + const margin = 200; + return ( + rect.bottom >= -margin && + rect.right >= -margin && + rect.top <= (window.innerHeight || 0) + margin && + rect.left <= (window.innerWidth || 0) + margin + ); + }; + let visible = !canDefer || isNearViewport(); + let measurePending = false; const resetTarget = (target: HTMLElement) => { target.style.setProperty("--fmd-fit-scale", "1"); @@ -482,6 +505,10 @@ function useSlideAutofit( const scheduleMeasure = () => { if (disposed) return; + if (!visible) { + measurePending = true; + return; + } cancelAnimationFrame(raf); raf = requestAnimationFrame(measureNow); }; @@ -502,11 +529,30 @@ function useSlideAutofit( root.addEventListener("load", scheduleMeasure, true); document.fonts?.ready.then(scheduleMeasure).catch(() => {}); + // `rootMargin` measures a thumbnail just before it scrolls in, so the fit + // transform is already applied by the time it is on screen. + const visibilityObserver = canDefer + ? new IntersectionObserver( + (entries) => { + const isVisible = entries.some((entry) => entry.isIntersecting); + if (isVisible === visible) return; + visible = isVisible; + if (visible && measurePending) { + measurePending = false; + scheduleMeasure(); + } + }, + { rootMargin: "200px" }, + ) + : null; + visibilityObserver?.observe(root); + return () => { disposed = true; cancelAnimationFrame(raf); resizeObserver.disconnect(); mutationObserver.disconnect(); + visibilityObserver?.disconnect(); root.removeEventListener("load", scheduleMeasure, true); }; }, [canvasWidth, canvasHeight, fitKey, ref]); diff --git a/templates/slides/changelog/2026-08-09-large-decks-no-longer-render-slowly-or-glitch-in-the-editor.md b/templates/slides/changelog/2026-08-09-large-decks-no-longer-render-slowly-or-glitch-in-the-editor.md new file mode 100644 index 0000000000..bbdd97bf69 --- /dev/null +++ b/templates/slides/changelog/2026-08-09-large-decks-no-longer-render-slowly-or-glitch-in-the-editor.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-08-09 +--- + +Fixed decks with many slides loading slowly and rendering incorrectly in the editor. Every slide thumbnail measured its full layout on mount, so a long deck forced hundreds of page reflows at once; off-screen thumbnails now wait until they scroll into view. From b4658e3385d5c40662e9d8fdeadd188db8452611 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Sun, 9 Aug 2026 08:15:45 +0000 Subject: [PATCH 11/19] Fix large deck rendering with content-visibility and aspect-ratio --- .../app/components/editor/EditorSidebar.tsx | 11 ++++++++++- .../app/components/editor/ExportMenu.test.tsx | 17 +++++++++++------ ...ger-render-slowly-or-glitch-in-the-editor.md | 2 +- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/templates/slides/app/components/editor/EditorSidebar.tsx b/templates/slides/app/components/editor/EditorSidebar.tsx index 77ea57760b..2c9e2b08b2 100644 --- a/templates/slides/app/components/editor/EditorSidebar.tsx +++ b/templates/slides/app/components/editor/EditorSidebar.tsx @@ -35,7 +35,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import type { Slide } from "@/context/DeckContext"; -import type { AspectRatio } from "@/lib/aspect-ratios"; +import { getAspectRatioDims, type AspectRatio } from "@/lib/aspect-ratios"; import { TAB_ID } from "@/lib/tab-id"; import type { DesignSystemData } from "../../../shared/api"; @@ -194,6 +194,8 @@ function SortableSlideThumb({ opacity: isDragging ? 0.5 : 1, }; + const thumbDims = getAspectRatioDims(aspectRatio); + return (