@breathe-ds/ui 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,341 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState, type ElementType, type ReactNode } from "react";
4
+ import { initials } from "./Avatar";
5
+ import { ProfessionalModal, type ProfessionalModalLabels, type ProfessionalPresentation } from "./ProfessionalModal";
6
+ import { CollapseRow, LinkRow, RowList } from "./Rows";
7
+
8
+ export type ProfessionalPhoto = {
9
+ src: string;
10
+ /** Default: `labels.portrait(name)` ("Portrait of {name}"). */
11
+ alt?: string;
12
+ srcSet?: string;
13
+ /** Default: one of three columns from lg, two from sm, full width on phones. */
14
+ sizes?: string;
15
+ };
16
+
17
+ export type ProfessionalLiving = {
18
+ /** Short muted clip, back-and-forth (first frame == last frame). */
19
+ src: string;
20
+ /** The clip's first frame: it is the still portrait. Same frame the clip
21
+ * starts and ends on, so there is no jump when it plays or stops. */
22
+ poster: string;
23
+ };
24
+
25
+ export type ProfessionalGuidance = { label: ReactNode; href: string };
26
+
27
+ export type ProfessionalCardLabels = ProfessionalModalLabels & {
28
+ /** alt of the portrait. */
29
+ portrait: (name: string) => string;
30
+ trajectory: string;
31
+ /** The row of the guidance the person signs; the count goes beside it. */
32
+ guidance: string;
33
+ /** sr-only, right after "Trajectory" and "Guidance": " of {name}". */
34
+ of: (name: string) => string;
35
+ instagram: string;
36
+ /** sr-only, right after "Instagram". */
37
+ instagramOf: (name: string) => string;
38
+ };
39
+
40
+ const EN: Omit<ProfessionalCardLabels, keyof ProfessionalModalLabels> = {
41
+ portrait: (name) => `Portrait of ${name}`,
42
+ trajectory: "Trajectory",
43
+ guidance: "Guidance",
44
+ of: (name) => ` of ${name}`,
45
+ instagram: "Instagram",
46
+ instagramOf: (name) => ` of ${name} (opens in a new tab)`,
47
+ };
48
+
49
+ const SIZES = "(min-width: 1024px) 30vw, (min-width: 640px) 45vw, 92vw";
50
+
51
+ /** The photo zooms in a little with the mouse over the card, as on the home. */
52
+ const ZOOM =
53
+ "aspect-[4/5] h-auto w-full object-cover transition-transform duration-500 ease-[var(--ease-cg)] motion-reduce:transition-none";
54
+ const ZOOM_CARD = `${ZOOM} [@media(hover:hover)_and_(pointer:fine)]:group-hover/card:scale-[1.03] motion-reduce:[@media(hover:hover)_and_(pointer:fine)]:group-hover/card:scale-100`;
55
+ /* inside the modal trigger only the photo button is the hover target */
56
+ const ZOOM_PHOTO = `${ZOOM} [@media(hover:hover)_and_(pointer:fine)]:group-hover/photo:scale-[1.03] motion-reduce:[@media(hover:hover)_and_(pointer:fine)]:group-hover/photo:scale-100`;
57
+
58
+ type LivingProps = ProfessionalLiving & {
59
+ /** The usual photo. Only shows if the video fails. */
60
+ photo: ProfessionalPhoto;
61
+ /** Replaces the fallback <img> (e.g. next/image). */
62
+ media?: ReactNode;
63
+ /** alt text read when the clip is decoration beside the name (sr-only). */
64
+ alt: string;
65
+ /** Empty alt when the text beside already says who it is. */
66
+ decorative?: boolean;
67
+ className?: string;
68
+ };
69
+
70
+ /**
71
+ * Portrait that comes alive with the mouse over the card — novo.creeta's
72
+ * `FotoViva` (components/foto-viva.tsx).
73
+ *
74
+ * What is seen still is the poster, the clip's own first frame. With the
75
+ * mouse over, the clip loops. On leave it does not cut: it finishes the way
76
+ * back to the first frame. The clip goes there and back, so the frame at t is
77
+ * the one at (duration − t): if the mouse leaves on the way out, it jumps to
78
+ * the mirrored point (same image) and plays only the way back.
79
+ *
80
+ * Only with a fine pointer (desktop); on touch the poster stays, because the
81
+ * tap on the photo already has a job (opens the presentation of who has one).
82
+ * People who asked for less motion also keep the poster. The clip only
83
+ * downloads on the first hover. If the video fails, the photo takes over.
84
+ *
85
+ * The hover target is the whole card: the closest `[data-cg-professional-card]`,
86
+ * <article> or <a>, else the parent.
87
+ */
88
+ export function LivingPortrait({ src, poster, photo, media, alt, decorative = false, className = "" }: LivingProps) {
89
+ const video = useRef<HTMLVideoElement>(null);
90
+ const [failed, setFailed] = useState(false);
91
+
92
+ useEffect(() => {
93
+ const v = video.current;
94
+ if (!v) return;
95
+ const card = v.closest("[data-cg-professional-card], article, a") ?? v.parentElement;
96
+ if (!card) return;
97
+ const canAnimate = () =>
98
+ typeof window.matchMedia === "function" &&
99
+ window.matchMedia("(hover: hover) and (pointer: fine)").matches &&
100
+ !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
101
+ const enter = () => {
102
+ if (!canAnimate()) return;
103
+ v.loop = true;
104
+ v.play().catch(() => {});
105
+ };
106
+ const leave = () => {
107
+ const d = v.duration;
108
+ if (!Number.isFinite(d) || d === 0) {
109
+ // no data yet: nothing to finish
110
+ v.pause();
111
+ return;
112
+ }
113
+ if (v.paused) return;
114
+ if (v.currentTime < d / 2) v.currentTime = d - v.currentTime;
115
+ v.loop = false;
116
+ };
117
+ // reached the end of the way back: the last frame equals the first
118
+ const onEnded = () => {
119
+ v.currentTime = 0;
120
+ v.loop = true;
121
+ };
122
+ card.addEventListener("pointerenter", enter);
123
+ card.addEventListener("pointerleave", leave);
124
+ v.addEventListener("ended", onEnded);
125
+ return () => {
126
+ card.removeEventListener("pointerenter", enter);
127
+ card.removeEventListener("pointerleave", leave);
128
+ v.removeEventListener("ended", onEnded);
129
+ };
130
+ }, []);
131
+
132
+ if (failed) {
133
+ return (
134
+ <span data-portrait="living" className={`relative block ${className}`}>
135
+ {media ?? (
136
+ <img
137
+ src={photo.src}
138
+ srcSet={photo.srcSet}
139
+ sizes={photo.sizes ?? SIZES}
140
+ alt={decorative ? "" : alt}
141
+ loading="lazy"
142
+ decoding="async"
143
+ className="absolute inset-0 h-full w-full object-cover"
144
+ />
145
+ )}
146
+ </span>
147
+ );
148
+ }
149
+
150
+ return (
151
+ <span data-portrait="living" className={`relative block ${className}`}>
152
+ <video
153
+ ref={video}
154
+ src={src}
155
+ poster={poster}
156
+ muted
157
+ loop
158
+ playsInline
159
+ preload="none"
160
+ aria-hidden="true"
161
+ tabIndex={-1}
162
+ onError={() => setFailed(true)}
163
+ className="absolute inset-0 h-full w-full object-cover"
164
+ />
165
+ {!decorative && <span className="sr-only">{alt}</span>}
166
+ </span>
167
+ );
168
+ }
169
+
170
+ type PortraitProps = {
171
+ name: string;
172
+ photo?: ProfessionalPhoto;
173
+ media?: ReactNode;
174
+ living?: ProfessionalLiving;
175
+ alt: string;
176
+ decorative?: boolean;
177
+ className: string;
178
+ };
179
+
180
+ /** The card's portrait: living clip, custom media, or the plain <img>. */
181
+ function Portrait({ photo, media, living, alt, decorative = false, className }: PortraitProps) {
182
+ if (living) {
183
+ return (
184
+ <LivingPortrait
185
+ src={living.src}
186
+ poster={living.poster}
187
+ photo={photo ?? { src: living.poster }}
188
+ media={media}
189
+ alt={alt}
190
+ decorative={decorative}
191
+ className={className}
192
+ />
193
+ );
194
+ }
195
+ if (media) return <>{media}</>;
196
+ if (!photo) return null;
197
+ return (
198
+ <img
199
+ src={photo.src}
200
+ srcSet={photo.srcSet}
201
+ sizes={photo.sizes ?? SIZES}
202
+ alt={decorative ? "" : alt}
203
+ loading="lazy"
204
+ decoding="async"
205
+ className={className}
206
+ />
207
+ );
208
+ }
209
+
210
+ type Props = {
211
+ name: string;
212
+ /** Second line under the name (the profession), in `ink-soft`. */
213
+ role?: ReactNode;
214
+ /** Registration line (CREFITO). The family rule: a health professional
215
+ * always shows the council number. */
216
+ registration?: ReactNode;
217
+ /** The still portrait (3:4 source, shown 4:5). Without it (and without
218
+ * `media`/`living`), the initials in a tinted bubble. */
219
+ photo?: ProfessionalPhoto;
220
+ /** Your own image element (e.g. next/image) in place of `photo`. Give it
221
+ * the box yourself (`aspect-[4/5] w-full object-cover`). */
222
+ media?: ReactNode;
223
+ /** Living portrait: the clip plays muted on hover; `photo` is the fallback. */
224
+ living?: ProfessionalLiving;
225
+ /** With a presentation video the portrait opens the modal (ProfessionalModal). */
226
+ presentation?: ProfessionalPresentation;
227
+ /** The bio: a string (one paragraph) or your own paragraphs. Opens in place
228
+ * under "Trajectory". */
229
+ bio?: ReactNode;
230
+ /** The guidance pages the person signs: the row lists them, with the count. */
231
+ guidance?: ProfessionalGuidance[];
232
+ /** Instagram profile URL (outbound row). */
233
+ instagram?: string;
234
+ /** Anchor of the card (the authorship link of a guidance page lands here). */
235
+ id?: string;
236
+ /** h2 on the team page (default), h3 inside a section that already has an h2. */
237
+ headingLevel?: "h2" | "h3";
238
+ /** Root element. Default <article>. */
239
+ as?: ElementType;
240
+ /** Link component of the app for the guidance links (e.g. next-intl Link). Default: <a>. */
241
+ linkComponent?: ElementType;
242
+ labels?: Partial<ProfessionalCardLabels>;
243
+ className?: string;
244
+ };
245
+
246
+ /**
247
+ * Team card of the Cuidado Global family — novo.creeta's `ProfessionalCard`
248
+ * (components/professional-card.tsx) with the Breathe tokens. A `card`
249
+ * surface with the portrait on top (plain, living, or opening the video
250
+ * presentation), the name, role and registration, then one list of equal
251
+ * rows separated by hairlines: Trajectory (opens the bio in place), Guidance
252
+ * (opens the links in place) and Instagram (outbound). Six open bios used to
253
+ * make the page 6,700px tall on a phone, hence the rows.
254
+ *
255
+ * The Guidance row carries `data-cg-open-on-arrival`: the site opens it when
256
+ * the page is reached by the card's anchor (creeta's `abre-ao-chegar.tsx`,
257
+ * not part of the DS).
258
+ */
259
+ export function ProfessionalCard({
260
+ name,
261
+ role,
262
+ registration,
263
+ photo,
264
+ media,
265
+ living,
266
+ presentation,
267
+ bio,
268
+ guidance,
269
+ instagram,
270
+ id,
271
+ headingLevel: Heading = "h2",
272
+ as: As = "article",
273
+ linkComponent,
274
+ labels,
275
+ className = "",
276
+ }: Props) {
277
+ const t = { ...EN, ...labels };
278
+ const L: ElementType = linkComponent ?? "a";
279
+ const alt = photo?.alt ?? t.portrait(name);
280
+ const hasPortrait = Boolean(photo || media || living);
281
+
282
+ return (
283
+ <As id={id} data-cg-professional-card="" className={`group/card scroll-mt-24 rounded-2xl bg-card p-3 pb-6 ${className}`}>
284
+ {hasPortrait && presentation ? (
285
+ // with a presentation video, the photo opens the modal
286
+ <ProfessionalModal
287
+ name={name}
288
+ role={role}
289
+ registration={registration}
290
+ bio={bio}
291
+ presentation={presentation}
292
+ labels={labels}
293
+ portrait={<Portrait name={name} photo={photo} media={media} living={living} alt={alt} decorative className={ZOOM_PHOTO} />}
294
+ />
295
+ ) : hasPortrait ? (
296
+ <span className="block overflow-hidden rounded-[20px]">
297
+ <Portrait name={name} photo={photo} media={media} living={living} alt={alt} className={ZOOM_CARD} />
298
+ </span>
299
+ ) : (
300
+ <div
301
+ data-testid="initials"
302
+ aria-hidden="true"
303
+ className="flex aspect-[3/4] w-full items-center justify-center rounded-[20px] bg-ink/[0.12]"
304
+ >
305
+ <span className="font-heading text-4xl font-bold text-ink">{initials(name)}</span>
306
+ </div>
307
+ )}
308
+ <div className="px-3 pt-5">
309
+ <Heading className="font-heading text-xl font-medium leading-snug text-ink">{name}</Heading>
310
+ {role && <p className="mt-1 text-sm font-semibold text-ink-soft">{role}</p>}
311
+ {registration && (
312
+ <>
313
+ {/* /80 = 5.78:1 on white (creeta) — the CREFITO has a legal
314
+ visibility duty (COFFITO 414/2012); /60 gave 3.37:1 and failed AA. */}
315
+ <p className="mt-1 text-xs text-ink/80">{registration}</p>
316
+ </>
317
+ )}
318
+ {/* Trajectory, guidance and Instagram: one list, of equal rows. They
319
+ used to be three things with three designs (an open button, a loose
320
+ list of links and a magenta link at the end). The first two open in
321
+ place: six open bios made 6,700px of page on a phone. */}
322
+ <RowList className="mt-4">
323
+ {bio && (
324
+ <CollapseRow label={t.trajectory} srLabel={t.of(name)}>
325
+ {bio}
326
+ </CollapseRow>
327
+ )}
328
+
329
+ {/* What the person signs: it is what makes this card the destination
330
+ of the authorship link of the guidance pages. Whoever arrives by
331
+ that link finds this block open (site-side, data-cg-open-on-arrival). */}
332
+ {guidance && guidance.length > 0 && (
333
+ <CollapseRow label={t.guidance} srLabel={t.of(name)} links={guidance} linkComponent={L} data-cg-open-on-arrival="" />
334
+ )}
335
+
336
+ {instagram && <LinkRow label={t.instagram} srLabel={t.instagramOf(name)} href={instagram} external />}
337
+ </RowList>
338
+ </div>
339
+ </As>
340
+ );
341
+ }
@@ -0,0 +1,243 @@
1
+ "use client";
2
+
3
+ import { useEffect, useId, useRef, useState, type ReactNode } from "react";
4
+ import { Play, X } from "lucide-react";
5
+ import { MediaCardBadge } from "./MediaCard";
6
+ import { CaptionBand, useCaption } from "./CaptionBand";
7
+ import { formatDuration } from "./VideoStoryCard";
8
+
9
+ export type ProfessionalCaptions = {
10
+ /** WebVTT file in the page's language. With it, the current line is shown in
11
+ * the CaptionBand at the top of the text column (never over the picture). */
12
+ src: string;
13
+ /** BCP 47 tag of the captions ("en", "pt-BR"). */
14
+ srcLang: string;
15
+ /** Name of the language, for the browser's captions menu. */
16
+ label?: string;
17
+ };
18
+
19
+ export type ProfessionalPresentation = {
20
+ /** The presentation video, recorded for the clinic's Instagram. Downloaded
21
+ * only when the modal opens; plays with sound (it was a tap by the person). */
22
+ src: string;
23
+ poster?: string;
24
+ captions?: ProfessionalCaptions;
25
+ /** Seconds (formatted as "1 min 15 s"), or an already formatted string for
26
+ * other languages. Goes into the sr-only text of the trigger. */
27
+ duration?: number | string;
28
+ };
29
+
30
+ export type ProfessionalModalLabels = {
31
+ /** The chip over the portrait: says the photo opens a video. */
32
+ presentation: string;
33
+ /** sr-only text of the trigger. `duration` is undefined when none was given. */
34
+ watch: (firstName: string, duration?: string) => string;
35
+ /** aria-label of the <video>. */
36
+ video: (firstName: string) => string;
37
+ close: string;
38
+ };
39
+
40
+ /** Lucide's icon type is wider than MediaCardBadge's `IconType` under strict
41
+ * TS (propTypes allow a string `size`); this thin wrapper fits the slot. */
42
+ const PlayIcon = (p: { size?: number; strokeWidth?: number; className?: string; "aria-hidden"?: boolean | "true" }) => <Play {...p} />;
43
+
44
+ const EN: ProfessionalModalLabels = {
45
+ presentation: "Presentation",
46
+ watch: (firstName, duration) => `Watch ${firstName}'s video presentation${duration ? ` (${duration})` : ""}`,
47
+ video: (firstName) => `${firstName}'s video presentation`,
48
+ close: "Close",
49
+ };
50
+
51
+ type Props = {
52
+ name: string;
53
+ /** Second line under the name (the profession). */
54
+ role?: ReactNode;
55
+ /** Registration line (CREFITO). Kept at /80: it has a legal visibility duty
56
+ * (COFFITO 414/2012). */
57
+ registration?: ReactNode;
58
+ /** Full bio: a string (one paragraph) or your own paragraphs. */
59
+ bio?: ReactNode;
60
+ presentation: ProfessionalPresentation;
61
+ /** The portrait the trigger shows (plain photo or living portrait), rendered
62
+ * by the card so both share one media contract. Decorative: the sr-only
63
+ * text of the button already says who it is. */
64
+ portrait: ReactNode;
65
+ labels?: Partial<ProfessionalModalLabels>;
66
+ };
67
+
68
+ /**
69
+ * Team portrait that opens the video presentation in a modal — novo.creeta's
70
+ * `Apresentacao` (components/apresentacao.tsx) with the Breathe tokens.
71
+ *
72
+ * - The portrait is a button with a small play chip in the corner. On tap, the
73
+ * modal grows out of the photo's place (FLIP: starts with the photo's scale
74
+ * and position, transform and opacity only) up to the centre. On close it
75
+ * goes back the same way.
76
+ * - It is a native <dialog>: focus trapped, Esc closes, focus returns to the
77
+ * button, dark blurred backdrop. Body scroll is locked by the browser's
78
+ * modal layer (`showModal()`), nothing else to do.
79
+ * - The video is only downloaded when the modal opens, and plays with sound
80
+ * (it was a tap by the person). Under reduced motion it just appears.
81
+ * - With `captions`, the current line is shown in the CaptionBand that opens
82
+ * the text column: under the video on phones, beside it from `md`.
83
+ */
84
+ export function ProfessionalModal({ name, role, registration, bio, presentation, portrait, labels }: Props) {
85
+ const t = { ...EN, ...labels };
86
+ const trigger = useRef<HTMLButtonElement>(null);
87
+ const box = useRef<HTMLDialogElement>(null);
88
+ const video = useRef<HTMLVideoElement>(null);
89
+ const [open, setOpen] = useState(false);
90
+ const headingId = useId();
91
+ const captions = presentation.captions;
92
+ const { line, attach } = useCaption();
93
+ const firstName = name.split(" ")[0];
94
+ const duration =
95
+ typeof presentation.duration === "number" ? formatDuration(presentation.duration) : presentation.duration;
96
+
97
+ // Where the modal is born from: the difference between the photo and the box, as a transform.
98
+ function origin() {
99
+ const d = box.current;
100
+ const f = trigger.current;
101
+ if (!d || !f) return;
102
+ const a = f.getBoundingClientRect();
103
+ const b = d.getBoundingClientRect();
104
+ d.style.setProperty("--dx", `${a.left + a.width / 2 - (b.left + b.width / 2)}px`);
105
+ d.style.setProperty("--dy", `${a.top + a.height / 2 - (b.top + b.height / 2)}px`);
106
+ d.style.setProperty("--sx", `${a.width / b.width}`);
107
+ d.style.setProperty("--sy", `${a.height / b.height}`);
108
+ }
109
+
110
+ function show() {
111
+ const d = box.current;
112
+ if (!d) return;
113
+ setOpen(true);
114
+ d.showModal();
115
+ // origin() reads the layout, which flushes the closed styles; the attribute
116
+ // set right after is what the transition runs from.
117
+ origin();
118
+ d.setAttribute("data-open", "");
119
+ }
120
+
121
+ // The <video> is only mounted while the modal is open: this effect presses
122
+ // play, once it exists. The tap on the photo counts as the person's
123
+ // gesture, so it plays with sound.
124
+ useEffect(() => {
125
+ if (!open) return;
126
+ const v = video.current;
127
+ if (!v) return;
128
+ v.play().catch(() => {});
129
+ }, [open]);
130
+
131
+ function close() {
132
+ const d = box.current;
133
+ if (!d) return;
134
+ video.current?.pause();
135
+ origin();
136
+ d.removeAttribute("data-open");
137
+ // wait for the way back to finish before taking it off the screen
138
+ const wait = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ? 0 : 260;
139
+ window.setTimeout(() => {
140
+ d.close();
141
+ setOpen(false);
142
+ }, wait);
143
+ }
144
+
145
+ // Esc and click on the backdrop: close with the same animation.
146
+ useEffect(() => {
147
+ const d = box.current;
148
+ if (!d) return;
149
+ const onCancel = (e: Event) => {
150
+ e.preventDefault();
151
+ close();
152
+ };
153
+ const onClick = (e: MouseEvent) => {
154
+ if (e.target === d) close();
155
+ };
156
+ d.addEventListener("cancel", onCancel);
157
+ d.addEventListener("click", onClick);
158
+ return () => {
159
+ d.removeEventListener("cancel", onCancel);
160
+ d.removeEventListener("click", onClick);
161
+ };
162
+ });
163
+
164
+ return (
165
+ <>
166
+ <button
167
+ ref={trigger}
168
+ type="button"
169
+ onClick={show}
170
+ className="group/photo relative block w-full overflow-hidden rounded-[20px] text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink"
171
+ >
172
+ {portrait}
173
+ {/* the play chip in the corner: says the photo opens a video */}
174
+ <span
175
+ aria-hidden="true"
176
+ className="absolute bottom-3 right-3 transition-transform duration-200 ease-out group-active/photo:scale-95 motion-reduce:transition-none"
177
+ >
178
+ <MediaCardBadge Icon={PlayIcon}>{t.presentation}</MediaCardBadge>
179
+ </span>
180
+ <span className="sr-only">{t.watch(firstName, duration)}</span>
181
+ </button>
182
+
183
+ {/* The <dialog> is born from the photo's place: --dx/--dy/--sx/--sy are
184
+ the photo's position and scale relative to the box, computed on open.
185
+ Transform and opacity only; `data-open` runs the transition, closing
186
+ runs it back. Under reduced motion: a short fade, no transform. */}
187
+ <dialog
188
+ ref={box}
189
+ aria-labelledby={headingId}
190
+ className="m-auto max-h-[calc(100dvh-1.5rem)] w-[min(100vw-1.5rem,64rem)] overflow-y-auto rounded-[32px] bg-card p-0 text-ink shadow-2xl md:rounded-[40px] opacity-0 [transform:translate(var(--dx,0),var(--dy,0))_scale(var(--sx,0.9),var(--sy,0.9))] [transition:transform_320ms_var(--ease-cg),opacity_200ms_ease-out] data-open:opacity-100 data-open:transform-none motion-reduce:transform-none motion-reduce:[transition:opacity_150ms_ease] backdrop:bg-ink/60 backdrop:backdrop-blur-md backdrop:opacity-0 backdrop:[transition:opacity_240ms_ease-out] [&[data-open]::backdrop]:opacity-100"
191
+ >
192
+ <div className="grid md:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
193
+ {/* on phones the box is shorter than 9:16: the video fits whole
194
+ (object-contain), without cutting the head */}
195
+ <div className="relative aspect-[9/16] max-h-[55dvh] w-full bg-ink md:max-h-none">
196
+ {open && (
197
+ <video
198
+ ref={video}
199
+ src={presentation.src}
200
+ poster={presentation.poster}
201
+ controls
202
+ playsInline
203
+ aria-label={t.video(firstName)}
204
+ className="absolute inset-0 h-full w-full object-contain md:object-cover"
205
+ >
206
+ {captions && (
207
+ <track ref={attach} kind="subtitles" src={captions.src} srcLang={captions.srcLang} label={captions.label} default />
208
+ )}
209
+ </video>
210
+ )}
211
+ </div>
212
+ <div className="flex flex-col p-6 md:p-10">
213
+ {/* the spoken line, in the page's language: opens the text column,
214
+ which sits right under the video on phones and beside it on desktop */}
215
+ {open && captions && (
216
+ <div className="-mt-2 mb-5 md:mr-12">
217
+ <CaptionBand text={line} lang={captions.srcLang} />
218
+ </div>
219
+ )}
220
+ <h2 id={headingId} className="font-heading text-2xl font-medium leading-snug md:text-[2rem]">
221
+ {name}
222
+ </h2>
223
+ {role && <p className="mt-1 text-sm font-semibold text-ink-soft">{role}</p>}
224
+ {registration && <p className="mt-1 text-xs text-ink/80">{registration}</p>}
225
+ {typeof bio === "string" ? (
226
+ <p className="mt-5 leading-relaxed text-ink/80">{bio}</p>
227
+ ) : (
228
+ bio && <div className="mt-5 space-y-3 leading-relaxed text-ink/80">{bio}</div>
229
+ )}
230
+ </div>
231
+ </div>
232
+ <button
233
+ type="button"
234
+ onClick={close}
235
+ className="absolute right-4 top-4 flex h-11 w-11 items-center justify-center rounded-full bg-ink text-white transition-transform duration-150 ease-out hover:bg-ink/90 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white motion-reduce:transition-none"
236
+ >
237
+ <X aria-hidden="true" size={20} strokeWidth={2} />
238
+ <span className="sr-only">{t.close}</span>
239
+ </button>
240
+ </dialog>
241
+ </>
242
+ );
243
+ }