diff --git a/apps/sim/app/(landing)/components/features/components/captured-platform-surface.tsx b/apps/sim/app/(landing)/components/features/components/captured-platform-surface.tsx index a83b364490c..768a2479bac 100644 --- a/apps/sim/app/(landing)/components/features/components/captured-platform-surface.tsx +++ b/apps/sim/app/(landing)/components/features/components/captured-platform-surface.tsx @@ -1,6 +1,8 @@ 'use client' import Image from 'next/image' +import { PLATFORM_LOOP_DESIGN } from '@/app/(landing)/components/shared/platform-loop-constants' +import { ResponsiveDesignStage } from '@/app/(landing)/components/shared/responsive-design-stage' import { PREVIEW_SIDEBAR_CHATS, PREVIEW_SIDEBAR_WORKFLOWS, @@ -26,23 +28,22 @@ export function CapturedPlatformSurface({ src, sizes, activeItem }: CapturedPlat 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}
+ ) } 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