Skip to content

Commit 1a8065a

Browse files
committed
Singapore schedule: float speaker card into description (xl+)
On xl screens with a single speaker, float the speaker card into the bottom-right of the description so prose flows around it. Below xl, or when the session has multiple speakers, the card stays in the "speakers below" row as before. Implementation: - Parse the FOST description HTML into individual <p> blocks rendered as React siblings — shape-outside only takes effect when the float shares a block formatting context with the surrounding text, so the prior single dangerouslySetInnerHTML wrapper wouldn't have worked. - The float box uses pt-[170px] + shape-outside: inset(170px 0 0 0) + shape-margin: 16px so the card visually anchors near the bottom of a ~4-paragraph description while text fills the top-right whitespace. - Hide the lower SessionSpeakers row at xl when the card is floated to avoid duplication.
1 parent 6647489 commit 1a8065a

1 file changed

Lines changed: 53 additions & 7 deletions

File tree

src/app/day/2026/singapore/schedule-section.tsx

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
99145
function SessionHeader({
100146
session,
101147
className,

0 commit comments

Comments
 (0)