)
}
diff --git a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-workflow-stage.tsx b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-workflow-stage.tsx
index c42c0698d10..342e6652205 100644
--- a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-workflow-stage.tsx
+++ b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-workflow-stage.tsx
@@ -13,6 +13,7 @@ import {
BLOCK_WIDTH,
type BlockDef,
} from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
+import { ResponsiveDesignStage } from '@/app/(landing)/components/shared/responsive-design-stage'
/** Breathing room between the canvas bounds and the card edges, in card px. */
const STAGE_MARGIN = 20
@@ -37,10 +38,12 @@ interface HeroWorkflowStageProps {
/**
* The hero window's live workflow canvas - the right-pane counterpart of the
- * chat loop. One stable SVG viewBox owns both the edge and block coordinate
- * systems, so drawing a line or revealing a block never changes the canvas's
- * measured scale. Blocks pop in one by one as `builtCount` advances and edges
- * stroke-draw once both endpoints exist.
+ * chat loop. A fixed HTML design surface owns both the edge and block
+ * coordinate systems, so drawing a line or revealing a block never changes
+ * the canvas's measured scale. SVG renders only the native edge paths; block
+ * cards stay in ordinary HTML to avoid WebKit's foreignObject compositing bugs.
+ * Blocks pop in one by one as `builtCount` advances and edges stroke-draw once
+ * both endpoints exist.
*
* Decorative and `aria-hidden` (via the parent frame), so blocks are NOT
* draggable - `pointer-events-none`, matching the rest of the hero animation.
@@ -68,65 +71,70 @@ export function HeroWorkflowStage({
)
return (
-
+
)
}
diff --git a/apps/sim/app/(landing)/components/shared/hero-loop-shell/hero-loop-shell.tsx b/apps/sim/app/(landing)/components/shared/hero-loop-shell/hero-loop-shell.tsx
index 363ad3fef2a..5badb42cd2d 100644
--- a/apps/sim/app/(landing)/components/shared/hero-loop-shell/hero-loop-shell.tsx
+++ b/apps/sim/app/(landing)/components/shared/hero-loop-shell/hero-loop-shell.tsx
@@ -2,6 +2,7 @@
import type { ReactNode } from 'react'
import { PLATFORM_LOOP_DESIGN } from '@/app/(landing)/components/shared/platform-loop-constants'
+import { ResponsiveDesignStage } from '@/app/(landing)/components/shared/responsive-design-stage'
import {
EnterpriseSidebar,
type EnterpriseSidebarProps,
@@ -23,11 +24,11 @@ interface HeroLoopShellProps {
}
/**
- * The platform heroes' shared scaled stage. An SVG viewBox maps the fixed
- * 1280x735 design space to the rendered window without applying a CSS
- * transform to the whole app. Keeping that scale out of the animated HTML
- * subtree prevents fractional repaint snapping in both the canvas and the
- * otherwise-static {@link EnterpriseSidebar}.
+ * The platform heroes' shared responsive stage. The whole preview remains
+ * ordinary HTML, fitted from its fixed 1280x735 design space by
+ * {@link ResponsiveDesignStage}; SVG is reserved for native workflow paths.
+ * This keeps the sidebar and every animated descendant in one browser-safe
+ * layout coordinate system across Safari, Chromium, and Firefox.
*/
export function HeroLoopShell({
workspaceName = 'Brightwave',
@@ -38,24 +39,21 @@ export function HeroLoopShell({
children,
}: HeroLoopShellProps) {
return (
-
-
-
-
-
{children}
-
-
-
+
+
{children}
+
)
}
diff --git a/apps/sim/app/(landing)/components/shared/responsive-design-stage/index.ts b/apps/sim/app/(landing)/components/shared/responsive-design-stage/index.ts
new file mode 100644
index 00000000000..cd4757dfe1f
--- /dev/null
+++ b/apps/sim/app/(landing)/components/shared/responsive-design-stage/index.ts
@@ -0,0 +1 @@
+export { ResponsiveDesignStage } from '@/app/(landing)/components/shared/responsive-design-stage/responsive-design-stage'
diff --git a/apps/sim/app/(landing)/components/shared/responsive-design-stage/responsive-design-stage.test.ts b/apps/sim/app/(landing)/components/shared/responsive-design-stage/responsive-design-stage.test.ts
new file mode 100644
index 00000000000..a0fb3057cc5
--- /dev/null
+++ b/apps/sim/app/(landing)/components/shared/responsive-design-stage/responsive-design-stage.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from 'vitest'
+import { calculateFitScale } from '@/app/(landing)/components/shared/responsive-design-stage/responsive-design-stage'
+
+describe('calculateFitScale', () => {
+ it('fits the design surface to the limiting host dimension', () => {
+ expect(
+ calculateFitScale({
+ availableWidth: 1080,
+ availableHeight: 620,
+ designWidth: 1280,
+ designHeight: 735,
+ inset: 0,
+ maxScale: 1,
+ })
+ ).toBeCloseTo(620 / 735)
+ })
+
+ it('reserves the requested inset before calculating the scale', () => {
+ expect(
+ calculateFitScale({
+ availableWidth: 500,
+ availableHeight: 700,
+ designWidth: 560,
+ designHeight: 700,
+ inset: 20,
+ maxScale: 1,
+ })
+ ).toBeCloseTo(480 / 560)
+ })
+
+ it('does not upscale beyond the configured maximum', () => {
+ expect(
+ calculateFitScale({
+ availableWidth: 1600,
+ availableHeight: 1000,
+ designWidth: 1280,
+ designHeight: 735,
+ inset: 0,
+ maxScale: 1,
+ })
+ ).toBe(1)
+ })
+
+ it('does not apply a scale before the host has measurable space', () => {
+ expect(
+ calculateFitScale({
+ availableWidth: 0,
+ availableHeight: 620,
+ designWidth: 1280,
+ designHeight: 735,
+ inset: 0,
+ maxScale: 1,
+ })
+ ).toBe(0)
+ })
+})
diff --git a/apps/sim/app/(landing)/components/shared/responsive-design-stage/responsive-design-stage.tsx b/apps/sim/app/(landing)/components/shared/responsive-design-stage/responsive-design-stage.tsx
new file mode 100644
index 00000000000..96cf6fb1c62
--- /dev/null
+++ b/apps/sim/app/(landing)/components/shared/responsive-design-stage/responsive-design-stage.tsx
@@ -0,0 +1,138 @@
+'use client'
+
+import { type ReactNode, useLayoutEffect, useRef } from 'react'
+import { cn } from '@sim/emcn'
+
+const SCALE_EPSILON = 0.0001
+
+interface FitScaleOptions {
+ availableWidth: number
+ availableHeight: number
+ designWidth: number
+ designHeight: number
+ inset: number
+ maxScale: number
+}
+
+export function calculateFitScale({
+ availableWidth,
+ availableHeight,
+ designWidth,
+ designHeight,
+ inset,
+ maxScale,
+}: FitScaleOptions): number {
+ if (
+ availableWidth <= inset ||
+ availableHeight <= inset ||
+ designWidth <= 0 ||
+ designHeight <= 0 ||
+ maxScale <= 0
+ ) {
+ return 0
+ }
+
+ return Math.min(
+ maxScale,
+ (availableWidth - inset) / designWidth,
+ (availableHeight - inset) / designHeight
+ )
+}
+
+interface ResponsiveDesignStageProps {
+ width: number
+ height: number
+ children: ReactNode
+ className?: string
+ contentClassName?: string
+ inset?: number
+ maxScale?: number
+ align?: 'start' | 'center'
+}
+
+/**
+ * Fits a fixed-size HTML design surface into its host without putting HTML in
+ * SVG. `ResizeObserver` watches only the stable host box, and the scale is
+ * written directly to the design surface so resizes do not rerender its React
+ * subtree. CSS `zoom` keeps the surface in normal document layout and avoids
+ * the fractional compositing drift caused by scaling a layer full of animated
+ * descendants. The transform branch is a fallback for older browsers.
+ */
+export function ResponsiveDesignStage({
+ width,
+ height,
+ children,
+ className,
+ contentClassName,
+ inset = 0,
+ maxScale = 1,
+ align = 'center',
+}: ResponsiveDesignStageProps) {
+ const hostRef = useRef(null)
+ const surfaceRef = useRef(null)
+
+ useLayoutEffect(() => {
+ const host = hostRef.current
+ const surface = surfaceRef.current
+ if (!host || !surface) return
+
+ surface.style.width = `${width}px`
+ surface.style.height = `${height}px`
+
+ const supportsZoom = CSS.supports('zoom', '1')
+ let appliedScale = -1
+
+ const applyScale = (availableWidth: number, availableHeight: number) => {
+ const scale = calculateFitScale({
+ availableWidth,
+ availableHeight,
+ designWidth: width,
+ designHeight: height,
+ inset,
+ maxScale,
+ })
+ if (scale === 0 || Math.abs(scale - appliedScale) < SCALE_EPSILON) return
+
+ if (supportsZoom) {
+ surface.style.zoom = String(scale)
+ surface.style.transform = ''
+ } else {
+ surface.style.zoom = '1'
+ surface.style.transform = `scale(${scale})`
+ }
+ surface.style.opacity = '1'
+ appliedScale = scale
+ }
+
+ applyScale(host.clientWidth, host.clientHeight)
+
+ const observer = new ResizeObserver(([entry]) => {
+ applyScale(entry.contentRect.width, entry.contentRect.height)
+ })
+ observer.observe(host)
+
+ return () => observer.disconnect()
+ }, [height, inset, maxScale, width])
+
+ return (
+
+
+ {children}
+
+
+ )
+}
diff --git a/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-platform-loop.tsx b/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-platform-loop.tsx
index 6ded478dc7e..8a0d99d1c51 100644
--- a/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-platform-loop.tsx
+++ b/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-platform-loop.tsx
@@ -27,8 +27,8 @@ interface EnterprisePlatformLoopProps {
/**
* The enterprise hero's platform loop - a sibling of the homepage
- * `HeroPlatformLoop` that shares its architecture (fixed design-space layer
- * scaled to the window via ResizeObserver + `transform: scale`, a parent-owned
+ * `HeroPlatformLoop` that shares its architecture (fixed HTML design surface
+ * fitted to the window via the shared responsive stage, a parent-owned
* timeline clock driving presentational stages, reduced-motion showing a
* static finished frame) but diverges in content: where the homepage overlays
* a live chat over a baked screenshot, this variant renders the WHOLE interior
diff --git a/apps/sim/app/(landing)/files/components/files-hero-loop.tsx b/apps/sim/app/(landing)/files/components/files-hero-loop.tsx
index b1dba0325ea..31322848db0 100644
--- a/apps/sim/app/(landing)/files/components/files-hero-loop.tsx
+++ b/apps/sim/app/(landing)/files/components/files-hero-loop.tsx
@@ -186,8 +186,8 @@ function ToolbarChip({ icon, label }: { icon: ReactNode; label: string }) {
/**
* The files hero's platform loop - the workflows editor loop's architecture
- * (a fixed 1280x735 design-space layer scaled to the window via
- * ResizeObserver + `transform: scale`, a parent-owned clock, reduced-motion
+ * (a fixed 1280x735 HTML design surface fitted to the window via the shared
+ * responsive stage, a parent-owned clock, reduced-motion
* showing the finished frame) with the same live {@link EnterpriseSidebar}
* highlighting its Files nav row. The workspace pane is the Files library
* itself: the 44px title bar (File icon, "Files", "Upload file"), the
diff --git a/apps/sim/app/(landing)/knowledge/components/knowledge-hero-loop.tsx b/apps/sim/app/(landing)/knowledge/components/knowledge-hero-loop.tsx
index 3a78ed891fa..c720177cf06 100644
--- a/apps/sim/app/(landing)/knowledge/components/knowledge-hero-loop.tsx
+++ b/apps/sim/app/(landing)/knowledge/components/knowledge-hero-loop.tsx
@@ -116,8 +116,8 @@ type SyncPhase = 'idle' | 'syncing' | 'synced'
/**
* The knowledge hero's module loop - the WorkflowsEditorLoop architecture
- * (fixed 1280x735 design-space layer scaled to the window via ResizeObserver
- * + `transform: scale`, a parent-owned clock, reduced-motion showing the
+ * (a fixed 1280x735 HTML design surface fitted to the window via the shared
+ * responsive stage, a parent-owned clock, reduced-motion showing the
* finished frame) with the workspace pane retelling the Knowledge Base
* module: the 44px title bar (Database mark, "New base"), the search /
* Filter / Sort options bar, and the knowledge-bases table in the real
diff --git a/apps/sim/app/(landing)/logs/components/logs-hero-loop.tsx b/apps/sim/app/(landing)/logs/components/logs-hero-loop.tsx
index a031a2343b8..1f3ca8635bd 100644
--- a/apps/sim/app/(landing)/logs/components/logs-hero-loop.tsx
+++ b/apps/sim/app/(landing)/logs/components/logs-hero-loop.tsx
@@ -223,8 +223,8 @@ function LogsTableRow({ row, visible }: LogsTableRowProps) {
/**
* The logs hero's platform loop - the workflows editor loop's architecture
- * (fixed 1280x735 design-space layer scaled to the window via ResizeObserver
- * + `transform: scale`, a parent-owned clock, reduced-motion showing the
+ * (a fixed 1280x735 HTML design surface fitted to the window via the shared
+ * responsive stage, a parent-owned clock, reduced-motion showing the
* finished frame) with the workspace pane replaced by a static rendering of
* the real Logs surface: the 44px title bar (Library icon, "Logs", Export,
* Logs/Dashboard tabs), the search/Filter/Sort options bar, and the runs
diff --git a/apps/sim/app/(landing)/tables/components/tables-hero-loop.tsx b/apps/sim/app/(landing)/tables/components/tables-hero-loop.tsx
index 6ecd49fd818..67c8a590617 100644
--- a/apps/sim/app/(landing)/tables/components/tables-hero-loop.tsx
+++ b/apps/sim/app/(landing)/tables/components/tables-hero-loop.tsx
@@ -340,8 +340,8 @@ function TablesGridPane({ rowCount, filledCount }: TablesGridPaneProps) {
/**
* The tables hero's editor loop - the grid-pane sibling of the workflows
- * editor loop. Same architecture (fixed 1280x735 design-space layer scaled
- * to the window via ResizeObserver + `transform: scale`, a parent-owned
+ * editor loop. Same architecture (a fixed 1280x735 HTML design surface fitted
+ * to the window via the shared responsive stage, a parent-owned
* clock driving a presentational pane, reduced-motion showing the finished
* frame) and the same live {@link EnterpriseSidebar} with its Tables nav
* row highlighted, but the workspace pane is the Leads table itself: the