@breathe-ds/ui 0.3.0 → 0.5.1

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,99 @@
1
+ import type { CSSProperties } from "react";
2
+ import { Pause, Play } from "lucide-react";
3
+
4
+ export type SequencePhoto = { src: string; alt: string; srcSet?: string; sizes?: string };
5
+
6
+ type Labels = {
7
+ /** sr-only of the pause control. */
8
+ pause: string;
9
+ };
10
+ const EN: Labels = { pause: "Pause the photos" };
11
+
12
+ type Props = {
13
+ photos: SequencePhoto[];
14
+ /** Unique in the page: ties the pause control to the sequence. */
15
+ id: string;
16
+ /** Seconds each photo stays, not counting the fade. */
17
+ seconds?: number;
18
+ labels?: Partial<Labels>;
19
+ className?: string;
20
+ };
21
+
22
+ /** Seconds of fade between one photo and the next. */
23
+ const FADE = 1;
24
+ /** Ken Burns: how much the photo grows while it shows. */
25
+ const ZOOM = 1.08;
26
+ const CURVE = "cubic-bezier(0.77, 0, 0.175, 1)";
27
+ const pct = (n: number) => `${Number(n.toFixed(3))}%`;
28
+
29
+ /** Keyframes for a sequence of `n` photos. The cycle lasts n × seconds and
30
+ * each photo owns its slice: middle ones fade in, stay and vanish at once
31
+ * when the next has covered them; the last fades out revealing the first;
32
+ * the zoom grows while the photo shows and resets while hidden. */
33
+ export function sequenceKeyframes(n: number, seconds: number): string {
34
+ const cycle = n * seconds;
35
+ const enter = (FADE / cycle) * 100;
36
+ const stay = (seconds / cycle) * 100;
37
+ const leave = ((seconds + FADE) / cycle) * 100;
38
+ return [
39
+ `@keyframes cg-seq-mid-${n}{0%{opacity:0}${pct(enter)}{opacity:1}${pct(leave)}{opacity:1}${pct(leave + 0.01)},100%{opacity:0}}`,
40
+ `@keyframes cg-seq-end-${n}{0%{opacity:0}${pct(enter)}{opacity:1}${pct(stay)}{opacity:1}${pct(leave)},100%{opacity:0}}`,
41
+ `@keyframes cg-seq-zoom-${n}{0%{transform:scale(1)}${pct(leave)}{transform:scale(${ZOOM})}${pct(leave + 0.01)},100%{transform:scale(1)}}`,
42
+ ].join("");
43
+ }
44
+
45
+ /** Photos that alternate with a fade and slowly zoom in, no JavaScript
46
+ * (creeta's `SequenciaFotos`). Only opacity and transform. Content that moves
47
+ * by itself for more than 5s must be pausable (WCAG 2.2.2): the hidden
48
+ * checkbox does it through CSS (theme.css). Reduced motion: first photo, still. */
49
+ export function PhotoSequence({ photos, id, seconds = 4, labels, className = "" }: Props) {
50
+ const t = { ...EN, ...labels };
51
+ const n = photos.length;
52
+ const cycle = n * seconds;
53
+
54
+ const animation = (i: number): CSSProperties => {
55
+ const zoom = `cg-seq-zoom-${n} ${cycle}s linear infinite`;
56
+ if (i === 0) return { animation: zoom };
57
+ const fade = `${i === n - 1 ? `cg-seq-end-${n}` : `cg-seq-mid-${n}`} ${cycle}s ${CURVE} infinite`;
58
+ return { animation: `${fade}, ${zoom}`, animationDelay: `${i * seconds}s` };
59
+ };
60
+
61
+ // position and radius are defaults: a className with `absolute` or `rounded-*`
62
+ // (filling a CareSteps frame) takes their place instead of fighting them
63
+ const position = /\babsolute\b|\bfixed\b/.test(className) ? "" : "relative";
64
+ const radius = /\brounded/.test(className) ? "" : "rounded-[28px]";
65
+ return (
66
+ <div className={`${position} ${radius} overflow-hidden bg-ink ${className}`}>
67
+ {n > 1 && <style dangerouslySetInnerHTML={{ __html: sequenceKeyframes(n, seconds) }} />}
68
+ <input id={id} type="checkbox" className="cg-seq-pause peer sr-only" />
69
+
70
+ <div className="cg-seq absolute inset-0">
71
+ {photos.map((p, i) => (
72
+ // eslint-disable-next-line @next/next/no-img-element
73
+ <img
74
+ key={p.src}
75
+ src={p.src}
76
+ srcSet={p.srcSet}
77
+ sizes={p.sizes}
78
+ alt={p.alt}
79
+ loading="lazy"
80
+ decoding="async"
81
+ style={n > 1 ? animation(i) : undefined}
82
+ className="absolute inset-0 h-full w-full object-cover"
83
+ />
84
+ ))}
85
+ </div>
86
+
87
+ {n > 1 && (
88
+ <label
89
+ htmlFor={id}
90
+ className="cg-seq-control absolute bottom-3 right-3 flex h-11 w-11 cursor-pointer items-center justify-center rounded-full bg-ink/85 text-surface backdrop-blur-md peer-checked:[&_.cg-on-pause]:block peer-checked:[&_.cg-on-play]:hidden peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-surface"
91
+ >
92
+ <Pause aria-hidden="true" size={18} strokeWidth={2} className="cg-on-play" />
93
+ <Play aria-hidden="true" size={18} strokeWidth={2} className="cg-on-pause ml-0.5 hidden" />
94
+ <span className="sr-only">{t.pause}</span>
95
+ </label>
96
+ )}
97
+ </div>
98
+ );
99
+ }
@@ -0,0 +1,66 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useOnAppear } from "./useOnAppear";
5
+ import { PauseButton } from "./PauseButton";
6
+
7
+ type Labels = {
8
+ /** Completes the pause button's name: "Pause the plan animation". */
9
+ pause: string;
10
+ };
11
+ const EN: Labels = { pause: "the plan animation" };
12
+
13
+ type Props = {
14
+ /** The three stages, short ("Short", "Medium", "Long"). Three: the keyframes
15
+ * in theme.css light one dot at a time, and there are three sets. */
16
+ names: [string, string, string];
17
+ /** Only from md, glued to the name: " term" → "Short term". */
18
+ suffix?: string;
19
+ labels?: Partial<Labels>;
20
+ className?: string;
21
+ };
22
+
23
+ /**
24
+ * Step 3 of the care flow (creeta's `Prazos`): the three stages of the plan.
25
+ * The line comes down and each dot lights up when it reaches it; after a rest
26
+ * everything goes off and restarts (6s loop, keyframes in theme.css).
27
+ * - Starts when on screen and only runs while on screen.
28
+ * - Moves by itself for more than 5s: pause button (WCAG 2.2.2).
29
+ * - Without JavaScript or with reduced motion: still, the three dots lit, no button.
30
+ * Fills its box (absolute inset-0): put it in a frame with a height.
31
+ */
32
+ export function PlanTimeline({ names, suffix, labels, className = "" }: Props) {
33
+ const t = { ...EN, ...labels };
34
+ const { ref, state, onScreen } = useOnAppear<HTMLDivElement>();
35
+ const [paused, setPaused] = useState(false);
36
+
37
+ return (
38
+ <div ref={ref} data-state={state} data-stopped={paused || !onScreen ? "" : undefined} className={`cg-plan absolute inset-0 ${className}`}>
39
+ <div aria-hidden="true" className="absolute inset-y-3 left-3 right-0 flex flex-col justify-center rounded-l-2xl bg-card pl-3 md:inset-y-5 md:left-5 md:pl-5">
40
+ {/* the track, light, and over it the line that comes down */}
41
+ <span className="absolute bottom-[22%] left-[17.5px] top-[22%] w-px bg-ink-soft/25 md:left-[25.5px]" />
42
+ <span className="cg-plan-line absolute bottom-[22%] left-[17.5px] top-[22%] w-px origin-top bg-ink-soft md:left-[25.5px]" />
43
+ {names.map((name, i) => (
44
+ <span
45
+ key={name}
46
+ data-n={i + 1}
47
+ className={`cg-plan-step flex flex-1 items-center gap-2.5 text-[0.8125rem] font-medium text-ink md:gap-3.5 md:text-[0.9375rem] ${i > 0 ? "border-t border-ink/10" : ""}`}
48
+ >
49
+ <span className="relative h-3 w-3 shrink-0 rounded-full border-2 border-ink-soft bg-card">
50
+ <span className="cg-plan-dot absolute -inset-0.5 rounded-full bg-ink-soft" />
51
+ </span>
52
+ {/* on the phone the frame is 134px and the pause button takes the corner: name only */}
53
+ <span className="cg-plan-name">
54
+ {name}
55
+ {suffix && <span className="max-md:hidden">{suffix}</span>}
56
+ </span>
57
+ </span>
58
+ ))}
59
+ </div>
60
+
61
+ {state !== "whole" && (
62
+ <PauseButton paused={paused} onToggle={() => setPaused((p) => !p)} what={t.pause} bubble="surface" className="bottom-3 right-0 md:bottom-5 md:right-1" />
63
+ )}
64
+ </div>
65
+ );
66
+ }
@@ -0,0 +1,102 @@
1
+ import type { ReactNode } from "react";
2
+ import type { PostDate } from "./PostCard";
3
+
4
+ type Labels = {
5
+ /** sr-only before the date. */
6
+ publishedOn: string;
7
+ /** sr-only heading of the aside (the video). */
8
+ aside: string;
9
+ };
10
+ const EN: Labels = { publishedOn: "Published on", aside: "Watch" };
11
+
12
+ type Props = {
13
+ /** The trail above the title (a Breadcrumb). */
14
+ trail?: ReactNode;
15
+ title: ReactNode;
16
+ /** Who signs (a PersonChip). */
17
+ signature?: ReactNode;
18
+ date?: PostDate;
19
+ /** The text, one entry per paragraph: the first one is the lead, larger. */
20
+ paragraphs: ReactNode[];
21
+ /** Small line under a hairline at the end of the text ("This text guides, but…"). */
22
+ notice?: ReactNode;
23
+ /** Right column from md, sticky: the video the text came from (a VideoStoryCard). */
24
+ aside?: ReactNode;
25
+ /** The video's speech, in full, open: here it is the body of the page, not an extra. */
26
+ transcript?: { title: ReactNode; note?: ReactNode; paragraphs: ReactNode[]; lang?: string };
27
+ /** After the article: "other posts" (a heading and a grid of PostCard), the final call… */
28
+ children?: ReactNode;
29
+ labels?: Partial<Labels>;
30
+ className?: string;
31
+ };
32
+
33
+ /**
34
+ * Post page of the Cuidado Global family — creeta's `OrientacaoTela`, by
35
+ * tokens. Order: the trail, the title, who signs and when; the text, which
36
+ * answers at once; the video the text came from and, in full, what is said in
37
+ * it. On the phone they come in reading order (text, video, transcript); from
38
+ * md the video goes to the right column, sticky, and follows the reading.
39
+ */
40
+ export function PostArticle({ trail, title, signature, date, paragraphs, notice, aside, transcript, children, labels, className = "" }: Props) {
41
+ const t = { ...EN, ...labels };
42
+ return (
43
+ <article className={className}>
44
+ {trail}
45
+
46
+ <header className="mt-3 max-w-3xl md:mt-5">
47
+ <h1 className="font-heading text-[2rem] font-medium leading-[1.08] tracking-[-0.02em] text-ink md:text-5xl">{title}</h1>
48
+ {(signature || date) && (
49
+ <div className="mt-6 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-ink-body">
50
+ {signature}
51
+ {date && (
52
+ <time dateTime={date.iso}>
53
+ <span className="sr-only">{t.publishedOn} </span>
54
+ {date.label}
55
+ </time>
56
+ )}
57
+ </div>
58
+ )}
59
+ </header>
60
+
61
+ {/* Three pieces in a grid. On the phone in reading order: the text (the
62
+ answer), the video, the transcript. From md the video takes the right
63
+ column and follows the reading of the other two. The two text pieces
64
+ share one column (`contents` on the phone, a stack from md): in two
65
+ grid rows the taller video stretched the first row and opened a gap
66
+ above the transcript (2026-10-29). */}
67
+ <div className="mt-8 grid gap-3 md:mt-10 md:grid-cols-[minmax(0,7fr)_minmax(0,4fr)] md:items-start md:gap-5">
68
+ <div className="contents md:col-start-1 md:row-start-1 md:flex md:flex-col md:gap-5">
69
+ <section className="rounded-[28px] bg-card p-6 md:p-10">
70
+ {paragraphs.map((p, i) => (
71
+ <p key={i} className={i === 0 ? "text-lg leading-relaxed text-ink md:text-xl" : "mt-4 leading-relaxed text-ink-body"}>
72
+ {p}
73
+ </p>
74
+ ))}
75
+ {notice && <p className="mt-6 border-t border-ink/10 pt-4 text-sm text-ink-body">{notice}</p>}
76
+ </section>
77
+
78
+ {transcript && transcript.paragraphs.length > 0 && (
79
+ <section className="rounded-[28px] bg-card p-6 max-md:order-2 md:p-10">
80
+ <h2 className="font-heading text-[1.375rem] font-medium leading-snug text-ink md:text-2xl">{transcript.title}</h2>
81
+ {transcript.note && <p className="mt-2 text-sm font-medium text-ink">{transcript.note}</p>}
82
+ <div lang={transcript.lang} className="mt-4 space-y-4 leading-relaxed text-ink-body">
83
+ {transcript.paragraphs.map((p, i) => (
84
+ <p key={i}>{p}</p>
85
+ ))}
86
+ </div>
87
+ </section>
88
+ )}
89
+ </div>
90
+
91
+ {aside && (
92
+ <aside className="max-md:order-1 md:sticky md:top-28 md:col-start-2 md:row-start-1">
93
+ <h2 className="sr-only">{t.aside}</h2>
94
+ {aside}
95
+ </aside>
96
+ )}
97
+ </div>
98
+
99
+ {children}
100
+ </article>
101
+ );
102
+ }
@@ -0,0 +1,69 @@
1
+ import type { ElementType, ReactNode } from "react";
2
+
3
+ export type PostCover = { src: string; srcSet?: string; sizes?: string };
4
+ export type PostDate = { iso: string; label: ReactNode };
5
+
6
+ type Labels = {
7
+ /** Decorative line at the foot ("Read the guidance"); the link is the title. */
8
+ read: ReactNode;
9
+ };
10
+ const EN: Labels = { read: "Read more" };
11
+
12
+ type Props = {
13
+ /** 4:3 at the top, decorative (the title says the subject). */
14
+ cover: PostCover;
15
+ /** Small line above the title ("Respiratory treatment"). */
16
+ category?: ReactNode;
17
+ title: ReactNode;
18
+ href: string;
19
+ /** The first paragraph, clamped to three lines. */
20
+ excerpt: ReactNode;
21
+ author: ReactNode;
22
+ date: PostDate;
23
+ headingLevel?: "h2" | "h3";
24
+ linkComponent?: ElementType;
25
+ labels?: Partial<Labels>;
26
+ className?: string;
27
+ };
28
+
29
+ const ArrowUpRight = () => (
30
+ <svg aria-hidden="true" viewBox="0 0 24 24" width={16} height={16} className="cg-arrow-go" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
31
+ );
32
+
33
+ /**
34
+ * Post card of the Cuidado Global family — creeta's `OrientacaoCartao`, by
35
+ * tokens: the card of one article in the list and in "other posts". The whole
36
+ * card is clickable (the title's link stretches over it) but the link's name
37
+ * stays the title: that is what the screen reader hears.
38
+ */
39
+ export function PostCard({ cover, category, title, href, excerpt, author, date, headingLevel: Title = "h2", linkComponent, labels, className = "" }: Props) {
40
+ const L: ElementType = linkComponent ?? "a";
41
+ const t = { ...EN, ...labels };
42
+ return (
43
+ <article className={`group relative flex h-full flex-col overflow-hidden rounded-[28px] bg-card transition-transform duration-200 ease-out active:scale-[0.99] motion-reduce:transition-none motion-reduce:active:scale-100 ${className}`}>
44
+ <div className="relative aspect-[4/3] overflow-hidden">
45
+ {/* eslint-disable-next-line @next/next/no-img-element */}
46
+ <img src={cover.src} srcSet={cover.srcSet} sizes={cover.sizes} alt="" loading="lazy" decoding="async" className="absolute inset-0 h-full w-full object-cover object-[50%_30%]" />
47
+ </div>
48
+ <div className="flex flex-1 flex-col p-6 md:p-7">
49
+ {category && <p className="mb-2 text-sm font-semibold text-ink-soft">{category}</p>}
50
+ <Title className="font-heading text-xl font-medium leading-snug text-ink md:text-[1.375rem]">
51
+ <L href={href} className="after:absolute after:inset-0 after:rounded-[28px] focus-visible:outline-none focus-visible:after:outline focus-visible:after:outline-2 focus-visible:after:-outline-offset-2 focus-visible:after:outline-ink">
52
+ {title}
53
+ </L>
54
+ </Title>
55
+ <p className="mt-3 line-clamp-3 leading-relaxed text-ink-body">{excerpt}</p>
56
+ <p className="mt-auto pt-5 text-sm text-ink-body">
57
+ <span className="font-semibold text-ink">{author}</span>
58
+ <span aria-hidden="true"> · </span>
59
+ <time dateTime={date.iso}>{date.label}</time>
60
+ </p>
61
+ {/* decoration: the link is the title */}
62
+ <p aria-hidden="true" className="mt-3 inline-flex items-center gap-1 text-sm font-semibold text-ink-soft">
63
+ {t.read}
64
+ <ArrowUpRight />
65
+ </p>
66
+ </div>
67
+ </article>
68
+ );
69
+ }
@@ -3,7 +3,7 @@
3
3
  import { useEffect, useRef, useState, type ElementType, type ReactNode } from "react";
