1- import { memo } from "react" ;
1+ import { memo , useEffect , useRef , useState , type RefObject } from "react" ;
22import { motion } from "motion/react" ;
33import { ArrowRight , FolderOpen } from "lucide-react" ;
44
55// ── Constants ─────────────────────────────────────────────────────────
66
77const EASE_OUT : [ number , number , number , number ] = [ 0.22 , 0.68 , 0 , 1 ] ;
88const DISPLAY_FONT = "'Instrument Serif', Georgia, serif" ;
9+ const SIDEBAR_ARROW_HEIGHT = 360 ;
10+ const SIDEBAR_ARROW_TIP_INSET = 18 ;
11+ const SIDEBAR_ARROW_BASE_SPAN = 642 ;
12+ const SIDEBAR_ARROW_TAIL_GAP = 36 ;
13+ const SIDEBAR_ARROW_HEAD_LENGTH = 34 ;
14+ const SIDEBAR_ARROW_HEAD_SPREAD = 19 ;
15+
16+ function clamp ( value : number , min : number , max : number ) {
17+ return Math . min ( Math . max ( value , min ) , max ) ;
18+ }
19+
20+ function rotateVector ( x : number , y : number , radians : number ) {
21+ const cos = Math . cos ( radians ) ;
22+ const sin = Math . sin ( radians ) ;
23+ return {
24+ x : x * cos - y * sin ,
25+ y : x * sin + y * cos ,
26+ } ;
27+ }
928
1029// ── Ambient Gradient Orbs ─────────────────────────────────────────────
1130
@@ -133,17 +152,106 @@ function SkeletonChat() {
133152 * Placed as a direct child of the outer `relative` container so the arrow
134153 * tip can sit on the sidebar boundary while the tail starts beneath the
135154 * centered caption. */
136- function SidebarArrow ( ) {
137- // Matches the hand-drawn reference more closely:
138- // tail on the right, long sweep underneath, then a sharper rise into the sidebar.
155+ interface SidebarArrowProps {
156+ anchorRef : RefObject < HTMLElement | null > ;
157+ }
158+
159+ function SidebarArrow ( { anchorRef } : SidebarArrowProps ) {
160+ const containerRef = useRef < HTMLDivElement | null > ( null ) ;
161+ const [ metrics , setMetrics ] = useState ( { svgWidth : 900 , tailX : 660 } ) ;
162+
163+ useEffect ( ( ) => {
164+ const container = containerRef . current ;
165+ const anchor = anchorRef . current ;
166+ if ( ! container || ! anchor ) {
167+ return ;
168+ }
169+
170+ const updateMetrics = ( ) => {
171+ const containerRect = container . getBoundingClientRect ( ) ;
172+ const anchorRect = anchor . getBoundingClientRect ( ) ;
173+ const nextWidth = Math . max ( containerRect . width , 1 ) ;
174+ const maxTailX = Math . max ( nextWidth - 24 , SIDEBAR_ARROW_TIP_INSET + 120 ) ;
175+ const minTailX = Math . min ( 660 , maxTailX ) ;
176+ const anchoredTailX =
177+ anchorRect . right - containerRect . left + SIDEBAR_ARROW_TAIL_GAP ;
178+ const nextTailX = clamp ( anchoredTailX , minTailX , maxTailX ) ;
179+
180+ setMetrics ( ( prevMetrics ) => {
181+ const widthChanged = Math . abs ( prevMetrics . svgWidth - nextWidth ) >= 1 ;
182+ const tailChanged = Math . abs ( prevMetrics . tailX - nextTailX ) >= 1 ;
183+ if ( ! widthChanged && ! tailChanged ) {
184+ return prevMetrics ;
185+ }
186+ return { svgWidth : nextWidth , tailX : nextTailX } ;
187+ } ) ;
188+ } ;
189+
190+ updateMetrics ( ) ;
191+
192+ const observer = new ResizeObserver ( ( ) => {
193+ updateMetrics ( ) ;
194+ } ) ;
195+
196+ observer . observe ( container ) ;
197+ observer . observe ( anchor ) ;
198+
199+ const fontReady = document . fonts ?. ready ;
200+ if ( fontReady ) {
201+ void fontReady . then ( ( ) => {
202+ updateMetrics ( ) ;
203+ } ) ;
204+ }
205+
206+ window . addEventListener ( "resize" , updateMetrics ) ;
207+ return ( ) => {
208+ observer . disconnect ( ) ;
209+ window . removeEventListener ( "resize" , updateMetrics ) ;
210+ } ;
211+ } , [ anchorRef ] ) ;
212+
213+ const usableWidth = Math . max ( metrics . svgWidth , 1 ) ;
214+ const tipX = SIDEBAR_ARROW_TIP_INSET ;
215+ const tailX = metrics . tailX ;
216+ const span = Math . max ( tailX - tipX , 1 ) ;
217+ const scaleX = ( offset : number ) => tipX + ( offset / SIDEBAR_ARROW_BASE_SPAN ) * span ;
218+ const endPoint = { x : tipX , y : 18 } ;
219+ const endControlPoint = { x : scaleX ( 92 ) , y : 136 } ;
220+
221+ // Keep the tip pinned near the sidebar while the sweep length expands.
139222 const curvePath = [
140- "M 660 104" ,
141- "C 760 122, 790 235, 720 272" ,
142- "C 635 318, 470 312, 330 258" ,
143- "C 210 212, 110 136, 18 18" ,
223+ `M ${ tailX . toFixed ( 2 ) } 104` ,
224+ `C ${ scaleX ( 742 ) . toFixed ( 2 ) } 122, ${ scaleX ( 772 ) . toFixed ( 2 ) } 235, ${ scaleX ( 702 ) . toFixed ( 2 ) } 272` ,
225+ `C ${ scaleX ( 617 ) . toFixed ( 2 ) } 318, ${ scaleX ( 452 ) . toFixed ( 2 ) } 312, ${ scaleX ( 312 ) . toFixed ( 2 ) } 258` ,
226+ `C ${ scaleX ( 192 ) . toFixed ( 2 ) } 212, ${ endControlPoint . x . toFixed ( 2 ) } 136, ${ endPoint . x } ${ endPoint . y } ` ,
144227 ] . join ( " " ) ;
145228
146- const arrowHead = "M 72 44 L 18 18 L 44 74" ;
229+ const tangentX = endPoint . x - endControlPoint . x ;
230+ const tangentY = endPoint . y - endControlPoint . y ;
231+ const tangentLength = Math . hypot ( tangentX , tangentY ) || 1 ;
232+ const unitTangentX = tangentX / tangentLength ;
233+ const unitTangentY = tangentY / tangentLength ;
234+ const headLength = clamp ( span * 0.055 , SIDEBAR_ARROW_HEAD_LENGTH , 48 ) ;
235+ const headSpread = clamp ( headLength * 0.56 , SIDEBAR_ARROW_HEAD_SPREAD , 26 ) ;
236+ const baseCenter = {
237+ x : endPoint . x - unitTangentX * headLength ,
238+ y : endPoint . y - unitTangentY * headLength ,
239+ } ;
240+ const upperWingDirection = rotateVector ( unitTangentX , unitTangentY , Math . PI / 2 ) ;
241+ const lowerWingDirection = rotateVector ( unitTangentX , unitTangentY , - Math . PI / 2 ) ;
242+ const upperWing = {
243+ x : baseCenter . x + upperWingDirection . x * headSpread ,
244+ y : baseCenter . y + upperWingDirection . y * headSpread ,
245+ } ;
246+ const lowerWing = {
247+ x : baseCenter . x + lowerWingDirection . x * headSpread ,
248+ y : baseCenter . y + lowerWingDirection . y * headSpread ,
249+ } ;
250+ const arrowHead = [
251+ `M ${ upperWing . x . toFixed ( 2 ) } ${ upperWing . y . toFixed ( 2 ) } ` ,
252+ `L ${ endPoint . x } ${ endPoint . y } ` ,
253+ `L ${ lowerWing . x . toFixed ( 2 ) } ${ lowerWing . y . toFixed ( 2 ) } ` ,
254+ ] . join ( " " ) ;
147255
148256 return (
149257 < >
@@ -165,14 +273,15 @@ function SidebarArrow() {
165273
166274 { /* Arrow */ }
167275 < motion . div
276+ ref = { containerRef }
168277 className = "pointer-events-none absolute inset-x-0 z-[2] h-[360px]"
169278 style = { { top : "calc(50% - 42px)" } }
170279 initial = { { opacity : 0 } }
171280 animate = { { opacity : 1 } }
172281 transition = { { delay : 0.5 , duration : 0.4 } }
173282 >
174283 < svg
175- viewBox = " 0 0 900 360"
284+ viewBox = { ` 0 0 ${ usableWidth } ${ SIDEBAR_ARROW_HEIGHT } ` }
176285 preserveAspectRatio = "none"
177286 fill = "none"
178287 className = "h-full w-full text-foreground/[0.16]"
@@ -217,6 +326,7 @@ export const WelcomeScreen = memo(function WelcomeScreen({
217326 hasProjects,
218327 onCreateProject,
219328} : WelcomeScreenProps ) {
329+ const subtitleRef = useRef < HTMLParagraphElement | null > ( null ) ;
220330
221331 // --- No projects state ---
222332 if ( ! hasProjects ) {
@@ -284,7 +394,7 @@ export const WelcomeScreen = memo(function WelcomeScreen({
284394 />
285395
286396 { /* Hand-drawn arrow from center to sidebar edge */ }
287- < SidebarArrow />
397+ < SidebarArrow anchorRef = { subtitleRef } />
288398
289399 { /* Central content */ }
290400 < div className = "relative z-10 flex flex-1 flex-col items-center justify-center px-6" >
@@ -307,7 +417,10 @@ export const WelcomeScreen = memo(function WelcomeScreen({
307417 >
308418 Continue building
309419 </ h1 >
310- < p className = "max-w-[320px] text-center text-base leading-relaxed text-muted-foreground" >
420+ < p
421+ ref = { subtitleRef }
422+ className = "max-w-[320px] text-center text-base leading-relaxed text-muted-foreground"
423+ >
311424 Pick up an existing thread or start fresh.
312425 </ p >
313426 </ motion . div >
0 commit comments