@@ -65,7 +65,7 @@ export function Card<Meta = unknown>(props: CardProps<Meta>) {
6565 } = props ;
6666 const api = useTutorial < Meta > ( ) ;
6767 const {
68- step, index, total, isFirst, isLast, canAdvance, isWaiting,
68+ step, steps : allSteps , index, total, isFirst, isLast, canAdvance, isWaiting,
6969 next, prev, close, defaultCardAnchor, cardPositioning,
7070 } = api ;
7171
@@ -74,20 +74,32 @@ export function Card<Meta = unknown>(props: CardProps<Meta>) {
7474 const bodyId = `${ baseId } -body` ;
7575
7676 // ── Card rect publication ─────────────────────────────────────────────
77- // Publish the card's live bounding rect so <Arrow> and <EditorHandles>
78- // can read it. Uses a rAF loop instead of ResizeObserver alone because
79- // editor drags change the card's position without changing its size,
80- // and ResizeObserver only fires on size changes.
8177 const cardRef = useRef < HTMLDivElement > ( null ) ;
78+ const measureRef = useRef < HTMLDivElement > ( null ) ;
8279 const setCardRect = useCardRectSetter ( ) ;
8380 const lastRectRef = useRef < Rect | null > ( null ) ;
8481 const rafRef = useRef < number > ( 0 ) ;
8582
86- // ── Stable card sizing ────────────────────────────────────────────────
87- // Track the maximum height the card reaches across all steps.
88- // Apply as minHeight so the card never shrinks → no visual jump.
89- const maxHeightRef = useRef < number > ( 0 ) ;
83+ // ── Uniform card sizing ───────────────────────────────────────────────
84+ // Pre-measure ALL steps in a hidden off-screen container on mount.
85+ // The max height becomes `stableMinHeight` — applied from step 1.
9086 const [ stableMinHeight , setStableMinHeight ] = useState < number > ( 0 ) ;
87+ const measuredRef = useRef ( false ) ;
88+
89+ useLayoutEffect ( ( ) => {
90+ if ( measuredRef . current || ! measureRef . current ) return ;
91+ const container = measureRef . current ;
92+ const children = container . querySelectorAll < HTMLElement > ( "[data-eto-measure]" ) ;
93+ let max = 0 ;
94+ children . forEach ( ( el ) => {
95+ const h = el . getBoundingClientRect ( ) . height ;
96+ if ( h > max ) max = h ;
97+ } ) ;
98+ if ( max > 0 ) {
99+ setStableMinHeight ( max ) ;
100+ measuredRef . current = true ;
101+ }
102+ } ) ;
91103
92104 useLayoutEffect ( ( ) => {
93105 if ( ! cardRef . current ) return ;
@@ -97,13 +109,6 @@ export function Card<Meta = unknown>(props: CardProps<Meta>) {
97109 const read = ( ) => {
98110 const r = el . getBoundingClientRect ( ) ;
99111 const next : Rect = { left : r . left , top : r . top , width : r . width , height : r . height } ;
100-
101- // Grow-only: update stable height when content is taller
102- if ( r . height > maxHeightRef . current + 0.5 ) {
103- maxHeightRef . current = r . height ;
104- setStableMinHeight ( r . height ) ;
105- }
106-
107112 const prev = lastRectRef . current ;
108113 if (
109114 prev &&
@@ -200,17 +205,15 @@ export function Card<Meta = unknown>(props: CardProps<Meta>) {
200205
201206 if ( ! step ) return null ;
202207
208+ const isRenderProp = typeof children === "function" ;
209+ const renderFn = isRenderProp ? ( children as ( a : CardRenderArgs < Meta > ) => React . ReactNode ) : null ;
210+
211+ // Build render args for the active step
203212 const renderArgs : CardRenderArgs < Meta > = {
204213 step, index, total, isFirst, isLast, canAdvance, isWaiting, stableMinHeight, next, prev, close,
205214 } ;
206- const isRenderProp = typeof children === "function" ;
207- const rendered = isRenderProp
208- ? ( children as ( a : CardRenderArgs < Meta > ) => React . ReactNode ) ( renderArgs )
209- : null ;
215+ const rendered = renderFn ? renderFn ( renderArgs ) : null ;
210216
211- // When render-prop mode is active, the wrapper is just a positioning
212- // container — the host's render-prop provides all visual styling.
213- // .eto-card--custom strips border/shadow/bg from the wrapper.
214217 const className = [
215218 "eto-card" ,
216219 isRenderProp ? "eto-card--custom" : "" ,
@@ -222,8 +225,41 @@ export function Card<Meta = unknown>(props: CardProps<Meta>) {
222225 ...( stableMinHeight > 0 ? { minHeight : stableMinHeight } : { } ) ,
223226 } ;
224227
228+ // ── Hidden measurement container ────────────────────────────────────
229+ // Renders every step's content off-screen once to find the tallest.
230+ // After measurement, the container is removed (measuredRef = true).
231+ const measureContainer = ! measuredRef . current ? (
232+ < div
233+ ref = { measureRef }
234+ aria-hidden = "true"
235+ style = { {
236+ position : "fixed" ,
237+ left : - 9999 ,
238+ top : - 9999 ,
239+ visibility : "hidden" ,
240+ pointerEvents : "none" ,
241+ width : "min(420px, calc(100vw - 2rem))" ,
242+ } }
243+ >
244+ { allSteps . map ( ( s , i ) => {
245+ const mockArgs : CardRenderArgs < Meta > = {
246+ step : s , index : i , total, isFirst : i === 0 , isLast : i === total - 1 ,
247+ canAdvance : true , isWaiting : false , stableMinHeight : 0 , next, prev, close,
248+ } ;
249+ return (
250+ < div key = { s . id } data-eto-measure >
251+ { renderFn ? renderFn ( mockArgs ) : (
252+ < DefaultCardBody api = { { ...api , step : s , index : i } as TutorialApi < unknown > } titleId = "" bodyId = "" />
253+ ) }
254+ </ div >
255+ ) ;
256+ } ) }
257+ </ div >
258+ ) : null ;
259+
225260 return (
226261 < OverlayPortal >
262+ { measureContainer }
227263 < div
228264 ref = { cardRef }
229265 className = { className }
0 commit comments