4
4
  import { initials } from "./Avatar";
5
5
  import { ProfessionalModal, type ProfessionalModalLabels, type ProfessionalPresentation } from "./ProfessionalModal";
6
- import { CollapseRow, LinkRow, RowList } from "./Rows";
6
+ import { CollapseRow, LinkRow, RowList, type RowLink } from "./Rows";
7
7
 
8
8
  export type ProfessionalPhoto = {
9
9
  src: string;
@@ -22,28 +22,50 @@ export type ProfessionalLiving = {
22
22
  poster: string;
23
23
  };
24
24
 
25
- export type ProfessionalGuidance = { label: ReactNode; href: string };
25
+ /**
26
+ * One row at the foot of the full card (RowList). Generic: the site decides
27
+ * what the rows are. On novo.creeta they are "Trajetória" (collapse with the
28
+ * bio), "Orientações" (collapse with the links the person signs, with
29
+ * `openOnArrival`) and "Instagram" (link with icon, new tab).
30
+ */
31
+ export type ProfessionalCardRow =
32
+ | {
33
+ kind: "collapse";
34
+ label: ReactNode;
35
+ /** sr-only right after the label, e.g. " of Person name". */
36
+ srLabel?: ReactNode;
37
+ /** Text content (a string becomes one paragraph). */
38
+ content?: ReactNode;
39
+ /** Or a list of links, with the count beside the label. */
40
+ links?: RowLink[];
41
+ count?: number;
42
+ /** Marks the row with `data-cg-open-on-arrival`: the site opens it when
43
+ * the page is reached by the card's anchor (creeta's `abre-ao-chegar`). */
44
+ openOnArrival?: boolean;
45
+ }
46
+ | {
47
+ kind: "link";
48
+ label: ReactNode;
49
+ href: string;
50
+ srLabel?: ReactNode;
51
+ /** ↗ another page (default) or ↓ an anchor on this page. */
52
+ arrow?: "go" | "down";
53
+ /** Opens in a new tab. */
54
+ external?: boolean;
55
+ /** Replaces the arrow (an 18px glyph): the Instagram icon, for instance. */
56
+ icon?: ReactNode;
57
+ };
26
58
 
27
59
  export type ProfessionalCardLabels = ProfessionalModalLabels & {
28
60
  /** alt of the portrait. */
29
61
  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;
62
+ /** The small badge on the medium card's photo, on hover ("Conhecer"). */
63
+ meet: string;
38
64
  };
39
65
 
40
66
  const EN: Omit<ProfessionalCardLabels, keyof ProfessionalModalLabels> = {
41
67
  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)`,
68
+ meet: "Meet",
47
69
  };
48
70
 
49
71
  const SIZES = "(min-width: 1024px) 30vw, (min-width: 640px) 45vw, 92vw";
@@ -52,6 +74,9 @@ const SIZES = "(min-width: 1024px) 30vw, (min-width: 640px) 45vw, 92vw";
52
74
  const ZOOM =
53
75
  "aspect-[4/5] h-auto w-full object-cover transition-transform duration-500 ease-[var(--ease-cg)] motion-reduce:transition-none";
54
76
  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`;
77
+ /* medium card: the 3:4 portrait, zoom with the mouse over the link */
78
+ const ZOOM_MEDIUM =
79
+ "aspect-[3/4] h-auto w-full object-cover transition-transform duration-500 ease-[var(--ease-cg)] motion-reduce:transition-none [@media(hover:hover)_and_(pointer:fine)]:group-hover:scale-[1.04] motion-reduce:[@media(hover:hover)_and_(pointer:fine)]:group-hover:scale-100";
55
80
  /* inside the modal trigger only the photo button is the hover target */
56
81
  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
82
 
@@ -224,15 +249,25 @@ type Props = {
224
249
  living?: ProfessionalLiving;
225
250
  /** With a presentation video the portrait opens the modal (ProfessionalModal). */
226
251
  presentation?: ProfessionalPresentation;
227
- /** The bio: a string (one paragraph) or your own paragraphs. Opens in place
228
- * under "Trajectory". */
252
+ /** The full bio, shown in the presentation modal: a string (one paragraph)
253
+ * or your own paragraphs. To show it on the card too, put it in a
254
+ * `rows` collapse. */
229
255
  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). */
256
+ /** The rows at the foot of the card (full size): collapses with text or
257
+ * links, links with an arrow or an icon. See ProfessionalCardRow. */
258
+ rows?: ProfessionalCardRow[];
259
+ /** Anchor of the card (a link elsewhere on the site lands here). */
235
260
  id?: string;
261
+ /** `full` (default): the team-page card. `medium`: the home's tile — a
262
+ * white card with the 3:4 portrait and the name, a link to the full card
263
+ * (living portrait and "Meet" badge on hover). `micro`: the "who treats"
264
+ * row — 72px photo, name and role, also a link. The small sizes render the
265
+ * link as the root; wrap them in your <li> (RailItem). */
266
+ size?: "full" | "medium" | "micro";
267
+ /** Where the small sizes lead: the full card on the team page ("/team/#slug"). */
268
+ href?: string;
269
+ /** Name shown on the medium card (the site uses the first two words). */
270
+ shortName?: string;
236
271
  /** h2 on the team page (default), h3 inside a section that already has an h2. */
237
272
  headingLevel?: "h2" | "h3";
238
273
  /** Root element. Default <article>. */
@@ -248,13 +283,14 @@ type Props = {
248
283
  * (components/professional-card.tsx) with the Breathe tokens. A `card`
249
284
  * surface with the portrait on top (plain, living, or opening the video
250
285
  * 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.
286
+ * rows separated by hairlines (`rows`): collapses that open in place (a text,
287
+ * or a list of links with the count) and links (arrow or icon). On creeta:
288
+ * Trajectory, Guidance and Instagram. Six open bios used to make the page
289
+ * 6,700px tall on a phone, hence the collapses.
254
290
  *
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).
291
+ * A collapse with `openOnArrival` carries `data-cg-open-on-arrival`: the site
292
+ * opens it when the page is reached by the card's anchor (creeta's
293
+ * `abre-ao-chegar.tsx`, not part of the DS).
258
294
  */
259
295
  export function ProfessionalCard({
260
296
  name,
@@ -265,9 +301,11 @@ export function ProfessionalCard({
265
301
  living,
266
302
  presentation,
267
303
  bio,
268
- guidance,
269
- instagram,
304
+ rows,
270
305
  id,
306
+ size = "full",
307
+ href = "#",
308
+ shortName,
271
309
  headingLevel: Heading = "h2",
272
310
  as: As = "article",
273
311
  linkComponent,
@@ -279,6 +317,69 @@ export function ProfessionalCard({
279
317
  const alt = photo?.alt ?? t.portrait(name);
280
318
  const hasPortrait = Boolean(photo || media || living);
281
319
 
320
+ if (size === "micro") {
321
+ // the "who treats" row of a treatment page (creeta telas/tratamento.tsx):
322
+ // the text beside says who it is, so the photo is decoration
323
+ return (
324
+ <L
325
+ href={href}
326
+ id={id}
327
+ className={`flex items-center gap-4 rounded-[22px] bg-card p-3 pr-5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink ${className}`}
328
+ >
329
+ {photo ? (
330
+ <img src={photo.src} srcSet={photo.srcSet} sizes="72px" alt="" loading="lazy" decoding="async" className="h-[72px] w-[72px] shrink-0 rounded-2xl object-cover" />
331
+ ) : media ? (
332
+ <span className="block h-[72px] w-[72px] shrink-0 overflow-hidden rounded-2xl">{media}</span>
333
+ ) : (
334
+ <span aria-hidden="true" className="flex h-[72px] w-[72px] shrink-0 items-center justify-center rounded-2xl bg-ink/[0.12] font-heading text-xl font-medium text-ink">
335
+ {initials(name)}
336
+ </span>
337
+ )}
338
+ <span>
339
+ <span className="block font-heading font-medium leading-snug text-ink">{name}</span>
340
+ {role && <span className="mt-0.5 block text-sm text-ink-soft">{role}</span>}
341
+ </span>
342
+ </L>
343
+ );
344
+ }
345
+
346
+ if (size === "medium") {
347
+ // the home's team tile (creeta telas/home.tsx): a white card with the
348
+ // photo and the name inside — without the card the name sat loose under
349
+ // the photo, with no "close". Mouse over: the clip plays, the photo zooms
350
+ // a little and a discreet "Meet" shows in the corner; on tap the card shrinks.
351
+ return (
352
+ <L
353
+ href={href}
354
+ id={id}
355
+ data-cg-professional-card=""
356
+ className={`group block h-full rounded-[26px] bg-card p-2 pb-4 transition-transform duration-200 ease-out active:scale-[0.98] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none motion-reduce:active:scale-100 ${className}`}
357
+ >
358
+ <span className="relative block overflow-hidden rounded-[20px]">
359
+ {hasPortrait ? (
360
+ <Portrait name={name} photo={photo ? { ...photo, sizes: photo.sizes ?? "(min-width: 768px) 16vw, 42vw" } : undefined} media={media} living={living} alt={alt} decorative className={ZOOM_MEDIUM} />
361
+ ) : (
362
+ <span aria-hidden="true" className="flex aspect-[3/4] w-full items-center justify-center bg-ink/[0.12] font-heading text-3xl font-medium text-ink">
363
+ {initials(name)}
364
+ </span>
365
+ )}
366
+ {/* only where a mouse exists, and with keyboard focus; the link
367
+ already has its name, so it is decoration */}
368
+ <span
369
+ aria-hidden="true"
370
+ className="pointer-events-none absolute bottom-2 left-2 inline-flex translate-y-1 items-center gap-1.5 rounded-full border border-white/15 bg-ink/80 px-3 py-2 text-xs font-medium text-white opacity-0 backdrop-blur-md transition duration-200 ease-out group-focus-visible:translate-y-0 group-focus-visible:opacity-100 motion-reduce:translate-y-0 motion-reduce:transition-none [@media(hover:hover)_and_(pointer:fine)]:group-hover:translate-y-0 [@media(hover:hover)_and_(pointer:fine)]:group-hover:opacity-100"
371
+ >
372
+ {t.meet}
373
+ <svg aria-hidden="true" viewBox="0 0 24 24" width={14} height={14} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
374
+ </span>
375
+ </span>
376
+ <span className="mt-3 block px-1.5 font-heading text-sm font-medium leading-snug text-ink transition-colors duration-150 group-hover:text-ink-soft motion-reduce:transition-none">
377
+ {shortName ?? name}
378
+ </span>
379
+ </L>
380
+ );
381
+ }
382
+
282
383
  return (
283
384
  <As id={id} data-cg-professional-card="" className={`group/card scroll-mt-24 rounded-2xl bg-card p-3 pb-6 ${className}`}>
284
385
  {hasPortrait && presentation ? (
@@ -315,26 +416,30 @@ export function ProfessionalCard({
315
416
  <p className="mt-1 text-xs text-ink/80">{registration}</p>
316
417
  </>
317
418
  )}
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>
419
+ {/* One list of equal rows. On creeta they used to be three things
420
+ with three designs (an open button, a loose list of links and a
421
+ magenta link at the end); the collapses open in place. */}
422
+ {rows && rows.length > 0 && (
423
+ <RowList className="mt-4">
424
+ {rows.map((row, i) =>
425
+ row.kind === "collapse" ? (
426
+ <CollapseRow
427
+ key={i}
428
+ label={row.label}
429
+ srLabel={row.srLabel}
430
+ count={row.count}
431
+ links={row.links}
432
+ linkComponent={L}
433
+ {...(row.openOnArrival ? { "data-cg-open-on-arrival": "" } : {})}
434
+ >
435
+ {row.content}
436
+ </CollapseRow>
437
+ ) : (
438
+ <LinkRow key={i} label={row.label} srLabel={row.srLabel} href={row.href} arrow={row.arrow} external={row.external} icon={row.icon} linkComponent={L} />
439
+ ),
440
+ )}
441
+ </RowList>
442
+ )}
338
443
  </div>
339
444
  </As>
340
445
  );