@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.
package/README.md CHANGED
@@ -52,12 +52,18 @@ For a site without a ready-made token file, define the roles on `:root` itself:
52
52
  }
53
53
  ```
54
54
 
55
+ `theme.css` also sets the family's base behaviour: smooth scrolling for anchors
56
+ (`html { scroll-behavior: smooth }`, switched off under `prefers-reduced-motion`)
57
+ and `scroll-padding` (6rem top, 7rem bottom) so keyboard focus never lands
58
+ hidden behind the sticky Header or the BottomNav (WCAG 2.4.11).
59
+
55
60
  ## Token roles
56
61
 
57
62
  | Token (Tailwind) | Role | `--cg-*` |
58
63
  |---|---|---|
59
64
  | `surface` | base background | `--cg-surface` |
60
65
  | `surface-panel` | dark panel (footer, final CTA) | `--cg-surface-panel` |
66
+ | `card` | raised surface: cards, pills, menus over the page (white on all three palettes) | `--cg-card` |
61
67
  | `ink` | primary text / headings | `--cg-ink` |
62
68
  | `ink-soft` | secondary text | `--cg-ink-soft` |
63
69
  | `ink-body` | reading body text | `--cg-ink-body` |
@@ -147,6 +153,17 @@ edited by hand: it is the one from each brand's manual.
147
153
  novo.creeta: symbol, then name, then tagline; with reduced motion it becomes
148
154
  a short fade.
149
155
 
156
+ ## Typography
157
+
158
+ The family's scale, from novo.creeta, as components: `Heading` (sizes
159
+ `display` 2.5rem→6xl · `hero` · `section` 1.75rem→4xl · `card` · `small`;
160
+ Quicksand medium in `ink`) with an optional `accent` — the second sentence of
161
+ the same heading on its own line, one tone below in `ink-soft` — and
162
+ `SectionHeading` (eyebrow/number line, title + accent, lead). `Text` for
163
+ paragraphs (`lead`, `body`, `small`, `eyebrow`, Figtree) and `Prose` for a run
164
+ of paragraphs at reading width. Two tones, one element: the hierarchy comes
165
+ from color, not from extra markup.
166
+
150
167
  ## Navigation
151
168
 
152
169
  - **Header**: brand (the app passes the link to the home page) · items · item with dropdown
@@ -168,6 +185,55 @@ a short fade.
168
185
  the code, opens the three languages; header) or `list` (plain links; the Footer
169
186
  uses it). Same page in the other language, each language written in itself,
170
187
  current marked by ✓ and `aria-current`.
188
+ - **List**: the family's list with a marker in front of each item (novo.creeta):
189
+ `marker="check"` (24px bubble, "what we assess / what we treat") or
190
+ `marker="arrow"` (corner arrow, one sentence per item, as in the Footer
191
+ notice). An item with `href` is a link: semibold, underline, outbound arrow.
192
+ - **Timeline**: the "Como cuidamos" of novo.creeta — ordered steps, each with a
193
+ 44px icon bubble, a title and a paragraph; a thin line links the drawings. A
194
+ step may end with a link (strong link + outbound arrow).
195
+ - **RowList** / **CollapseRow** / **LinkRow**: the list of equal rows at the foot
196
+ of a card (the Trajectory / Guidance / Instagram block of the ProfessionalCard),
197
+ generic. 48px rows with hairlines; a CollapseRow is a native `<details>` with a
198
+ text or a list of links (`links`, count beside the label); a LinkRow goes
199
+ somewhere (↗ another page, ↓ anchor, `external` for a new tab, `icon` to
200
+ replace the arrow). The ProfessionalCard is built on them.
201
+ - **ProfessionalCard** / **ProfessionalModal** / **CaptionBand**: the team card of
202
+ novo.creeta — portrait (plain, living clip on hover, or with the "Presentation"
203
+ chip), name, role, registration (**always**: COFFITO 414/2012), trajectory /
204
+ guidance / Instagram rows; the modal plays the presentation with sound, the
205
+ live caption band (active WebVTT cue) above the name, and the full bio.
206
+ - **Byline** / **PersonChip** / **Avatar**: who signs or explains content
207
+ (novo.creeta) — stacked portraits + eyebrow + linked names, or portrait + name
208
+ + "Watch the video ↗"; the person chip (portrait, name, role · registration,
209
+ arrow bubble when it links to the person's card). No photo → initials bubble.
210
+ - **Breadcrumb**: the article trail "← Section › Parent" (current page not listed).
211
+ - **FactStrip**: the three facts under the hero of novo.creeta — icon bubble +
212
+ two lines of equal weight per fact, thin dividers between columns (rows on
213
+ phones); the separator between the lines is read by screen readers only.
214
+ - **InfoCard**: the white info cards of the clinic page (icon bubble, title,
215
+ lines/address, note, outbound links "Directions ↗ · Waze ↗").
216
+ - **MediaCard**: photo or muted clip behind a title, badge top-left, arrow button
217
+ top-right, whole card a link; Ken Burns zoom on hover (mouse only), clip plays
218
+ while visible or on hover; pause hooks exported for a page-level pause button.
219
+ The portrait variant ("Who created the method"): `eyebrow` + title + `linkLabel`
220
+ ("See the team ↗") with `arrowButton={false}`.
221
+ - **VideoStoryCard**: the vertical Instagram-style video card (poster, play
222
+ button, duration badge; gradient grows and the lead appears on hover/focus;
223
+ tap plays with sound in place; optional muted hover preview). When the clip
224
+ ends the card returns to the poster with a "Watched" badge (check + word,
225
+ top-right): `watched` sets it from the site, `onWatched` fires once at the end,
226
+ `remember` keeps the set of watched clips in localStorage (by `src`). Exports
227
+ the page-level pause contract (`CARD_VIDEOS_PAUSE_EVENT` / `CARD_VIDEOS_PAUSED_ATTR`).
228
+ - **TestimonialCard**: clip with a play button on the left, quote + author +
229
+ outbound link on the right ("Famílias contam").
230
+ - **MapCard**: the Mapa of novo.creeta — a still preview fading into the card,
231
+ the address, "View the map" and the outbound "Directions" link; Google is only
232
+ called after the tap (no consent banner, lighter page). Open, the map fills
233
+ the card with the same close bar as the hero video.
234
+ - **CtaCard** / **VideoInviteCard**: the final call of a novo.creeta page — white
235
+ card (title, lead, CTA slot) beside a video card: muted loop with the speaker's
236
+ name over it, pause control, play button that plays the full video with sound.
171
237
  - **Footer**: the site-footer of novo.creeta, by tokens — full width like the
172
238
  Header, with the signed brand + sentence, "Pages" (title on the first line, links in two
173
239
  columns), "Where we are" (address, hours, "Directions ↗ · Waze ↗"), an optional
@@ -182,7 +248,7 @@ a short fade.
182
248
  ## Roadmap
183
249
 
184
250
  - [x] v0.1 — tokens (preset + ttcab/creeta palettes) + preview
185
- - [x] Accordion (1st component; injectable-deps pattern)
251
+ - [x] Accordion (1st component; injectable-deps pattern) — the Perguntas of novo.creeta: white card (`bg-card`), 22px radius, no border
186
252
  - [x] Family typography (Quicksand + Figtree, free fonts only)
187
253
  - [ ] **novo.creeta scales** (full reference, approved): **typography** (sizes/weights/line-height)
188
254
  **+ spacing/rhythm** (section padding, gaps) **+ default border-radius** → become DS tokens
@@ -190,17 +256,13 @@ a short fade.
190
256
  import/inject it (without each site depending on lucide directly)
191
257
  - [ ] **MediaCard** (video/image behind + icon badge + title + lead + arrow;
192
258
  **horizontal carousel on mobile** variant with progress bar + pause) — approved
193
- - [ ] **TestimonialCard** — approved. Variants: **quote** (text card),
194
- **video hero** (full-bleed with overlaid quote), **video beside** ("Famílias contam": video + text).
195
- Consider a **mix** of the two. **Already exists in ttcab** (TestimonialVideoHero/Card) → extract/generalize.
196
- - [ ] **VideoStoryCard** (short vertical video) — approved. Play + duration badge +
197
- title + lead + **"Read the transcript"** (collapsible, reuses the Accordion). Pattern of the Videos page.
198
- _Note: the card can still improve in a future update (fine to leave for later)._
199
- - [ ] **ProfessionalCard** (team) — approved. Two versions: **small** (photo + name, in a row)
259
+ - [x] **TestimonialCard** — from novo.creeta (10/29): clip + play on the left, quote/author/link on the right. The "video hero" and "video beside" variants from ttcab are still open
260
+ - [x] **VideoStoryCard** — from novo.creeta (10/29): vertical card, play + duration badge, lead on hover/focus, plays with sound in place; "Read the transcript" goes in `children` (Accordion)
261
+ - [x] **ProfessionalCard** (team) — from novo.creeta (10/29): small = PersonChip/Byline, big = the card with living portrait, presentation modal + captions. Original note: Two versions: **small** (photo + name, in a row)
200
262
  and **big** (photo + name + role + CREFITO + "Trajetória"/"Orientações" accordions).
201
263
  **Video** variant: "Apresentação" badge, hover plays the video, and a **modal** (video + bio).
202
264
  **Data rule:** every professional MUST have a **CREFITO** attached (COFFITO requirement).
203
- - [x] **PillList** (chips like "Condições que atendemos": label + arrow, wrapping grid, "View all" link)
265
+ - [x] **Pill / PillList** — the Atalho of novo.creeta (white 44px pill, arrow bubble: ↓ anchor, ↗ other page, optional meta); wrapping row + "See all"
204
266
  - [x] **AnchorNav** (floating capsule of anchors, scroll-spy + sliding indicator) — extracted from ttcab (10/29)
205
267
  - [x] **Footer** · **PageVideoHero** (loop and player) · **Header** (dropdown, house) · **BottomNav** · **Curtain** · **Lockup** (5 arrangements) · **BreatheMark**
206
268
  - [x] **Site brand marks** (`components/marks/`): real symbol + wordmark for TTCAB and Creeta (10/29)
@@ -51,7 +51,8 @@ export function Accordion({ items, defaultOpenId, single = true }: Props) {
51
51
  return (
52
52
  <div
53
53
  key={it.id}
54
- className="overflow-hidden rounded-2xl border border-line bg-surface"
54
+ // the Perguntas of novo.creeta: a white card (bg-card), 22px radius, no border — it reads by tone over the page
55
+ className="overflow-hidden rounded-[22px] bg-card px-5 md:px-7"
55
56
  >
56
57
  <h3 className="m-0">
57
58
  <button
@@ -60,13 +61,13 @@ export function Accordion({ items, defaultOpenId, single = true }: Props) {
60
61
  aria-expanded={isOpen}
61
62
  aria-controls={panelId}
62
63
  onClick={() => toggle(it.id)}
63
- className="flex w-full items-center justify-between gap-4 px-[clamp(18px,3vw,28px)] py-[clamp(16px,2.4vw,22px)] text-left font-heading text-[1.05rem] font-semibold text-ink transition-colors focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ink"
64
+ className="flex min-h-14 w-full items-center justify-between gap-4 py-1 text-left font-heading text-lg font-medium text-ink transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink"
64
65
  >
65
66
  <span>{it.title}</span>
66
67
  <svg
67
68
  aria-hidden="true"
68
69
  viewBox="0 0 24 24"
69
- className={`h-5 w-5 flex-none text-ink-soft transition-transform duration-200 ease-[cubic-bezier(.2,.8,.2,1)] motion-reduce:transition-none ${
70
+ className={`h-5 w-5 flex-none text-ink transition-transform duration-200 ease-[cubic-bezier(.2,.8,.2,1)] motion-reduce:transition-none ${
70
71
  isOpen ? "rotate-180" : ""
71
72
  }`}
72
73
  fill="none"
@@ -91,7 +92,8 @@ export function Accordion({ items, defaultOpenId, single = true }: Props) {
91
92
  }`}
92
93
  >
93
94
  <div className="min-h-0 overflow-hidden">
94
- <div className="px-[clamp(18px,3vw,28px)] pt-0 pb-[clamp(18px,2.6vw,26px)] text-ink-body">
95
+ {/* full-width item, reading-width body (max-w-2xl), as the Perguntas of novo.creeta */}
96
+ <div className="max-w-2xl pb-5 leading-relaxed text-ink/80">
95
97
  {it.content}
96
98
  </div>
97
99
  </div>
@@ -0,0 +1,37 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ type Props = {
4
+ /** Person's name: the initials fallback and nothing else (the image is decorative). */
5
+ name: string;
6
+ /** Photo src. Without it, the initials in a tinted bubble (as the team cards of novo.creeta). */
7
+ photo?: string;
8
+ /** Your own image element (e.g. next/image), replaces `photo`. */
9
+ children?: ReactNode;
10
+ /** Side in px. */
11
+ size?: number;
12
+ /** White ring, for stacked groups. */
13
+ ring?: boolean;
14
+ className?: string;
15
+ };
16
+
17
+ export const initials = (name: string) =>
18
+ name
19
+ .trim()
20
+ .split(/\s+/)
21
+ .filter((w) => w.length > 2 || w === name)
22
+ .slice(0, 2)
23
+ .map((w) => w[0]?.toUpperCase() ?? "")
24
+ .join("");
25
+
26
+ /** Round portrait, decorative (the name is written next to it). */
27
+ export function Avatar({ name, photo, children, size = 40, ring = false, className = "" }: Props) {
28
+ const style = { width: size, height: size, fontSize: Math.round(size * 0.36) };
29
+ const base = `inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full ${ring ? "ring-2 ring-card" : ""} ${className}`;
30
+ if (children) return <span aria-hidden="true" className={base} style={style}>{children}</span>;
31
+ if (photo) return <img src={photo} alt="" loading="lazy" decoding="async" className={`${base} object-cover`} style={style} />;
32
+ return (
33
+ <span aria-hidden="true" className={`${base} bg-ink/[0.12] font-heading font-bold text-ink`} style={style}>
34
+ {initials(name)}
35
+ </span>
36
+ );
37
+ }
@@ -282,7 +282,7 @@ const CSS = `
282
282
  .cg-more-panel{
283
283
  position:fixed; inset:auto .75rem calc(5.75rem + env(safe-area-inset-bottom)) .75rem;
284
284
  width:auto; margin:0; border:0; border-radius:26px; padding:.5rem;
285
- background:#fff; color:var(--color-ink);
285
+ background:var(--color-card); color:var(--color-ink);
286
286
  box-shadow:0 20px 48px -16px rgb(0 0 0 / .35), 0 1px 2px rgb(0 0 0 / .1);
287
287
  opacity:0; transform:translateY(8px) scale(.97); transform-origin:88% 100%;
288
288
  transition:opacity 180ms cubic-bezier(.23,1,.32,1), transform 180ms cubic-bezier(.23,1,.32,1), overlay 180ms allow-discrete, display 180ms allow-discrete;
@@ -0,0 +1,45 @@
1
+ import type { ElementType, ReactNode } from "react";
2
+
3
+ export type Crumb = { label: ReactNode; href: string };
4
+
5
+ type Props = {
6
+ /** From the section the page belongs to down to the parent of the current page. */
7
+ items: Crumb[];
8
+ linkComponent?: ElementType;
9
+ /** aria-label of the nav. */
10
+ label?: string;
11
+ className?: string;
12
+ };
13
+
14
+ // trail link: no resting underline (two underlines side by side weighed too much);
15
+ // it shows on hover and on focus
16
+ const link =
17
+ "inline-flex min-h-11 items-center text-ink underline decoration-transparent underline-offset-4 transition-[text-decoration-color] duration-150 hover:decoration-ink focus-visible:decoration-ink focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none";
18
+
19
+ /**
20
+ * Breadcrumb — the trail of a novo.creeta article: "← Section › Parent", so
21
+ * the text starts right away. The first crumb carries the back arrow; the
22
+ * current page is not listed (its title follows).
23
+ */
24
+ export function Breadcrumb({ items, linkComponent, label = "Trail", className = "" }: Props) {
25
+ const L: ElementType = linkComponent ?? "a";
26
+ return (
27
+ <nav aria-label={label} className={className}>
28
+ <ol className="m-0 flex list-none flex-wrap items-center gap-x-2 p-0 text-sm font-semibold text-ink/80">
29
+ {items.map((c, i) => (
30
+ <li key={`${i}-${c.href}`} className="flex items-center gap-x-2">
31
+ {i > 0 && (
32
+ <svg aria-hidden="true" viewBox="0 0 24 24" width={14} height={14} className="text-ink/50" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m9 18 6-6-6-6" /></svg>
33
+ )}
34
+ <L href={c.href} className={`${link} ${i === 0 ? "gap-1.5" : ""}`}>
35
+ {i === 0 && (
36
+ <svg aria-hidden="true" viewBox="0 0 24 24" width={16} height={16} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m12 19-7-7 7-7M19 12H5" /></svg>
37
+ )}
38
+ {c.label}
39
+ </L>
40
+ </li>
41
+ ))}
42
+ </ol>
43
+ </nav>
44
+ );
45
+ }
@@ -27,19 +27,6 @@ export function BreatheMark({
27
27
  }: Props) {
28
28
  return (
29
29
  <span className={className} style={{ display: "inline-flex", lineHeight: 0 }}>
30
- <style>{`
31
- @keyframes breathe-mark-inhale {
32
- 0%, 100% { transform: scale(0.93); }
33
- 50% { transform: scale(1.05); }
34
- }
35
- .breathe-mark__g {
36
- transform-box: fill-box;
37
- transform-origin: center;
38
- animation: breathe-mark-inhale var(--breathe-dur, 6s) cubic-bezier(0.37, 0, 0.63, 1) infinite;
39
- }
40
- .breathe-mark--static .breathe-mark__g { animation: none; }
41
- @media (prefers-reduced-motion: reduce) { .breathe-mark__g { animation: none; } }
42
- `}</style>
43
30
  <svg
44
31
  width={size}
45
32
  height={size}
@@ -0,0 +1,103 @@
1
+ import type { ElementType, ReactNode } from "react";
2
+ import { Avatar } from "./Avatar";
3
+
4
+ export type Person = { name: string; href?: string; photo?: string; avatar?: ReactNode };
5
+
6
+ type Props = {
7
+ people: Person[];
8
+ /** Small line above the names ("Who explains"). With it, the avatars stack
9
+ * and the names go on their own line; without it, one inline line:
10
+ * avatar · name · action. */
11
+ eyebrow?: ReactNode;
12
+ /** Link after the name(s) ("Watch the video ↗"). */
13
+ action?: { label: ReactNode; href: string; external?: boolean };
14
+ /** Joiners between names: ", " and " and ". */
15
+ joiners?: { sep: string; last: string };
16
+ /** Wrap the names in <cite> (a testimonial's author). */
17
+ cite?: boolean;
18
+ /** sr-only after an external action link. */
19
+ newTabLabel?: string;
20
+ linkComponent?: ElementType;
21
+ className?: string;
22
+ };
23
+
24
+ const ArrowUpRight = () => (
25
+ <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>
26
+ );
27
+
28
+ const nameLink =
29
+ "underline decoration-ink/30 underline-offset-4 transition-colors duration-150 hover:decoration-ink motion-reduce:transition-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink";
30
+
31
+ /**
32
+ * Byline — who signs or explains a piece of content, from novo.creeta:
33
+ * - group ("Quem explica" under the timeline): stacked portraits, an eyebrow
34
+ * and the names, each one a link to the person's card;
35
+ * - inline (under a topic card): portrait, name and "Watch the video ↗".
36
+ * Portraits are decorative: the names carry the information.
37
+ */
38
+ export function Byline({ people, eyebrow, action, joiners = { sep: ", ", last: " and " }, cite = false, newTabLabel = "(opens in a new tab)", linkComponent, className = "" }: Props) {
39
+ const L: ElementType = linkComponent ?? "a";
40
+ // portraits stack with a ring (as on the site); initials would lose their letters, so they sit side by side
41
+ const stack = people.length > 1 && people.every((p) => p.photo || p.avatar);
42
+ const row = `flex shrink-0 ${stack ? "-space-x-2" : "gap-1.5"}`;
43
+ const Names: ElementType = cite ? "cite" : "span";
44
+ const names = people.map((p, i) => (
45
+ <span key={p.name}>
46
+ {i > 0 && (i === people.length - 1 ? joiners.last : joiners.sep)}
47
+ {p.href ? (
48
+ <L href={p.href} className={nameLink}>{p.name}</L>
49
+ ) : (
50
+ p.name
51
+ )}
52
+ </span>
53
+ ));
54
+ const actionEl = action ? (
55
+ <L
56
+ href={action.href}
57
+ {...(action.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
58
+ className="inline-flex min-h-11 items-center gap-1 font-semibold text-ink underline underline-offset-4"
59
+ >
60
+ {action.label}
61
+ <ArrowUpRight />
62
+ {action.external && <span className="sr-only"> {newTabLabel}</span>}
63
+ </L>
64
+ ) : null;
65
+
66
+ if (eyebrow) {
67
+ return (
68
+ <div className={`flex items-center gap-3 ${className}`}>
69
+ <span className={row}>
70
+ {people.map((p) => (
71
+ <Avatar key={p.name} name={p.name} photo={p.photo} size={36} ring={stack}>{p.avatar}</Avatar>
72
+ ))}
73
+ </span>
74
+ <div className="leading-snug">
75
+ <p className="m-0 text-sm text-ink/80">{eyebrow}</p>
76
+ <p className="m-0 mt-0.5 font-semibold text-ink">
77
+ <Names className="not-italic">{names}</Names>
78
+ {actionEl && <> · {actionEl}</>}
79
+ </p>
80
+ </div>
81
+ </div>
82
+ );
83
+ }
84
+
85
+ return (
86
+ <p className={`m-0 flex items-center gap-3 text-sm text-ink/80 ${className}`}>
87
+ {people.length === 1 ? (
88
+ <Avatar name={people[0].name} photo={people[0].photo} size={40}>{people[0].avatar}</Avatar>
89
+ ) : (
90
+ <span className={row}>
91
+ {people.map((p) => (
92
+ <Avatar key={p.name} name={p.name} photo={p.photo} size={36} ring={stack}>{p.avatar}</Avatar>
93
+ ))}
94
+ </span>
95
+ )}
96
+ <span className="flex flex-wrap items-center gap-x-2">
97
+ <Names className="not-italic font-semibold text-ink">{names}</Names>
98
+ {actionEl}
99
+ </span>
100
+ </p>
101
+ );
102
+ }
103
+
@@ -0,0 +1,74 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+
5
+ /**
6
+ * The line being spoken right now, read from the video's WebVTT <track>
7
+ * (novo.creeta's `useLegenda`, components/legenda.tsx).
8
+ *
9
+ * The captions are not drawn over the picture: many of the site's videos
10
+ * already carry burned-in Portuguese captions, in positions that vary, and one
11
+ * on top of the other is unreadable. The track is the browser's (a hidden
12
+ * <track> it loads and syncs itself) and the text comes out beside/under the
13
+ * video, in `CaptionBand`.
14
+ *
15
+ * Usage: `const { line, attach } = useCaption()` and `<track ref={attach} … />`.
16
+ */
17
+ export function useCaption() {
18
+ const [line, setLine] = useState("");
19
+
20
+ const attach = useCallback((el: HTMLTrackElement | null) => {
21
+ const track = el?.track;
22
+ if (!track) return;
23
+ // 'hidden': the browser loads the file and fires the events, but does not draw
24
+ track.mode = "hidden";
25
+ track.addEventListener("cuechange", () => {
26
+ const cue = track.activeCues?.[0] as VTTCue | undefined;
27
+ setLine(cue ? cue.text : "");
28
+ });
29
+ }, []);
30
+
31
+ return { line, attach };
32
+ }
33
+
34
+ type Props = {
35
+ /** The current cue text (from `useCaption`). Empty between cues: the band
36
+ * stays, so the layout does not jump at every line. */
37
+ text: string;
38
+ /** BCP 47 tag of the captions ("en", "pt-BR"). */
39
+ lang: string;
40
+ className?: string;
41
+ };
42
+
43
+ /**
44
+ * Caption band of the Cuidado Global family (novo.creeta's `FaixaDeLegenda`).
45
+ * Fixed height of three lines so the page does not jump at every line. White
46
+ * on `ink` (creeta's petrol: 10.2:1). Hidden from screen readers: who neither
47
+ * hears nor sees uses the transcript on the page; here the text would change
48
+ * every two seconds.
49
+ *
50
+ * When it appears (the person pressed play) the page scrolls the least needed
51
+ * for the band to be in view: on phones it would be born behind the bottom
52
+ * bar, and the person would watch without knowing there are captions. The
53
+ * scroll margin is the bar's height.
54
+ */
55
+ export function CaptionBand({ text, lang, className = "" }: Props) {
56
+ const band = useRef<HTMLParagraphElement>(null);
57
+
58
+ useEffect(() => {
59
+ const calm = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
60
+ band.current?.scrollIntoView?.({ block: "nearest", behavior: calm ? "auto" : "smooth" });
61
+ }, []);
62
+
63
+ return (
64
+ <p
65
+ ref={band}
66
+ aria-hidden="true"
67
+ lang={lang}
68
+ data-caption-band=""
69
+ className={`mt-2 flex min-h-[4.75rem] scroll-mb-28 items-center justify-center rounded-[20px] bg-ink px-4 py-2.5 text-center text-[0.9375rem] font-medium leading-snug text-white md:scroll-mb-6 ${className}`}
70
+ >
71
+ {text}
72
+ </p>
73
+ );
74
+ }
@@ -0,0 +1,52 @@
1
+ import type { ReactNode } from "react";
2
+ import { VideoInviteCard, type VideoInviteCardProps } from "./VideoInviteCard";
3
+
4
+ type Props = {
5
+ /** The question that closes the page, e.g. "Shall we find out what your body needs?". */
6
+ title: ReactNode;
7
+ /** Supporting line under the title. Omit it to drop the paragraph. */
8
+ lead?: ReactNode;
9
+ /** The call to action — pass the DS `Button` (variant="accent" on the family's sites). */
10
+ action: ReactNode;
11
+ /** Side card with the speaker's video (VideoInviteCard props). Omit for the text card alone. */
12
+ video?: Omit<VideoInviteCardProps, "className">;
13
+ /** Any other side media (e.g. a still photo), placed in the same frame as the video. */
14
+ media?: ReactNode;
15
+ className?: string;
16
+ };
17
+
18
+ /** Side frame: 3:2 on mobile (video on top), free height from md on, same radius as the text card. */
19
+ const sideFrame = "aspect-[3/2] md:order-2 md:aspect-auto md:min-h-[340px]";
20
+
21
+ /**
22
+ * Final call of the Cuidado Global family — mirrors the `ChamadaFinal` of
23
+ * novo.creeta with the Breathe tokens: every page ends with the same
24
+ * invitation, in the same place.
25
+ *
26
+ * Two cards side by side, on the same grid as the site's other card pairs
27
+ * (gap-3, gap-5 from md): the text on a white card — title, lead and the CTA —
28
+ * and the video on another, each with its own four corners (28px). On mobile
29
+ * the video comes first. Without video/media it is a single card.
30
+ *
31
+ * The white card keeps ink text (10.21:1 on creeta); the lead is ink at 80%
32
+ * (5.78:1 on white). The CTA is a slot: the site passes its own Button/link.
33
+ */
34
+ export function CtaCard({ title, lead, action, video, media, className = "" }: Props) {
35
+ const hasSide = Boolean(video || media);
36
+
37
+ return (
38
+ <section className={`text-ink ${hasSide ? "grid gap-3 md:grid-cols-2 md:gap-5" : ""} ${className}`}>
39
+ {video ? (
40
+ <VideoInviteCard {...video} className={sideFrame} />
41
+ ) : media ? (
42
+ <div className={`relative overflow-hidden rounded-2xl ${sideFrame}`}>{media}</div>
43
+ ) : null}
44
+ <div className="flex flex-col justify-center rounded-2xl bg-card px-6 py-10 md:px-10 md:py-14">
45
+ <h2 className="max-w-xl font-heading text-3xl leading-[1.1] font-medium md:text-4xl">{title}</h2>
46
+ {lead && <p className="mt-4 max-w-md text-ink/80">{lead}</p>}
47
+ {/* The button follows the site's pattern (accent on white), same as the header's "Book". */}
48
+ <div className="mt-7">{action}</div>
49
+ </div>
50
+ </section>
51
+ );
52
+ }
@@ -0,0 +1,55 @@
1
+ import type { ComponentType, ReactNode } from "react";
2
+
3
+ type IconType = ComponentType<{ size?: number; strokeWidth?: number; className?: string; "aria-hidden"?: boolean | "true" }>;
4
+
5
+ export type Fact = {
6
+ Icon: IconType;
7
+ /** First line, strong ("Monday to Friday"). */
8
+ title: ReactNode;
9
+ /** Second line, lighter ("8 am to 6 pm"). */
10
+ text?: ReactNode;
11
+ /** Read between the two lines by screen readers only, so the sentence
12
+ * still reads whole (", " or " "). Default: ", ". */
13
+ separator?: string;
14
+ };
15
+
16
+ type Props = {
17
+ facts: Fact[];
18
+ /** aria-label of the list ("How we see patients"). */
19
+ label?: string;
20
+ className?: string;
21
+ };
22
+
23
+ /**
24
+ * Fact strip — the three facts under the hero of novo.creeta ("Monday to
25
+ * Friday / 8 am to 6 pm", "At the clinic / or at home", "Individual care /
26
+ * from babies to the elderly"). Each fact is an icon bubble plus two lines of
27
+ * equal visual weight, so none wraps differently from the others. On desktop
28
+ * a thin line separates the columns; on phones it separates the rows.
29
+ */
30
+ export function FactStrip({ facts, label, className = "" }: Props) {
31
+ const cols = facts.length === 2 ? "md:grid-cols-2" : facts.length === 4 ? "md:grid-cols-4" : "md:grid-cols-3";
32
+ return (
33
+ <ul
34
+ aria-label={label}
35
+ className={`m-0 list-none divide-y divide-ink/10 rounded-2xl bg-card px-6 md:grid ${cols} md:divide-x md:divide-y-0 md:px-0 md:py-7 ${className}`}
36
+ >
37
+ {facts.map(({ Icon, title, text, separator = ", " }, i) => (
38
+ <li key={i} className="flex items-center gap-4 py-5 md:px-8 md:py-0 lg:px-10">
39
+ <span aria-hidden="true" className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-ink/[0.12] text-ink">
40
+ <Icon size={22} strokeWidth={1.75} />
41
+ </span>
42
+ <p className="m-0 leading-snug">
43
+ <span className="block font-heading text-lg font-medium text-ink">{title}</span>
44
+ {text && (
45
+ <>
46
+ <span className="sr-only">{separator}</span>
47
+ <span className="block text-[0.9375rem] text-ink/80">{text}</span>
48
+ </>
49
+ )}
50
+ </p>
51
+ </li>
52
+ ))}
53
+ </ul>
54
+ );
55
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ComponentType, ElementType, ReactNode } from "react";
2
2
  import { LanguageSwitcher, type Language } from "./LanguageSwitcher";
3
+ import { List } from "./List";
3
4
 
4
5
  /** lucide-style icon component (the footer sets the size). */
5
6
  type IconType = ComponentType<{ size?: number; strokeWidth?: number; className?: string; "aria-hidden"?: boolean | "true" }>;
@@ -56,9 +57,6 @@ type Props = {
56
57
  const ArrowUpRight = () => (
57
58
  <svg aria-hidden="true" viewBox="0 0 24 24" width={18} height={18} className="cg-arrow-go" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
58
59
  );
59
- const CornerDownRight = () => (
60
- <svg aria-hidden="true" viewBox="0 0 24 24" width={18} height={18} className="mt-[0.2rem] shrink-0" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m15 10 5 5-5 5" /><path d="M4 4v7a4 4 0 0 0 4 4h12" /></svg>
61
- );
62
60
 
63
61
  const title = "font-heading text-lg font-medium text-ink";
64
62
  const link =
@@ -104,8 +102,7 @@ export function Footer({
104
102
  const noticeId = notice?.id ?? "footer-notice";
105
103
 
106
104
  return (
107
- <footer className={`${outer} bg-white py-10 text-ink md:py-14 ${className}`}>
108
- <style>{CSS}</style>
105
+ <footer className={`${outer} bg-card py-10 text-ink md:py-14 ${className}`}>
109
106
  <div className="mx-auto max-w-[1600px] px-4 md:px-6">
110
107
  {/* without the pages column, "where" keeps to the right edge (auto width) */}
111
108
  <div className={`grid gap-x-10 gap-y-10 ${pages ? "md:grid-cols-[minmax(0,4fr)_minmax(0,5fr)_minmax(0,3fr)]" : "md:grid-cols-[minmax(0,1fr)_auto]"}`}>
@@ -169,14 +166,7 @@ export function Footer({
169
166
  <h2 id={noticeId} className="font-heading text-2xl font-medium leading-snug md:text-[1.75rem]">
170
167
  {notice.title}
171
168
  </h2>
172
- <ul className="mt-4 list-none space-y-2 leading-relaxed text-ink/80">
173
- {notice.items.map((item, i) => (
174
- <li key={i} className="flex gap-2.5">
175
- <CornerDownRight />
176
- <span>{item}</span>
177
- </li>
178
- ))}
179
- </ul>
169
+ <List marker="arrow" className="mt-4" items={notice.items.map((label) => ({ label }))} />
180
170
  </div>
181
171
  {notice.action && <div className="w-full shrink-0 sm:w-auto sm:self-start lg:self-center [&>*]:w-full [&>*]:justify-center sm:[&>*]:w-auto">{notice.action}</div>}
182
172
  </div>
@@ -215,10 +205,3 @@ export function Footer({
215
205
  );
216
206
  }
217
207
 
218
- /* Outbound arrow: takes a small step toward where the link goes, on hover. */
219
- const CSS = `
220
- @media (prefers-reduced-motion:no-preference){
221
- .cg-arrow-go{ transition:transform 150ms var(--ease-cg,cubic-bezier(.2,.8,.2,1)); }
222
- a:hover .cg-arrow-go{ transform:translate(2px,-2px); }
223
- }
224
- `;