1- 'use client'
2-
3- import { type CSSProperties , useLayoutEffect , useMemo , useRef , useState } from 'react'
1+ import { useMemo } from 'react'
42import { cn } from '@sim/emcn'
53import { StageBlockCard } from '@/app/(landing)/components/hero/components/hero-platform-loop/stage-block-card'
64import {
5+ blockHeight ,
76 handleAnchors ,
87 STAGE_BLOCKS ,
98 STAGE_CANVAS ,
@@ -15,8 +14,6 @@ import {
1514 type BlockDef ,
1615} from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
1716
18- /** Upper bound on the canvas render scale (the scale at the full 1300px cap). */
19- const MAX_STAGE_SCALE = 0.71
2017/** Breathing room between the canvas bounds and the card edges, in card px. */
2118const STAGE_MARGIN = 20
2219
@@ -40,11 +37,10 @@ interface HeroWorkflowStageProps {
4037
4138/**
4239 * The hero window's live workflow canvas - the right-pane counterpart of the
43- * chat loop. Blocks pop in one by one as `builtCount` advances (staggered
44- * scale/fade entrances, edges stroke-draw once both endpoints exist) at their
45- * fixed positions. The edge SVG is `overflow-visible` - SVGs clip
46- * at their viewport by default, which would cut the lines if a block ever sat
47- * outside the design-canvas bounds.
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.
4844 *
4945 * Decorative and `aria-hidden` (via the parent frame), so blocks are NOT
5046 * draggable - `pointer-events-none`, matching the rest of the hero animation.
@@ -64,116 +60,73 @@ export function HeroWorkflowStage({
6460 canvas = STAGE_CANVAS ,
6561 selectedId,
6662} : HeroWorkflowStageProps ) {
67- const containerRef = useRef < HTMLDivElement > ( null )
68- const [ scale , setScale ] = useState ( MAX_STAGE_SCALE )
6963 const blocksById = useMemo ( ( ) => new Map ( blocks . map ( ( b ) => [ b . id , b ] ) ) , [ blocks ] )
7064
71- // Fit the design canvas to the card: scale down when the pane narrows so the
72- // branch blocks never clip, capped at the full-width scale. Measures LAYOUT
73- // size (offsetWidth/Height) - the stage lives inside the platform loop's
74- // scale-transformed design-space layer, and getBoundingClientRect's visual
75- // size would compound that outer scale into a double shrink.
76- useLayoutEffect ( ( ) => {
77- const el = containerRef . current
78- if ( ! el ) return
79- const measure = ( ) => {
80- const w = el . offsetWidth
81- const h = el . offsetHeight
82- if ( w < 40 || h < 40 ) return
83- setScale (
84- Math . min (
85- MAX_STAGE_SCALE ,
86- ( w - STAGE_MARGIN ) / canvas . width ,
87- ( h - STAGE_MARGIN ) / canvas . height
88- )
89- )
90- }
91- measure ( )
92- const ro = new ResizeObserver ( measure )
93- ro . observe ( el )
94- return ( ) => ro . disconnect ( )
95- } , [ canvas . width , canvas . height ] )
96-
9765 const builtIds = useMemo (
9866 ( ) => new Set ( blocks . slice ( 0 , builtCount ) . map ( ( b ) => b . id ) ) ,
9967 [ blocks , builtCount ]
10068 )
10169
10270 return (
103- < div
104- ref = { containerRef }
105- className = 'flex h-full w-full items-center justify-center overflow-hidden'
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'
10677 >
107- < div
108- className = 'relative shrink-0'
109- style = { {
110- width : canvas . width * scale ,
111- height : canvas . height * scale ,
112- } }
113- >
114- < div
115- className = 'absolute top-0 left-0'
116- style = { {
117- width : canvas . width ,
118- height : canvas . height ,
119- transform : `scale(${ scale } )` ,
120- transformOrigin : '0 0' ,
121- } }
122- >
123- < svg
124- className = 'pointer-events-none absolute inset-0 overflow-visible'
125- width = { canvas . width }
126- height = { canvas . height }
127- viewBox = { `0 0 ${ canvas . width } ${ canvas . height } ` }
128- fill = 'none'
129- aria-hidden = 'true'
130- >
131- { edges . map ( ( [ from , to ] ) => {
132- const source = blocksById . get ( from )
133- const target = blocksById . get ( to )
134- if ( ! source || ! target ) return null
135- const visible = builtIds . has ( from ) && builtIds . has ( to )
136- const s = handleAnchors ( source ) . out
137- const t = handleAnchors ( target ) . in
138- return (
139- < path
140- key = { `${ from } -${ to } ` }
141- d = { verticalSmoothStep ( s . x , s . y , t . x , t . y ) }
142- pathLength = { 1 }
143- stroke = 'var(--workflow-edge)'
144- strokeWidth = { 2 }
145- strokeLinecap = 'round'
146- className = 'transition-[stroke-dashoffset] duration-500 [stroke-dasharray:1] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)]'
147- style = { { strokeDashoffset : visible ? 0 : 1 } as CSSProperties }
148- />
149- )
150- } ) }
151- </ svg >
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+ } ) }
152100
153- { blocks . map ( ( block ) => {
154- const built = builtIds . has ( block . id )
155- return (
156- < div
157- key = { block . id }
101+ { blocks . map ( ( block ) => {
102+ const built = builtIds . has ( block . id )
103+ return (
104+ < foreignObject
105+ key = { block . id }
106+ x = { block . x }
107+ y = { block . y }
108+ width = { BLOCK_WIDTH }
109+ height = { blockHeight ( block ) }
110+ overflow = 'visible'
111+ >
112+ < div
113+ 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'
116+ ) }
117+ >
118+ < StageBlockCard block = { block } />
119+ < span
120+ aria-hidden
158121 className = { cn (
159- 'pointer-events-none absolute transition-[opacity,scale] duration-300 [transition-timing-function:cubic-bezier(0.22,1,0.36,1)] ' ,
160- built ? 'scale-100 opacity-100' : 'scale-[0.94] opacity-0'
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'
161124 ) }
162- style = { { left : block . x , top : block . y , width : BLOCK_WIDTH } }
163- >
164- < StageBlockCard block = { block } />
165- < span
166- aria-hidden
167- className = { cn (
168- 'pointer-events-none absolute inset-0 rounded-[13px] ring-[1.75px] ring-[var(--text-secondary)] transition-opacity duration-300 ease-out' ,
169- selectedId === block . id && built ? 'opacity-100' : 'opacity-0'
170- ) }
171- />
172- </ div >
173- )
174- } ) }
175- </ div >
176- </ div >
177- </ div >
125+ />
126+ </ div >
127+ </ foreignObject >
128+ )
129+ } ) }
130+ </ svg >
178131 )
179132}
0 commit comments