Skip to content

Commit 0af1c55

Browse files
committed
fix(landing): stabilize previews across browsers
1 parent af7a12c commit 0af1c55

11 files changed

Lines changed: 304 additions & 102 deletions

File tree

apps/sim/app/(landing)/components/features/components/captured-platform-surface.tsx

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
'use client'
22

33
import Image from 'next/image'
4+
import { PLATFORM_LOOP_DESIGN } from '@/app/(landing)/components/shared/platform-loop-constants'
5+
import { ResponsiveDesignStage } from '@/app/(landing)/components/shared/responsive-design-stage'
46
import {
57
PREVIEW_SIDEBAR_CHATS,
68
PREVIEW_SIDEBAR_WORKFLOWS,
@@ -26,23 +28,22 @@ export function CapturedPlatformSurface({ src, sizes, activeItem }: CapturedPlat
2628
return (
2729
<div className='absolute inset-0 overflow-hidden'>
2830
<Image src={src} alt='' fill sizes={sizes} className='object-cover' />
29-
<svg
30-
aria-hidden='true'
31-
className='pointer-events-none absolute inset-0 size-full overflow-hidden'
32-
viewBox='0 0 1280 735'
33-
preserveAspectRatio='xMinYMin meet'
31+
<ResponsiveDesignStage
32+
width={PLATFORM_LOOP_DESIGN.width}
33+
height={PLATFORM_LOOP_DESIGN.height}
34+
align='start'
35+
className='pointer-events-none absolute inset-0'
36+
contentClassName='flex'
3437
>
35-
<foreignObject width='1280' height='735'>
36-
<div className='flex h-full w-[249px] border-[var(--border)] border-r bg-[var(--surface-1)]'>
37-
<EnterpriseSidebar
38-
chats={PREVIEW_SIDEBAR_CHATS}
39-
workflows={PREVIEW_SIDEBAR_WORKFLOWS}
40-
activeItem={activeItem}
41-
/>
42-
<div className='min-w-0 flex-1 bg-[var(--surface-1)]' />
43-
</div>
44-
</foreignObject>
45-
</svg>
38+
<div className='flex h-full w-[249px] border-[var(--border)] border-r bg-[var(--surface-1)]'>
39+
<EnterpriseSidebar
40+
chats={PREVIEW_SIDEBAR_CHATS}
41+
workflows={PREVIEW_SIDEBAR_WORKFLOWS}
42+
activeItem={activeItem}
43+
/>
44+
<div className='min-w-0 flex-1 bg-[var(--surface-1)]' />
45+
</div>
46+
</ResponsiveDesignStage>
4647
</div>
4748
)
4849
}

apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-workflow-stage.tsx

Lines changed: 61 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
BLOCK_WIDTH,
1414
type BlockDef,
1515
} from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
16+
import { ResponsiveDesignStage } from '@/app/(landing)/components/shared/responsive-design-stage'
1617

