@breathe-ds/ui 0.5.1 → 0.6.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.
Files changed (45) hide show
  1. package/README.md +33 -5
  2. package/components/Accordion.tsx +6 -3
  3. package/components/AnchorNav.tsx +10 -5
  4. package/components/Avatar.tsx +1 -1
  5. package/components/BottomNav.tsx +12 -5
  6. package/components/Button.tsx +5 -2
  7. package/components/Byline.tsx +25 -1
  8. package/components/Card.tsx +11 -3
  9. package/components/ContactRow.tsx +112 -0
  10. package/components/CourseCard.tsx +3 -3
  11. package/components/CtaCard.tsx +3 -1
  12. package/components/DateBadge.tsx +1 -1
  13. package/components/EmptyState.tsx +54 -0
  14. package/components/EventFacts.tsx +1 -1
  15. package/components/EventRow.tsx +20 -8
  16. package/components/FactStrip.tsx +1 -1
  17. package/components/FilterBar.tsx +50 -0
  18. package/components/FilterChips.tsx +63 -0
  19. package/components/FilterPill.tsx +35 -0
  20. package/components/Footer.tsx +1 -1
  21. package/components/Header.tsx +61 -33
  22. package/components/Heading.tsx +3 -2
  23. package/components/InfoCard.tsx +7 -2
  24. package/components/List.tsx +4 -2
  25. package/components/PageHero.tsx +74 -0
  26. package/components/PageVideoHero.tsx +21 -6
  27. package/components/PersonChip.tsx +1 -1
  28. package/components/Pill.tsx +1 -1
  29. package/components/ProfessionalCard.tsx +60 -5
  30. package/components/ProfileSpotlight.tsx +94 -0
  31. package/components/PublicationCard.tsx +215 -0
  32. package/components/PullQuote.tsx +87 -0
  33. package/components/RegistrationBox.tsx +1 -1
  34. package/components/SearchField.tsx +85 -0
  35. package/components/SelectField.tsx +75 -0
  36. package/components/SidePanel.tsx +6 -3
  37. package/components/StickyGroupHeading.tsx +38 -0
  38. package/components/TestimonialCard.tsx +179 -23
  39. package/components/Text.tsx +1 -1
  40. package/components/Timeline.tsx +1 -1
  41. package/components/VideoInviteCard.tsx +20 -6
  42. package/package.json +14 -2
  43. package/theme.css +50 -0
  44. package/tokens/creeta.css +1 -0
  45. package/tokens/ttcab.css +6 -1
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useRef, useState, type ElementType, type ReactNode } from "react";
4
- import { initials } from "./Avatar";
4
+ import { Avatar, initials } from "./Avatar";
5
5
  import { ProfessionalModal, type ProfessionalModalLabels, type ProfessionalPresentation } from "./ProfessionalModal";
6
6
  import { CollapseRow, LinkRow, RowList, type RowLink } from "./Rows";
7
7
 
@@ -56,6 +56,19 @@ export type ProfessionalCardRow =
56
56
  icon?: ReactNode;
57
57
  };
58
58
 
