@@ -68,34 +68,80 @@ function SessionBlock({
6868 session : SingaporeSession
6969 isFirst : boolean
7070} ) {
71+ // On xl+ with a single speaker we float the card into the bottom-right of
72+ // the description so prose flows around it. Multi-speaker sessions keep
73+ // the regular "speakers below" layout at every breakpoint.
74+ const floatSpeaker =
75+ session . speakers . length === 1 ? session . speakers [ 0 ] : null
76+
7177 return (
7278 < article >
7379 < Hr className = { isFirst ? "mt-8 lg:mt-12" : "mt-12 lg:mt-16" } />
7480 < SessionHeader session = { session } className = "px-2 pt-8 sm:px-3 lg:pt-12" />
7581 { session . description && (
7682 < >
7783 < Hr className = "mt-10 2xl:mt-16" />
78- < div
79- className = "typography-body-lg mt-8 flex flex-col gap-4 px-2 pb-8 sm:px-3 lg:mt-12 xl:pb-12 [&_a]:break-words"
80- dangerouslySetInnerHTML = { {
81- __html : formatDescription ( session . description ) ,
82- } }
84+ < SessionDescription
85+ description = { session . description }
86+ floatSpeaker = { floatSpeaker }
8387 />
8488 </ >
8589 ) }
8690 { session . speakers . length > 0 && (
87- < >
91+ < div className = { floatSpeaker ? "xl:hidden" : undefined } >
8892 < Hr />
8993 < SessionSpeakers
9094 speakers = { session . speakers }
9195 className = "-mx-px -mb-px"
9296 />
93- </ >
97+ </ div >
9498 ) }
9599 </ article >
96100 )
97101}
98102
103+ function SessionDescription ( {
104+ description,
105+ floatSpeaker,
106+ } : {
107+ description : string
108+ floatSpeaker : SingaporeSpeaker | null
109+ } ) {
110+ const paragraphs = parseParagraphs ( description )
111+
112+ return (
113+ < div className = "typography-body-lg mt-8 px-2 pb-8 sm:px-3 lg:mt-12 xl:pb-12 [&>p+p]:mt-4 [&_a]:break-words" >
114+ { floatSpeaker && (
115+ < div
116+ className = "hidden xl:float-right xl:ml-6 xl:block xl:w-[340px] xl:pt-[170px]"
117+ style = { {
118+ shapeOutside : "inset(170px 0 0 0)" ,
119+ shapeMargin : "16px" ,
120+ } }
121+ >
122+ < SpeakerCard speaker = { floatSpeaker } index = { 0 } />
123+ </ div >
124+ ) }
125+ { paragraphs . map ( ( html , i ) => (
126+ < p key = { i } dangerouslySetInnerHTML = { { __html : html } } />
127+ ) ) }
128+ </ div >
129+ )
130+ }
131+
132+ /**
133+ * Split FOST description HTML (a sequence of `<p>...</p>` blocks) into the
134+ * inner HTML of each paragraph so we can render them as real React `<p>`
135+ * siblings — needed because `shape-outside` only takes effect when the float
136+ * shares a block formatting context with the surrounding text.
137+ */
138+ function parseParagraphs ( html : string ) : string [ ] {
139+ const formatted = formatDescription ( html )
140+ const matches = formatted . match ( / < p > [ \s \S ] * ?< \/ p > / g)
141+ if ( ! matches ) return [ formatted ]
142+ return matches . map ( p => p . replace ( / ^ < p > / , "" ) . replace ( / < \/ p > $ / , "" ) )
143+ }
144+
99145function SessionHeader ( {
100146 session,
101147 className,
0 commit comments