1718
/** Breathing room between the canvas bounds and the card edges, in card px. */
1819
const STAGE_MARGIN = 20
@@ -37,10 +38,12 @@ interface HeroWorkflowStageProps {
3738

3839
/**
3940
* The hero window's live workflow canvas - the right-pane counterpart of the
40-
* chat loop. One stable SVG viewBox owns both the edge and block coordinate
41-
* systems, so drawing a line or revealing a block never changes the canvas's
42-
* measured scale. Blocks pop in one by one as `builtCount` advances and edges
43-
* stroke-draw once both endpoints exist.
41+
* chat loop. A fixed HTML design surface owns both the edge and block
42+
* coordinate systems, so drawing a line or revealing a block never changes
43+
* the canvas's measured scale. SVG renders only the native edge paths; block
44+
* cards stay in ordinary HTML to avoid WebKit's foreignObject compositing bugs.
45+
* Blocks pop in one by one as `builtCount` advances and edges stroke-draw once
46+
* both endpoints exist.
4447
*
4548
* Decorative and `aria-hidden` (via the parent frame), so blocks are NOT
4649
* draggable - `pointer-events-none`, matching the rest of the hero animation.
@@ -68,65 +71,70 @@ export function HeroWorkflowStage({
6871
)
6972

7073
return (
71-
<svg
72-
aria-hidden='true'
73-
className='size-full overflow-hidden'
74-
viewBox={`${-STAGE_MARGIN / 2} ${-STAGE_MARGIN / 2} ${canvas.width + STAGE_MARGIN} ${canvas.height + STAGE_MARGIN}`}
75-
preserveAspectRatio='xMidYMid meet'
76-
fill='none'
74+
<ResponsiveDesignStage
75+
width={canvas.width}
76+
height={canvas.height}
77+
inset={STAGE_MARGIN}
78+
className='size-full'
79+
contentClassName='relative'
7780
>
78-
{edges.map(([from, to]) => {
79-
const source = blocksById.get(from)
80-
const target = blocksById.get(to)
81-
if (!source || !target) return null
82-
const visible = builtIds.has(from) && builtIds.has(to)
83-
const s = handleAnchors(source).out
84-
const t = handleAnchors(target).in
85-
return (
86-
<path
87-
key={`${from}-${to}`}
88-
d={verticalSmoothStep(s.x, s.y, t.x, t.y)}
89-
pathLength={1}
90-
stroke='var(--workflow-edge)'
91-
strokeWidth={2}
92-
strokeLinecap='round'
93-
className={cn(
94-
'transition-[stroke-dashoffset] duration-500 [stroke-dasharray:1] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)]',
95-
visible ? '[stroke-dashoffset:0]' : '[stroke-dashoffset:1]'
96-
)}
97-
/>
98-
)
99-
})}
81+
<svg
82+
aria-hidden='true'
83+
className='pointer-events-none absolute inset-0 size-full overflow-visible'
84+
viewBox={`0 0 ${canvas.width} ${canvas.height}`}
85+
fill='none'
86+
>
87+
{edges.map(([from, to]) => {
88+
const source = blocksById.get(from)
89+
const target = blocksById.get(to)
90+
if (!source || !target) return null
91+
const visible = builtIds.has(from) && builtIds.has(to)
92+
const s = handleAnchors(source).out
93+
const t = handleAnchors(target).in
94+
return (
95+
<path
96+
key={`${from}-${to}`}
97+
d={verticalSmoothStep(s.x, s.y, t.x, t.y)}
98+
pathLength={1}
99+
stroke='var(--workflow-edge)'
100+
strokeWidth={2}
101+
strokeLinecap='round'
102+
className={cn(
103+
'transition-[stroke-dashoffset] duration-500 [stroke-dasharray:1] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)]',
104+
visible ? '[stroke-dashoffset:0]' : '[stroke-dashoffset:1]'
105+
)}
106+
/>
107+
)
108+
})}
109+
</svg>
100110

101111
{blocks.map((block) => {
102112
const built = builtIds.has(block.id)
103113
return (
104-
<foreignObject
114+
<div
105115
key={block.id}
106-
x={block.x}
107-
y={block.y}
108-
width={BLOCK_WIDTH}
109-
height={blockHeight(block)}
110-
overflow='visible'
116+
className={cn(
117+
'pointer-events-none absolute origin-center transition-[opacity,scale] duration-300 [transition-timing-function:cubic-bezier(0.22,1,0.36,1)]',
118+
built ? 'scale-100 opacity-100' : 'scale-[0.94] opacity-0'
119+
)}
120+
style={{
121+
left: block.x,
122+
top: block.y,
123+
width: BLOCK_WIDTH,
124+
height: blockHeight(block),
125+
}}
111126
>
112-
<div
127+
<StageBlockCard block={block} />
128+
<span
129+
aria-hidden
113130
className={cn(
114-
'pointer-events-none relative size-full origin-center transition-[opacity,scale] duration-300 will-change-[opacity,transform] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)]',
115-
built ? 'scale-100 opacity-100' : 'scale-[0.94] opacity-0'
131+
'pointer-events-none absolute inset-0 rounded-[13px] ring-[1.75px] ring-[var(--text-secondary)] transition-opacity duration-300 ease-out',
132+
selectedId === block.id && built ? 'opacity-100' : 'opacity-0'
116133
)}
117-
>
118-
<StageBlockCard block={block} />
119-
<span
120-
aria-hidden
121-
className={cn(
122-
'pointer-events-none absolute inset-0 rounded-[13px] ring-[1.75px] ring-[var(--text-secondary)] transition-opacity duration-300 ease-out',
123-
selectedId === block.id && built ? 'opacity-100' : 'opacity-0'
124-
)}
125-
/>
126-
</div>
127-
</foreignObject>
134+
/>
135+
</div>
128136
)
129137
})}
130-
</svg>
138+
</ResponsiveDesignStage>
131139
)
132140
}

apps/sim/app/(landing)/components/shared/hero-loop-shell/hero-loop-shell.tsx

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import type { ReactNode } from 'react'
44
import { PLATFORM_LOOP_DESIGN } from '@/app/(landing)/components/shared/platform-loop-constants'
5+
import { ResponsiveDesignStage } from '@/app/(landing)/components/shared/responsive-design-stage'
56
import {
67
EnterpriseSidebar,
78
type EnterpriseSidebarProps,
@@ -23,11 +24,11 @@ interface HeroLoopShellProps {
2324
}
2425

2526
/**
26-
* The platform heroes' shared scaled stage. An SVG viewBox maps the fixed
27-
* 1280x735 design space to the rendered window without applying a CSS
28-
* transform to the whole app. Keeping that scale out of the animated HTML
29-
* subtree prevents fractional repaint snapping in both the canvas and the
30-
* otherwise-static {@link EnterpriseSidebar}.
27+
* The platform heroes' shared responsive stage. The whole preview remains
28+
* ordinary HTML, fitted from its fixed 1280x735 design space by
29+
* {@link ResponsiveDesignStage}; SVG is reserved for native workflow paths.
30+
* This keeps the sidebar and every animated descendant in one browser-safe
31+
* layout coordinate system across Safari, Chromium, and Firefox.
3132
*/
3233
export function HeroLoopShell({
3334
workspaceName = 'Brightwave',
@@ -38,24 +39,21 @@ export function HeroLoopShell({
3839
children,
3940
}: HeroLoopShellProps) {
4041
return (
41-
<svg
42-
aria-hidden='true'
43-
className='pointer-events-none absolute inset-0 size-full overflow-hidden'
44-
viewBox={`0 0 ${PLATFORM_LOOP_DESIGN.width} ${PLATFORM_LOOP_DESIGN.height}`}
45-
preserveAspectRatio='xMinYMin meet'
42+
<ResponsiveDesignStage
43+
width={PLATFORM_LOOP_DESIGN.width}
44+
height={PLATFORM_LOOP_DESIGN.height}
45+
align='start'
46+
className='pointer-events-none absolute inset-0'
47+
contentClassName='flex bg-[var(--surface-1)]'
4648
>
47-
<foreignObject width={PLATFORM_LOOP_DESIGN.width} height={PLATFORM_LOOP_DESIGN.height}>
48-
<div className='flex size-full bg-[var(--surface-1)]'>
49-
<EnterpriseSidebar
50-
workspaceName={workspaceName}
51-
profileName={profileName}
52-
chats={chats}
53-
workflows={workflows}
54-
activeItem={activeItem}
55-
/>
56-
<div className='h-full min-w-0 flex-1 py-[7px] pr-[8px]'>{children}</div>
57-
</div>
58-
</foreignObject>
59-
</svg>
49+
<EnterpriseSidebar
50+
workspaceName={workspaceName}
51+
profileName={profileName}
52+
chats={chats}
53+
workflows={workflows}
54+
activeItem={activeItem}
55+
/>
56+
<div className='h-full min-w-0 flex-1 py-[7px] pr-[8px]'>{children}</div>
57+
</ResponsiveDesignStage>
6058
)
6159
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ResponsiveDesignStage } from './responsive-design-stage'
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { calculateFitScale } from '@/app/(landing)/components/shared/responsive-design-stage/responsive-design-stage'
3+
4+
describe('calculateFitScale', () => {
5+
it('fits the design surface to the limiting host dimension', () => {
6+
expect(
7+
calculateFitScale({
8+
availableWidth: 1080,
9+
availableHeight: 620,
10+
designWidth: 1280,
11+
designHeight: 735,
12+
inset: 0,
13+
maxScale: 1,
14+
})
15+
).toBeCloseTo(620 / 735)
16+
})
17+
18+
it('reserves the requested inset before calculating the scale', () => {
19+
expect(
20+
calculateFitScale({
21+
availableWidth: 500,
22+
availableHeight: 700,
23+
designWidth: 560,
24+
designHeight: 700,
25+
inset: 20,
26+
maxScale: 1,
27+
})
28+
).toBeCloseTo(480 / 560)
29+
})
30+
31+
it('does not upscale beyond the configured maximum', () => {
32+
expect(
33+
calculateFitScale({
34+
availableWidth: 1600,
35+
availableHeight: 1000,
36+
designWidth: 1280,
37+
designHeight: 735,
38+
inset: 0,
39+
maxScale: 1,
40+
})
41+
).toBe(1)
42+
})
43+
44+
it('does not apply a scale before the host has measurable space', () => {
45+
expect(
46+
calculateFitScale({
47+
availableWidth: 0,
48+
availableHeight: 620,
49+
designWidth: 1280,
50+
designHeight: 735,
51+
inset: 0,
52+
maxScale: 1,
53+
})
54+
).toBe(0)
55+
})
56+
})

0 commit comments

Comments
 (0)