59
+ /** One contact line of the `listing` card: a LinkRow with the lucide glyph on
60
+ * the right (phone, mail, WhatsApp). The label is the visible number/address;
61
+ * `srLabel` says who it reaches ("Ligar para Ana Souza"). */
62
+ export type ProfessionalContact = {
63
+ label: ReactNode;
64
+ href: string;
65
+ icon?: ReactNode;
66
+ srLabel?: ReactNode;
67
+ external?: boolean;
68
+ /** Long unbroken text (an e-mail) may wrap anywhere, only when it does not fit. */
69
+ breakAll?: boolean;
70
+ };
71
+
59
72
  export type ProfessionalCardLabels = ProfessionalModalLabels & {
60
73
  /** alt of the portrait. */
61
74
  portrait: (name: string) => string;
@@ -263,7 +276,12 @@ type Props = {
263
276
  * (living portrait and "Meet" badge on hover). `micro`: the "who treats"
264
277
  * row — 72px photo, name and role, also a link. The small sizes render the
265
278
  * link as the root; wrap them in your <li> (RailItem). */
266
- size?: "full" | "medium" | "micro";
279
+ size?: "full" | "medium" | "micro" | "listing";
280
+ /** `listing` only: the contact rows (phone, e-mail). */
281
+ contacts?: ProfessionalContact[];
282
+ /** `listing` only: the name as rendered, e.g. with the search match marked.
283
+ * `name` stays the plain text (initials, alt). */
284
+ nameDisplay?: ReactNode;
267
285
  /** Where the small sizes lead: the full card on the team page ("/team/#slug"). */
268
286
  href?: string;
269
287
  /** Name shown on the medium card (the site uses the first two words). */
@@ -304,6 +322,8 @@ export function ProfessionalCard({
304
322
  rows,
305
323
  id,
306
324
  size = "full",
325
+ contacts,
326
+ nameDisplay,
307
327
  href = "#",
308
328
  shortName,
309
329
  headingLevel: Heading = "h2",
@@ -317,6 +337,41 @@ export function ProfessionalCard({
317
337
  const alt = photo?.alt ?? t.portrait(name);
318
338
  const hasPortrait = Boolean(photo || media || living);
319
339
 
340
+ if (size === "listing") {
341
+ // the directory card (ttcab "Encontre um profissional"): many people, no
342
+ // photos. The family's solid white card, the initials in a tint bubble,
343
+ // name, place (in `role`) and the registration, then the contacts as
344
+ // 48px rows. The card itself is not a link: the rows are the actions.
345
+ return (
346
+ <As id={id} data-cg-professional-card="" className={`flex h-full flex-col rounded-[22px] bg-card px-5 pt-5 pb-1.5 ${className}`}>
347
+ <div className="flex items-start gap-4">
348
+ {photo ? <Avatar name={name} photo={photo.src} size={48} /> : <Avatar name={name} size={48} />}
349
+ <div className="min-w-0 pt-0.5">
350
+ <Heading className="font-heading text-lg leading-snug font-medium text-pretty text-ink">{nameDisplay ?? name}</Heading>
351
+ {role && <p className="mt-1 text-sm text-ink-soft">{role}</p>}
352
+ {registration && <p className="mt-1 text-xs text-ink/80">{registration}</p>}
353
+ </div>
354
+ </div>
355
+ {contacts && contacts.length > 0 && (
356
+ <div className="mt-auto pt-5">
357
+ <RowList>
358
+ {contacts.map((c, i) => (
359
+ <LinkRow
360
+ key={i}
361
+ label={<span className={c.breakAll ? "[overflow-wrap:anywhere]" : undefined}>{c.label}</span>}
362
+ srLabel={c.srLabel}
363
+ href={c.href}
364
+ icon={c.icon}
365
+ external={c.external}
366
+ />
367
+ ))}
368
+ </RowList>
369
+ </div>
370
+ )}
371
+ </As>
372
+ );
373
+ }
374
+
320
375
  if (size === "micro") {
321
376
  // the "who treats" row of a treatment page (creeta telas/tratamento.tsx):
322
377
  // the text beside says who it is, so the photo is decoration
@@ -331,7 +386,7 @@ export function ProfessionalCard({
331
386
  ) : media ? (
332
387
  <span className="block h-[72px] w-[72px] shrink-0 overflow-hidden rounded-2xl">{media}</span>
333
388
  ) : (
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">
389
+ <span aria-hidden="true" className="flex h-[72px] w-[72px] shrink-0 items-center justify-center rounded-2xl bg-tint font-heading text-xl font-medium text-ink">
335
390
  {initials(name)}
336
391
  </span>
337
392
  )}
@@ -359,7 +414,7 @@ export function ProfessionalCard({
359
414
  {hasPortrait ? (
360
415
  <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
416
  ) : (
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">
417
+ <span aria-hidden="true" className="flex aspect-[3/4] w-full items-center justify-center bg-tint font-heading text-3xl font-medium text-ink">
363
418
  {initials(name)}
364
419
  </span>
365
420
  )}
@@ -401,7 +456,7 @@ export function ProfessionalCard({
401
456
  <div
402
457
  data-testid="initials"
403
458
  aria-hidden="true"
404
- className="flex aspect-[3/4] w-full items-center justify-center rounded-[20px] bg-ink/[0.12]"
459
+ className="flex aspect-[3/4] w-full items-center justify-center rounded-[20px] bg-tint"
405
460
  >
406
461
  <span className="font-heading text-4xl font-bold text-ink">{initials(name)}</span>
407
462
  </div>
@@ -0,0 +1,94 @@
1
+ import type { ComponentType, ReactNode } from "react";
2
+ import { LivingPortrait, type ProfessionalLiving } from "./ProfessionalCard";
3
+
4
+ type Props = {
5
+ name: string;
6
+ /** Small line above the name ("Idealizadora", "Course coordinator"). */
7
+ eyebrow?: ReactNode;
8
+ /** What the person does, under the name, one tone below. */
9
+ role?: ReactNode;
10
+ /** Council registration (CREFITO): the family rule for health professionals. */
11
+ registration?: ReactNode;
12
+ /** The bio: a string (one paragraph) or your own paragraphs. */
13
+ bio?: ReactNode;
14
+ /** The still portrait (4:5). With `living`, it is the fallback if the clip fails. */
15
+ photo: { src: string; alt?: string };
16
+ /** Living portrait: the clip plays muted while the mouse is on the block. */
17
+ living?: ProfessionalLiving;
18
+ /** Your own image element (e.g. next/image) in place of the photo; must fill the box. */
19
+ media?: ReactNode;
20
+ /** Links or buttons at the foot ("Watch the presentation", "Curriculum ↗"). */
21
+ actions?: ReactNode;
22
+ /** Social profiles at the foot (Instagram, LinkedIn, Lattes…): round icon
23
+ * links, each with its name for screen readers, opening in a new tab. */
24
+ social?: { label: string; href: string; Icon: ComponentType<{ size?: number; strokeWidth?: number; "aria-hidden"?: boolean | "true" }> }[];
25
+ /** sr-only after each social link. */
26
+ newTabLabel?: string;
27
+ /** `card`: on a white card. `plain` (default): straight on the page color. */
28
+ variant?: "plain" | "card";
29
+ /** h2 for a page section (default), h3 inside a section that already has one. */
30
+ headingLevel?: "h2" | "h3";
31
+ id?: string;
32
+ className?: string;
33
+ };
34
+
35
+ /**
36
+ * Profile spotlight of the Cuidado Global family: one person in focus — the
37
+ * founder, a course coordinator, a guest. Portrait on the left (photo or the
38
+ * living portrait of the team pages), then eyebrow, name, role, registration,
39
+ * bio and actions. Stacks on phones (portrait on top). The whole block is the
40
+ * hover target of the living portrait.
41
+ */
42
+ export function ProfileSpotlight({ name, eyebrow, role, registration, bio, photo, living, media, actions, social, newTabLabel = "(opens in a new tab)", variant = "plain", headingLevel = "h2", id, className = "" }: Props) {
43
+ const H = headingLevel;
44
+ const card = variant === "card";
45
+ return (
46
+ <article
47
+ id={id}
48
+ data-cg-professional-card
49
+ className={`grid items-center gap-8 md:grid-cols-[minmax(220px,360px)_1fr] md:gap-[clamp(32px,6vw,88px)] ${card ? "rounded-[28px] bg-card p-5 md:p-8" : ""} ${id ? "scroll-mt-24" : ""} ${className}`}
50
+ >
51
+ <div className="overflow-hidden rounded-2xl bg-tint">
52
+ {living ? (
53
+ <LivingPortrait src={living.src} poster={living.poster} photo={photo} media={media} alt={photo.alt ?? name} decorative className="aspect-[4/5]" />
54
+ ) : (
55
+ media ?? (
56
+ // eslint-disable-next-line @next/next/no-img-element
57
+ <img src={photo.src} alt="" loading="lazy" decoding="async" className="aspect-[4/5] w-full object-cover" />
58
+ )
59
+ )}
60
+ </div>
61
+ <div className="min-w-0">
62
+ {eyebrow && <p className="m-0 mb-3 font-heading text-sm font-semibold text-ink-soft">{eyebrow}</p>}
63
+ <H className="m-0 font-heading text-[clamp(1.9rem,3.6vw,2.75rem)] leading-[1.1] font-medium tracking-[-0.015em] text-ink">{name}</H>
64
+ {role && <p className="mt-4 mb-0 max-w-[52ch] text-lg leading-snug text-ink-body">{role}</p>}
65
+ {registration && <p className="mt-2 mb-0 text-sm text-ink-soft">{registration}</p>}
66
+ {bio && (
67
+ <div className="mt-6 flex max-w-[60ch] flex-col gap-4 leading-relaxed text-ink-body [&>p]:m-0">
68
+ {typeof bio === "string" ? <p>{bio}</p> : bio}
69
+ </div>
70
+ )}
71
+ {actions && <div className="mt-7 flex flex-wrap items-center gap-3">{actions}</div>}
72
+ {social?.length ? (
73
+ <ul className={`m-0 flex list-none flex-wrap gap-2 p-0 ${actions ? "mt-5" : "mt-7"}`}>
74
+ {social.map(({ label, href, Icon }) => (
75
+ <li key={href}>
76
+ <a
77
+ href={href}
78
+ target="_blank"
79
+ rel="noopener noreferrer"
80
+ className="flex h-11 w-11 items-center justify-center rounded-full bg-tint text-ink transition-colors duration-200 hover:bg-ink hover:text-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none"
81
+ >
82
+ <Icon size={19} strokeWidth={1.75} aria-hidden="true" />
83
+ <span className="sr-only">
84
+ {label} {newTabLabel}
85
+ </span>
86
+ </a>
87
+ </li>
88
+ ))}
89
+ </ul>
90
+ ) : null}
91
+ </div>
92
+ </article>
93
+ );
94
+ }
@@ -0,0 +1,215 @@
1
+ "use client";
2
+
3
+ import { useId, useState, type ElementType, type ReactNode } from "react";
4
+ import { ArrowRight, ChevronDown, FileText } from "lucide-react";
5
+ import { FADE_IN } from "./Rows";
6
+
7
+ export type PublicationCardLabels = {
8
+ /** The toggle ("Resumo"). */
9
+ abstract: string;
10
+ /** sr-only word before the title after the toggle and the PDF link ("de" → "Resumo de {title}").
11
+ * A plain string so a server component can pass it. */
12
+ of: string;
13
+ pdf: string;
14
+ newTab: string;
15
+ /** Footer link of the compact card ("Ler o artigo"). */
16
+ read: string;
17
+ };
18
+
19
+ const EN: PublicationCardLabels = {
20
+ abstract: "Abstract",
21
+ of: "of",
22
+ pdf: "PDF",
23
+ newTab: "(opens in a new tab)",
24
+ read: "Read the article",
25
+ };
26
+
27
+ type Props = {
28
+ /** Plain title (sr-only labels). */
29
+ title: string;
30
+ /** Language of the title and abstract when it differs from the page ("en"). */
31
+ lang?: string;
32
+ /** The study type, shown as a small tag ("Ensaio clínico randomizado"). */
33
+ kind?: ReactNode;
34
+ /** Topics, in a lighter line beside the tag. */
35
+ topics?: ReactNode[];
36
+ /** Authors, already shortened ("Ana Souza e mais 3 autores"). */
37
+ byline?: ReactNode;
38
+ /** The publication's page on the site. */
39
+ href?: string;
40
+ /** The full PDF, opened in a new tab. */
41
+ pdf?: string;
42
+ /** Abstract paragraphs (structured abstracts keep Background / Methods… apart). */
43
+ abstract?: string[];
44
+ /** Language of the abstract if it differs from the title's. */
45
+ abstractLang?: string;
46
+ headingLevel?: "h2" | "h3";
47
+ /** `full` (default): the library row, abstract opening in place.
48
+ * `compact`: the "related articles" tile — same height as its neighbours,
49
+ * tag and topics on two fixed lines, and a footer pinned to the bottom with
50
+ * "read the article" on the left and the PDF on the right. */
51
+ variant?: "full" | "compact";
52
+ /** Link component of the app (e.g. next-intl Link). Default: <a>. */
53
+ linkComponent?: ElementType;
54
+ labels?: Partial<PublicationCardLabels>;
55
+ className?: string;
56
+ };
57
+
58
+ /** "Background: text" -> the label in bold, so a structured abstract can be scanned. */
59
+ export function abstractLabel(p: string): { label?: string; text: string } {
60
+ const m = p.match(/^([A-Z][A-Za-z ]{2,22}|[A-ZÀ-Ú ]{3,22}):\s*(.+)$/);
61
+ return m ? { label: m[1].replace(/\s+/g, " ").trim(), text: m[2] } : { text: p };
62
+ }
63
+
64
+ /** The paragraphs of an abstract, labels in bold. */
65
+ export function AbstractText({ paragraphs, lang, className = "" }: { paragraphs: string[]; lang?: string; className?: string }) {
66
+ return (
67
+ <div lang={lang} className={`flex max-w-[68ch] flex-col gap-3 leading-relaxed text-ink-body ${className}`}>
68
+ {paragraphs.map((p, i) => {
69
+ const { label, text } = abstractLabel(p);
70
+ return (
71
+ <p key={i} className="m-0">
72
+ {label && <strong className="font-semibold text-ink">{label}. </strong>}
73
+ {text}
74
+ </p>
75
+ );
76
+ })}
77
+ </div>
78
+ );
79
+ }
80
+
81
+ const ACTION =
82
+ "inline-flex min-h-11 items-center gap-1.5 rounded-[2px] text-sm font-semibold text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink";
83
+
84
+ /**
85
+ * PublicationCard — one study in a library of scientific articles (ttcab's
86
+ * Artigos). Built on proximity and common region: the white card holds the
87
+ * whole item, the tag and topics sit tight over the title, the authors right
88
+ * under it, and the two actions (open the abstract here, open the PDF) share
89
+ * one row under the authors — never pushed to the far edge of the page.
90
+ *
91
+ * The abstract opens in place (a button with `aria-expanded` controlling the
92
+ * region below, so the PDF link can sit beside the toggle); it fades in with
93
+ * `@starting-style`, no JavaScript animation.
94
+ */
95
+ export function PublicationCard({
96
+ title,
97
+ lang,
98
+ kind,
99
+ topics,
100
+ byline,
101
+ href,
102
+ pdf,
103
+ abstract,
104
+ abstractLang,
105
+ headingLevel: H = "h2",
106
+ variant = "full",
107
+ linkComponent,
108
+ labels,
109
+ className = "",
110
+ }: Props) {
111
+ const t = { ...EN, ...labels };
112
+ const L: ElementType = linkComponent ?? "a";
113
+ const [open, setOpen] = useState(false);
114
+ const regionId = useId();
115
+ const hasAbstract = Boolean(abstract && abstract.length);
116
+
117
+ if (variant === "compact") {
118
+ return (
119
+ <article className={`flex h-full flex-col rounded-[22px] bg-card ${className}`}>
120
+ <div className="flex flex-1 flex-col px-6 pt-6 pb-5">
121
+ {kind && (
122
+ <p className="m-0">
123
+ <span className="inline-block rounded-full bg-tint px-2.5 py-1 text-xs font-semibold text-ink">{kind}</span>
124
+ </p>
125
+ )}
126
+ {topics && topics.length > 0 && <p className="m-0 mt-2 text-sm text-ink-soft">{topics.join(" · ")}</p>}
127
+ <H lang={lang} className="mt-3 font-heading text-[1.15rem] leading-snug font-medium text-balance text-ink">
128
+ {href ? (
129
+ <L href={href} className="rounded-[2px] underline decoration-transparent decoration-[1.5px] underline-offset-4 transition-[text-decoration-color] duration-150 hover:decoration-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none">
130
+ {title}
131
+ </L>
132
+ ) : (
133
+ title
134
+ )}
135
+ </H>
136
+ {byline && <p className="m-0 mt-2 text-sm text-ink-soft">{byline}</p>}
137
+ </div>
138
+ {(href || pdf) && (
139
+ <footer className="flex min-h-14 items-center justify-between gap-4 rounded-b-[22px] border-t border-line bg-surface-alt/60 px-6">
140
+ {href ? (
141
+ <L href={href} tabIndex={-1} aria-hidden="true" className="group/read inline-flex min-h-11 items-center gap-1.5 text-sm font-semibold text-ink">
142
+ {t.read}
143
+ <ArrowRight aria-hidden="true" size={16} className="transition-transform duration-200 group-hover/read:translate-x-0.5 motion-reduce:transform-none" />
144
+ </L>
145
+ ) : (
146
+ <span />
147
+ )}
148
+ {pdf && (
149
+ <a href={pdf} target="_blank" rel="noopener noreferrer" className={`${ACTION} underline decoration-ink/35 decoration-[1.5px] underline-offset-4 transition-[text-decoration-color] duration-150 hover:decoration-ink motion-reduce:transition-none`}>
150
+ <FileText aria-hidden="true" size={16} className="text-ink-soft" />
151
+ {t.pdf}
152
+ <span className="sr-only">{` ${t.of} ${title} ${t.newTab}`}</span>
153
+ </a>
154
+ )}
155
+ </footer>
156
+ )}
157
+ </article>
158
+ );
159
+ }
160
+
161
+ return (
162
+ <article className={`rounded-[22px] bg-card p-[clamp(20px,3vw,28px)] ${className}`}>
163
+ {(kind || (topics && topics.length > 0)) && (
164
+ <p className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-sm text-ink-soft">
165
+ {kind && <span className="rounded-full bg-tint px-2.5 py-1 text-xs font-semibold text-ink">{kind}</span>}
166
+ {topics && topics.length > 0 && <span>{topics.join(" · ")}</span>}
167
+ </p>
168
+ )}
169
+ <H lang={lang} className="mt-3 max-w-[64ch] font-heading text-[1.2rem] leading-snug font-medium text-balance text-ink md:text-[1.3rem]">
170
+ {href ? (
171
+ <L
172
+ href={href}
173
+ className="rounded-[2px] underline decoration-transparent decoration-[1.5px] underline-offset-4 transition-[text-decoration-color] duration-150 hover:decoration-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none"
174
+ >
175
+ {title}
176
+ </L>
177
+ ) : (
178
+ title
179
+ )}
180
+ </H>
181
+ {byline && <p className="mt-1.5 text-sm text-ink-soft">{byline}</p>}
182
+
183
+ {(hasAbstract || pdf) && (
184
+ <div className="mt-3 flex flex-wrap items-center gap-x-6 border-t border-line pt-1">
185
+ {hasAbstract && (
186
+ <button type="button" aria-expanded={open} aria-controls={regionId} onClick={() => setOpen((v) => !v)} className={`group/abs ${ACTION}`}>
187
+ {t.abstract}
188
+ <ChevronDown
189
+ aria-hidden="true"
190
+ size={16}
191
+ className={`text-ink-soft transition-transform duration-200 motion-reduce:transition-none ${open ? "rotate-180" : "group-hover/abs:translate-y-0.5"}`}
192
+ />
193
+ <span className="sr-only">{` ${t.of} ${title}`}</span>
194
+ </button>
195
+ )}
196
+ {pdf && (
197
+ <a href={pdf} target="_blank" rel="noopener noreferrer" className={`${ACTION} underline decoration-ink/35 decoration-[1.5px] underline-offset-4 transition-[text-decoration-color] duration-150 hover:decoration-ink motion-reduce:transition-none`}>
198
+ <FileText aria-hidden="true" size={16} className="text-ink-soft" />
199
+ {t.pdf}
200
+ <span className="sr-only">
201
+ {` ${t.of} ${title} ${t.newTab}`}
202
+ </span>
203
+ </a>
204
+ )}
205
+ </div>
206
+ )}
207
+
208
+ {hasAbstract && (
209
+ <div id={regionId} hidden={!open} className={`${FADE_IN} mt-2`}>
210
+ <AbstractText paragraphs={abstract!} lang={abstractLang ?? lang} />
211
+ </div>
212
+ )}
213
+ </article>
214
+ );
215
+ }
@@ -0,0 +1,87 @@
1
+ import type { ElementType, ReactNode } from "react";
2
+ import { Byline, type Person } from "./Byline";
3
+
4
+ /**
5
+ * The hand-drawn underline in the action color, under the key words of a line
6
+ * ("de forma integrada"). Decorative: the words stay plain text for screen
7
+ * readers. Keep it to a few words — it does not wrap.
8
+ */
9
+ export function Highlight({ children }: { children: ReactNode }) {
10
+ return (
11
+ <span className="relative whitespace-nowrap">
12
+ {children}
13
+ <svg
14
+ aria-hidden="true"
15
+ viewBox="0 0 200 14"
16
+ preserveAspectRatio="none"
17
+ className="pointer-events-none absolute inset-x-0 -bottom-[0.16em] h-[0.3em] w-full"
18
+ >
19
+ <path
20
+ d="M3 9 C 40 3, 80 12, 118 7 C 150 3, 178 8, 197 6"
21
+ fill="none"
22
+ strokeWidth="3"
23
+ strokeLinecap="round"
24
+ className="stroke-action-line"
25
+ />
26
+ </svg>
27
+ </span>
28
+ );
29
+ }
30
+
31
+ type Props = {
32
+ /** The line itself. Use <Highlight> on the few words that carry it. */
33
+ children: ReactNode;
34
+ /** Who said it (portrait + name + role under the line). Omit for a brand
35
+ * statement — then the quote marks stay, without an author. */
36
+ cite?: { people: Person[]; role?: ReactNode };
37
+ /** Small line above ("O que nos guia"). */
38
+ eyebrow?: ReactNode;
39
+ /** `center` (default) for a statement between sections; `start` beside other content. */
40
+ align?: "center" | "start";
41
+ /** `lg` (default) between sections; `md` inside a column or a card. */
42
+ size?: "md" | "lg";
43
+ /** Big opening quote mark in the action color. Default: true. */
44
+ mark?: boolean;
45
+ as?: ElementType;
46
+ className?: string;
47
+ };
48
+
49
+ const SIZE = {
50
+ lg: "text-[clamp(1.75rem,4.2vw,3.25rem)] leading-[1.14]",
51
+ md: "text-[clamp(1.4rem,2.6vw,2rem)] leading-[1.2]",
52
+ };
53
+
54
+ /**
55
+ * Pull quote of the Cuidado Global family: one line that matters, set large,
56
+ * with the opening mark in the action color and, optionally, the person who
57
+ * said it (the family's Byline). Between sections it breathes on its own; the
58
+ * line is balanced (text-balance) so it never ends on a lonely word.
59
+ */
60
+ export function PullQuote({ children, cite, eyebrow, align = "center", size = "lg", mark = true, as: Root = "figure", className = "" }: Props) {
61
+ const center = align === "center";
62
+ return (
63
+ <Root className={`${center ? "mx-auto text-center" : ""} max-w-4xl ${className}`}>
64
+ {mark && (
65
+ // drawn mark: the font's own “ is tiny in Quicksand, it would not hold the line
66
+ <svg
67
+ aria-hidden="true"
68
+ viewBox="0 0 48 36"
69
+ className={`mb-6 block h-9 w-12 fill-action-line md:mb-8 md:h-12 md:w-16 ${center ? "mx-auto" : ""}`}
70
+ >
71
+ <path d="M0 36V22C0 10.5 6 2.6 18.2 0l2.3 5.2C14.6 7.4 11.4 11 11 16h8.6v20H0Zm27.4 0V22c0-11.5 6-19.4 18.2-22l2.3 5.2C42 7.4 38.8 11 38.4 16H47v20H27.4Z" />
72
+ </svg>
73
+ )}
74
+ {eyebrow && (
75
+ <p className="mb-4 font-heading text-[0.82rem] font-bold tracking-[0.14em] text-ink-soft uppercase">{eyebrow}</p>
76
+ )}
77
+ <blockquote className="m-0">
78
+ <p className={`m-0 font-heading font-medium tracking-[-0.015em] text-balance text-ink ${SIZE[size]}`}>{children}</p>
79
+ </blockquote>
80
+ {cite && (
81
+ <figcaption className={`mt-8 flex ${center ? "justify-center" : ""}`}>
82
+ <Byline cite people={cite.people} subtitle={cite.role} className="text-left" />
83
+ </figcaption>
84
+ )}
85
+ </Root>
86
+ );
87
+ }
@@ -66,7 +66,7 @@ export function RegistrationBox({ title, intro, options, value, onChange, action
66
66
  </span>
67
67
  <span className="flex min-w-0 flex-1 flex-wrap items-center gap-2 font-semibold text-ink">
68
68
  {o.label}
69
- {o.remaining && <span className="rounded-pill bg-ink/[0.08] px-2 py-0.5 text-xs font-semibold text-ink-soft">{o.remaining}</span>}
69
+ {o.remaining && <span className="rounded-pill bg-tint px-2 py-0.5 text-xs font-semibold text-ink-soft">{o.remaining}</span>}
70
70
  </span>
71
71
  <span className="shrink-0 font-semibold text-ink">{o.price}</span>
72
72
  </label>
@@ -0,0 +1,85 @@
1
+ "use client";
2
+
3
+ import { useRef, type InputHTMLAttributes, type ReactNode } from "react";
4
+ import { Search, X } from "lucide-react";
5
+
6
+ /** The family's field: a white pill on the page colour, warm hairline, the
7
+ * card's soft shadow. 56px tall, so it reads as the page's main control. */
8
+ export const FIELD =
9
+ "w-full min-h-14 rounded-pill border border-line bg-card text-base text-ink shadow-[0_2px_12px_color-mix(in_srgb,var(--color-shadow)_6%,transparent)] transition-[border-color,box-shadow] duration-150 outline-none hover:border-ink/35 focus-visible:border-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink disabled:cursor-not-allowed disabled:opacity-60 motion-reduce:transition-none";
10
+
11
+ /** Label of a field: Figtree semibold in `ink`, sentence case (no uppercase
12
+ * tracking: the label is read, not scanned). */
13
+ export const FIELD_LABEL = "mb-2 block text-sm font-semibold text-ink";
14
+
15
+ type Props = Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "onChange" | "type" | "children"> & {
16
+ id: string;
17
+ label: ReactNode;
18
+ value: string;
19
+ onValueChange: (value: string) => void;
20
+ /** aria-label of the clear button ("Limpar busca"). */
21
+ clearLabel?: string;
22
+ /** Short help under the field, linked by aria-describedby. */
23
+ hint?: ReactNode;
24
+ /** Keep the label for screen readers only (when a heading right above already names the field). */
25
+ hideLabel?: boolean;
26
+ className?: string;
27
+ };
28
+
29
+ /**
30
+ * SearchField — the search box of a list page (the professionals directory,
31
+ * a blog). A native `type="search"` input (Esc clears it, the phone keyboard
32
+ * shows "Search"), with the lucide magnifier on the left and a clear button
33
+ * on the right once there is text; clearing puts the focus back in the field.
34
+ * The browser's own cancel glyph is hidden so there is only one ✕.
35
+ *
36
+ * Placeholder in `ink-soft` (#54707a on white: 5.3:1 on ttcab, passes AA):
37
+ * it carries an example, so it has to be legible.
38
+ */
39
+ export function SearchField({ id, label, value, onValueChange, clearLabel = "Clear search", hint, hideLabel = false, className = "", ...rest }: Props) {
40
+ const input = useRef<HTMLInputElement>(null);
41
+ const hintId = hint ? `${id}-hint` : undefined;
42
+ return (
43
+ <div className={className}>
44
+ <label htmlFor={id} className={hideLabel ? "sr-only" : FIELD_LABEL}>
45
+ {label}
46
+ </label>
47
+ <div className="relative">
48
+ <Search aria-hidden="true" size={20} className="pointer-events-none absolute top-1/2 left-5 -translate-y-1/2 text-ink-soft" />
49
+ <input
50
+ ref={input}
51
+ id={id}
52
+ type="search"
53
+ value={value}
54
+ onChange={(e) => onValueChange(e.target.value)}
55
+ autoComplete="off"
56
+ autoCorrect="off"
57
+ autoCapitalize="none"
58
+ spellCheck={false}
59
+ enterKeyHint="search"
60
+ aria-describedby={hintId}
61
+ className={`${FIELD} pr-14 pl-13 placeholder:text-ink-soft [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none`}
62
+ {...rest}
63
+ />
64
+ {value && (
65
+ <button
66
+ type="button"
67
+ aria-label={clearLabel}
68
+ onClick={() => {
69
+ onValueChange("");
70
+ input.current?.focus();
71
+ }}
72
+ className="absolute top-1/2 right-2 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full text-ink-soft transition-colors duration-150 hover:bg-tint hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-ink motion-reduce:transition-none"
73
+ >
74
+ <X aria-hidden="true" size={18} />
75
+ </button>
76
+ )}
77
+ </div>
78
+ {hint && (
79
+ <p id={hintId} className="mt-2 px-5 text-sm text-ink-soft">
80
+ {hint}
81
+ </p>
82
+ )}
83
+ </div>
84
+ );
85
+ }