@breathe-ds/ui 0.3.0 → 0.4.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
@@ -69,7 +69,17 @@ hidden behind the sticky Header or the BottomNav (WCAG 2.4.11).
69
69
  | `ink-body` | reading body text | `--cg-ink-body` |
70
70
  | `action` | CTA / highlight — **never plain text** | `--cg-action` |
71
71
  | `action-text` | text in the action color (AA) | `--cg-action-text` |
72
+ | `on-action` | text ON the action color (the accent button) | `--cg-on-action` |
73
+ | `action-tint` | highlighted panel or card: a 6% wash of the action color over the surface | `--cg-action-tint` |
74
+ | `action-line` | border of a highlighted card (closed tone, 3:1 on the surface; pure ttcab orange is 1.91:1) | `--cg-action-line` |
72
75
  | `line` | hairlines / borders | `--cg-line` |
76
+
77
+ **Dose of the action color** (what brands with a hot accent do, Cloudflare orange,
78
+ Airbnb red): the action color is the fill of ONE element per block, with
79
+ `on-action` text on it; links stay in `ink`/`ink-soft` with an underline; a
80
+ highlighted card is `action-tint` + `action-line`, not the full color; the
81
+ color as text only as `action-text`. The "Action dose" section of the playground
82
+ shows the disciplined and the loud version side by side.
73
83
  | `font-heading` / `font-body` | typography | `--cg-font-*` |
74
84
  | `radius-xl/2xl/pill` · `ease-cg` | radius and motion curve | fixed |
75
85
 
@@ -82,10 +92,11 @@ Values from the standard WCAG formula (the same ones `preview/` computes live):
82
92
  | surface `#fbf5e9` / ink `#144753` | 9.41:1 | AAA |
83
93
  | surface `#fbf5e9` / ink-soft `#54707a` | 4.86:1 | AA |
84
94
  | surface `#fbf5e9` / ink-body `#1e4f5b` | 8.32:1 | AAA |
85
- | surface `#fbf5e9` / action-text `#9a5d07` | 4.90:1 | AA |
95
+ | surface `#fbf5e9` / action-text `#bd3f0c` | 4.98:1 | AA |
96
+ | action `#e8541e` / on-action `#ffffff` (accent button) | 3.67:1 | 3:1 UI component; below AA text, conscious exception |
86
97
  | surface-panel `#144753` / cream text | 9.41:1 | AAA |
87
98
 
88
- `action` `#f6a22a` is decorative/CTA only — do not use it as text on cream.
99
+ `action` `#e8541e` is a vivid red-orange with white text, the Cloudflare/Airbnb route: 3.67:1 passes the 3:1 of UI components, not the 4.5:1 of normal text. A conscious exception, limited to the accent Button (one per block, same type scale as the other buttons so they never look mismatched); small controls (chips, tabs, segments) use `ink` or `action-tint` + `action-text` instead. The UI orange does not match the logo's `#f6a22a` on purpose. As text, use `action-text`.
89
100
 
90
101
  ## Family typography
91
102
 
@@ -200,9 +211,17 @@ from color, not from extra markup.
200
211
  replace the arrow). The ProfessionalCard is built on them.
201
212
  - **ProfessionalCard** / **ProfessionalModal** / **CaptionBand**: the team card of
202
213
  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.
214
+ chip), name, role, registration (**always**: COFFITO 414/2012) and generic
215
+ `rows` (collapse with text or links, link with arrow or icon — on creeta:
216
+ trajectory, guidance, Instagram); the modal plays the presentation with sound,
217
+ the live caption band (active WebVTT cue) above the name, and the full `bio`.
218
+ Three sizes (`size`): `full` (team page), `medium` (the home's tile — 3:4
219
+ portrait, name, "Meet" badge on hover, a link to the full card; `shortName`,
220
+ `href`) and `micro` (the "who treats" row — 72px photo, name, role, link).
221
+ - **Rail** / **RailItem**: novo.creeta's `Trilho` — on the phone a snapping
222
+ horizontal rail with a progress bar underneath (shown only when it scrolls),
223
+ a grid from md (`grid` classes written in full). RailItem presets the phone
224
+ width: `wide` (treatments, videos) or `narrow` (team).
206
225
  - **Byline** / **PersonChip** / **Avatar**: who signs or explains content
207
226
  (novo.creeta) — stacked portraits + eyebrow + linked names, or portrait + name
208
227
  + "Watch the video ↗"; the person chip (portrait, name, role · registration,
@@ -234,9 +253,33 @@ from color, not from extra markup.
234
253
  - **CtaCard** / **VideoInviteCard**: the final call of a novo.creeta page — white
235
254
  card (title, lead, CTA slot) beside a video card: muted loop with the speaker's
236
255
  name over it, pause control, play button that plays the full video with sound.
256
+ - **CareSteps**: "how the care works" of novo.creeta — a bento mosaic of
257
+ numbered steps (drawing above, text below; the first takes two columns) with
258
+ the invite on the dark panel closing the sequence. The drawings are slots:
259
+ **ChatPreview** (a sample chat, bubble by bubble, the clinic "types"; loops
260
+ while on screen, pause button), **PlanTimeline** (three stages, the line comes
261
+ down and each dot lights up; 6s loop, pause button), **PhotoSequence** (photos
262
+ with fade and a slow zoom, CSS only, paused by a hidden checkbox), or a still
263
+ image. All stand still and whole with reduced motion. `useOnAppear` (animate
264
+ when half on screen, only while on screen) and `PauseButton` (44px, 32px
265
+ circle) are exported for your own drawings.
266
+ - **Blog**: creeta's "guidance" area — **PostCard** (the card of the list:
267
+ cover, category, title as the link stretched over the card, excerpt in three
268
+ lines, author · date), **FeaturedPosts** ("in the words of who treats you":
269
+ bento, the first post wide with the cover beside the text, a Byline with the
270
+ speaker and "Watch the video ↗" at the foot) and **PostArticle** (the page:
271
+ trail, title, PersonChip and date, the text with its notice, the video beside
272
+ it sticky from md, the transcript open; children for "other posts" and the
273
+ final call).
274
+ - **StoryPanel**: creeta's "origin" block — one dark panel (surface-panel)
275
+ grouping a story: media at the top with a film that fades into the panel, the
276
+ logo at its base, title, lead, three pillars in columns with a hairline above
277
+ each, and the outbound links. For "who we are" and "why us" blocks.
237
278
  - **Footer**: the site-footer of novo.creeta, by tokens — full width like the
238
279
  Header, with the signed brand + sentence, "Pages" (title on the first line, links in two
239
- columns), "Where we are" (address, hours, "Directions ↗ · Waze ↗"), an optional
280
+ columns; with many pages, `pages.groups` — titled columns, up to four side by
281
+ side, so the list gets wider instead of taller), "Where we are" (address, hours,
282
+ "Directions ↗ · Waze ↗"), an optional
240
283
  notice band on `surface`, the legal line with the **CNPJ (always required in
241
284
  the family)** plus the contact chips, and the languages as plain links.
242
285
  `navHiddenFrom` keeps room at the bottom for the BottomNav.
@@ -264,7 +307,7 @@ from color, not from extra markup.
264
307
  **Data rule:** every professional MUST have a **CREFITO** attached (COFFITO requirement).
265
308
  - [x] **Pill / PillList** — the Atalho of novo.creeta (white 44px pill, arrow bubble: ↓ anchor, ↗ other page, optional meta); wrapping row + "See all"
266
309
  - [x] **AnchorNav** (floating capsule of anchors, scroll-spy + sliding indicator) — extracted from ttcab (10/29)
267
- - [x] **Footer** · **PageVideoHero** (loop and player) · **Header** (dropdown, house) · **BottomNav** · **Curtain** · **Lockup** (5 arrangements) · **BreatheMark**
310
+ - [x] **Footer** · **PageVideoHero** (loop, and player from a YouTube id or the site's own mp4) · **Header** (dropdown, house) · **BottomNav** · **Curtain** · **Lockup** (5 arrangements) · **BreatheMark**
268
311
  - [x] **Site brand marks** (`components/marks/`): real symbol + wordmark for TTCAB and Creeta (10/29)
269
312
  - [ ] **ThreadRail** (side graphic device — a thread running down the left linking sections) — approved,
270
313
  **already exists in ttcab** → extract.
@@ -36,7 +36,8 @@ type Props = {
36
36
  * The active signal = the indicator (ink/12 fill + outline), NOT text color:
37
37
  * the text is always `ink` on `surface`, with no low-contrast frame.
38
38
  * prefers-reduced-motion removes the slide. Target sections need a
39
- * `scroll-mt-*` compatible with the sticky offset (e.g. `scroll-mt-40`).
39
+ * `scroll-mt-*` that, added to the theme's `scroll-padding-top` (96px), clears
40
+ * the header plus the capsule (e.g. `scroll-mt-20` with an 80px header).
40
41
  */
41
42
  export function AnchorNav({ items, stickyTop = "top-20", linkComponent, className = "", labels }: Props) {
42
43
  const L: ElementType = linkComponent ?? "a";
@@ -45,6 +46,10 @@ export function AnchorNav({ items, stickyTop = "top-20", linkComponent, classNam
45
46
  const listRef = useRef<HTMLUListElement>(null);
46
47
  const linkRefs = useRef<Record<string, HTMLAnchorElement | null>>({});
47
48
  const [pill, setPill] = useState({ x: 0, y: 0, w: 0, h: 0, ready: false });
49
+ // Stable key of the items: sites pass the array inline, so a new reference
50
+ // on every render. Keyed by reference, the arrival effect below re-ran on
51
+ // every page scroll and kept dragging the page back to the hash.
52
+ const key = items.map((it) => it.id).join("|");
48
53
 
49
54
  // Scroll-spy
50
55
  useEffect(() => {
@@ -63,11 +68,13 @@ export function AnchorNav({ items, stickyTop = "top-20", linkComponent, classNam
63
68
  );
64
69
  sections.forEach((s) => io.observe(s));
65
70
  return () => io.disconnect();
66
- }, [items]);
71
+ // eslint-disable-next-line react-hooks/exhaustive-deps
72
+ }, [key]);
67
73
 
68
74
  // Arriving with a #hash via client-side navigation: scrolls to the anchor (on
69
75
  // a full load the browser already does it; here it's idempotent). Reapplies a
70
- // few times to beat the router's late scroll reset.
76
+ // few times to beat the router's late scroll reset. Runs once, on arrival:
77
+ // the hash the person sets by clicking a pill is the browser's job.
71
78
  useEffect(() => {
72
79
  const hash = window.location.hash.slice(1);
73
80
  if (!hash || !items.some((it) => it.id === hash)) return;
@@ -80,7 +87,8 @@ export function AnchorNav({ items, stickyTop = "top-20", linkComponent, classNam
80
87
  if (++tries >= 4) window.clearInterval(id);
81
88
  }, 60);
82
89
  return () => window.clearInterval(id);
83
- }, [items]);
90
+ // eslint-disable-next-line react-hooks/exhaustive-deps
91
+ }, [key]);
84
92
 
85
93
  const measure = () => {
86
94
  const el = linkRefs.current[active];
@@ -15,10 +15,17 @@ const linkBase =
15
15
  "group inline-flex items-center gap-1.5 font-body font-semibold underline decoration-[1.5px] decoration-current/50 underline-offset-4 transition-[text-decoration-color,text-underline-offset] duration-200 hover:decoration-current hover:underline-offset-[5px] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink rounded-[2px] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none [&>svg]:transition-transform [&>svg]:duration-200 hover:[&>svg]:-translate-y-0.5 hover:[&>svg]:translate-x-0.5 motion-reduce:[&>svg]:transform-none";
16
16
 
17
17
  const variants: Record<Exclude<Variant, "link">, string> = {
18
- // solid petrol, cream text (e.g. "Falar com a equipe")
19
- solid: "bg-ink text-surface hover:-translate-y-0.5 shadow-[0_6px_18px_rgba(20,71,83,0.28)]",
20
- // action color (orange/magenta) + on-action text (from the token, varies per site)
21
- accent: "bg-action text-on-action hover:-translate-y-0.5 shadow-[0_6px_18px_rgba(20,71,83,0.22)]",
18
+ // Flat, as the pill buttons of novo.creeta: no drop shadow, no lift; the hover
19
+ // is a change of fill and the tap scales the pill (pillBase). A shadow under a
20
+ // hot action color read as a grey halo (2026-10-29).
21
+ // solid petrol, cream text (e.g. "Falar com a equipe"): hover goes to ink-soft (creeta's teal-escuro)
22
+ solid: "bg-ink text-surface hover:bg-ink-soft",
23
+ // action color (orange/magenta) + on-action text (from the token, varies per site).
24
+ // Same type scale as the other variants: buttons side by side must share a size.
25
+ // On ttcab (white on the vivid red-orange) that is 3.67:1, a conscious exception
26
+ // (Cloudflare and Airbnb ship the same): passes the 3:1 of UI components, not
27
+ // the 4.5:1 of normal text. Keep the accent for ONE large button per block.
28
+ accent: "bg-action text-on-action hover:brightness-[0.92]",
22
29
  outline: "border border-line text-ink hover:bg-ink/[0.05]",
23
30
  ghost: "text-ink hover:bg-ink/[0.06]",
24
31
  };
@@ -51,11 +51,13 @@ export function Byline({ people, eyebrow, action, joiners = { sep: ", ", last: "
51
51
  )}
52
52
  </span>
53
53
  ));
54
+ // min-h-11 keeps the 44px tap target; -my-3 takes those extra 12px out of the
55
+ // flow, so when the link wraps under the names the two lines read as one caption
54
56
  const actionEl = action ? (
55
57
  <L
56
58
  href={action.href}
57
59
  {...(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"
60
+ className="-my-3 inline-flex min-h-11 items-center gap-1 font-semibold text-ink underline underline-offset-4"
59
61
  >
60
62
  {action.label}
61
63
  <ArrowUpRight />
@@ -0,0 +1,86 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export type CareStep = {
4
+ title: ReactNode;
5
+ /** What fits the phone card (two columns). */
6
+ summary: ReactNode;
7
+ /** The full text, from lg. */
8
+ text: ReactNode;
9
+ /** The drawing above the text: a ChatPreview, a photo, a PlanTimeline, a
10
+ * PhotoSequence… It fills the frame (absolute inset-0). Decorative: it
11
+ * repeats what the text says; only a pause button should reach the keyboard. */
12
+ visual: ReactNode;
13
+ };
14
+
15
+ type Labels = {
16
+ /** sr-only before the title: "Step 1: ". The number on screen is decoration. */
17
+ step: (n: number) => string;
18
+ };
19
+ const EN: Labels = { step: (n) => `Step ${n}: ` };
20
+
21
+ type Props = {
22
+ steps: CareStep[];
23
+ /** The last tile, on the dark panel: the sentence and the action (a WhatsApp link, a Button). */
24
+ invite: { text: ReactNode; action: ReactNode };
25
+ /** Heading level of the step titles. */
26
+ headingLevel?: "h2" | "h3";
27
+ labels?: Partial<Labels>;
28
+ className?: string;
29
+ };
30
+
31
+ /** The frame of each drawing: surface colour, corner concentric with the
32
+ * card's (28px minus the 12px of breathing room). */
33
+ const FRAME = "relative h-32 overflow-hidden rounded-2xl bg-surface md:h-52";
34
+
35
+ /**
36
+ * How the care works, from the first contact to the treatment — creeta's
37
+ * `Passos`, by tokens. A bento mosaic: each step has its drawing above and
38
+ * its text below; the first takes two columns and the invite closes the
39
+ * sequence:
40
+ *
41
+ * [ 01, wide ] 02 phone: [ 01, wide ]
42
+ * 03 04 invite 02 03
43
+ * 04 invite
44
+ *
45
+ * The invite comes last: it is the conclusion of the steps.
46
+ */
47
+ export function CareSteps({ steps, invite, headingLevel: Title = "h3", labels, className = "" }: Props) {
48
+ const t = { ...EN, ...labels };
49
+ return (
50
+ <div className={`grid grid-cols-2 gap-3 md:grid-cols-3 md:gap-5 ${className}`}>
51
+ {/* contents: the <li> become tiles of the mosaic and the list stays a list */}
52
+ <ol className="contents">
53
+ {steps.map((s, i) => (
54
+ <li key={i} className={`flex flex-col rounded-[28px] bg-card p-3 ${i === 0 ? "col-span-2 md:flex-row-reverse" : ""}`}>
55
+ {i === 0 ? (
56
+ <div className="relative overflow-hidden rounded-2xl bg-surface md:w-1/2">{s.visual}</div>
57
+ ) : (
58
+ <div className={FRAME}>{s.visual}</div>
59
+ )}
60
+
61
+ <div className="flex flex-1 flex-col px-2 pb-2 pt-4 md:px-3 md:pb-3 md:pt-5 lg:px-5 lg:pb-5 lg:pt-6">
62
+ {/* The number is visual reinforcement; the order goes as text for the screen reader. */}
63
+ <span aria-hidden="true" className="font-heading text-[1.75rem] font-normal leading-none tracking-[-0.04em] text-ink-soft md:text-[2.5rem]">
64
+ {String(i + 1).padStart(2, "0")}
65
+ </span>
66
+ {/* 16px on the phone: the column has 141px, and at 18px titles broke into three lines */}
67
+ <Title className={`mt-2 font-heading text-base font-medium leading-snug text-ink md:text-xl ${i === 0 ? "md:mt-auto md:pt-6" : "md:mt-3"}`}>
68
+ <span className="sr-only">{t.step(i + 1)}</span>
69
+ {s.title}
70
+ </Title>
71
+ {/* the full text only when the column fits it: up to lg it has less than 180px */}
72
+ <p className="mt-1.5 text-sm leading-relaxed text-ink-body lg:hidden">{s.summary}</p>
73
+ <p className="mt-2 hidden leading-relaxed text-ink-body lg:block">{s.text}</p>
74
+ </div>
75
+ </li>
76
+ ))}
77
+ </ol>
78
+
79
+ {/* The invite closes the mosaic: it is the last tile. */}
80
+ <div className="flex min-h-[200px] flex-col justify-between gap-6 rounded-[28px] bg-surface-panel p-5 text-surface md:p-6 lg:p-8">
81
+ <p className="font-heading text-[1.375rem] font-medium leading-[1.15] md:text-[2rem] md:leading-[1.12]">{invite.text}</p>
82
+ <div className="self-start">{invite.action}</div>
83
+ </div>
84
+ </div>
85
+ );
86
+ }
@@ -0,0 +1,138 @@
1
+ "use client";
2
+
3
+ import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
4
+ import { Clock } from "lucide-react";
5
+ import { reducedMotion, useOnAppear } from "./useOnAppear";
6
+ import { PauseButton } from "./PauseButton";
7
+
8
+ export type ChatMessage = { from: "person" | "clinic"; text: string };
9
+
10
+ type Labels = {
11
+ /** Completes the pause button's name: "Pause the sample conversation". */
12
+ pause: string;
13
+ };
14
+ const EN: Labels = { pause: "the sample conversation" };
15
+
16
+ type Props = {
17
+ messages: ChatMessage[];
18
+ /** Small line at the top, with a clock: the opening hours. */
19
+ hours?: string;
20
+ labels?: Partial<Labels>;
21
+ className?: string;
22
+ };
23
+
24
+ /** How long the clinic is "typing" before each reply, in ms. */
25
+ const TYPING = 1100;
26
+ /** At the end the conversation rests this long, fades out and restarts. */
27
+ const REST = 3200;
28
+ const EXIT = 350;
29
+ /** Time to read the previous message before the next: grows with its length, 0.9s to 1.9s. */
30
+ export const readingTime = (previous?: ChatMessage) =>
31
+ previous ? Math.min(1900, Math.max(900, 600 + previous.text.length * 28)) : 400;
32
+
33
+ const BUBBLE = "cg-bubble max-w-[15rem] rounded-[20px] px-4 py-3 text-sm leading-snug lg:text-[0.9375rem]";
34
+ const CURVE = "cubic-bezier(0.23, 1, 0.32, 1)";
35
+
36
+ /**
37
+ * Step 1 of the care flow (creeta's `Conversa`): a sample conversation, bubble
38
+ * by bubble. The person writes, the clinic "types" and answers.
39
+ * - Starts when half of it is on screen and loops: at the end rests 3.2s,
40
+ * fades and restarts. Only runs while on screen.
41
+ * - Works like a real chat: fixed height, new bubbles come in at the bottom,
42
+ * old ones slide up and vanish at the top. Nothing pushes the page.
43
+ * - Moves by itself for more than 5s, so it has a pause button (WCAG 2.2.2).
44
+ * - Without JavaScript, IntersectionObserver or with reduced motion, the whole
45
+ * conversation shows still, without the button.
46
+ * - The chat is decoration (aria-hidden): the step's text already says what
47
+ * matters. The button stays outside it, for the keyboard and screen reader.
48
+ */
49
+ export function ChatPreview({ messages, hours, labels, className = "" }: Props) {
50
+ const t = { ...EN, ...labels };
51
+ const { ref, state, onScreen } = useOnAppear<HTMLDivElement>();
52
+ const stack = useRef<HTMLDivElement>(null);
53
+ const height = useRef(0);
54
+ const [shown, setShown] = useState(0);
55
+ const [typing, setTyping] = useState(false);
56
+ const [paused, setPaused] = useState(false);
57
+ const [leaving, setLeaving] = useState(false);
58
+
59
+ const visible = state === "whole" ? messages : state === "waiting" ? [] : messages.slice(0, shown);
60
+
61
+ // The clock: one message at a time, and back to the start.
62
+ useEffect(() => {
63
+ if (state !== "playing" || paused || !onScreen) return;
64
+ const wait = readingTime(messages[shown - 1]);
65
+ const timer =
66
+ shown >= messages.length
67
+ ? leaving
68
+ ? setTimeout(() => {
69
+ setShown(0);
70
+ setLeaving(false);
71
+ }, EXIT)
72
+ : setTimeout(() => setLeaving(true), REST)
73
+ : messages[shown].from === "person"
74
+ ? setTimeout(() => setShown((v) => v + 1), wait + 300)
75
+ : typing
76
+ ? setTimeout(() => {
77
+ setTyping(false);
78
+ setShown((v) => v + 1);
79
+ }, TYPING)
80
+ : setTimeout(() => setTyping(true), wait);
81
+ return () => clearTimeout(timer);
82
+ }, [state, paused, onScreen, shown, typing, leaving, messages]);
83
+
84
+ // New bubble at the bottom: the stack grows upward at once. To slide the old
85
+ // ones up, it starts offset by the height it gained and returns (transform only).
86
+ useLayoutEffect(() => {
87
+ const el = stack.current;
88
+ if (!el) return;
89
+ const gained = el.offsetHeight - height.current;
90
+ height.current = el.offsetHeight;
91
+ if (state !== "playing" || gained <= 0 || reducedMotion() || !el.animate) return;
92
+ el.animate([{ transform: `translateY(${gained}px)` }, { transform: "translateY(0)" }], { duration: 320, easing: CURVE });
93
+ }, [state, shown, typing]);
94
+
95
+ return (
96
+ <div ref={ref} data-state={state} data-paused={paused ? "" : undefined} className={`cg-chat relative h-full min-h-[17.5rem] md:min-h-[20rem] ${className}`}>
97
+ <div aria-hidden="true">
98
+ {hours && (
99
+ <span className="absolute left-4 top-0 flex h-11 items-center gap-1.5 text-xs font-medium text-ink/80 lg:left-7">
100
+ <Clock size={13} strokeWidth={2} />
101
+ {hours}
102
+ </span>
103
+ )}
104
+ {/* the chat window: what goes past the top fades out */}
105
+ <div className="absolute inset-x-0 bottom-0 top-11 overflow-hidden [mask-image:linear-gradient(to_bottom,transparent,black_2rem)]">
106
+ <div
107
+ ref={stack}
108
+ className={`absolute inset-x-0 bottom-0 flex flex-col gap-2.5 px-4 pb-4 transition-opacity duration-300 ease-out motion-reduce:transition-none lg:px-7 lg:pb-7 ${leaving ? "opacity-0" : ""}`}
109
+ >
110
+ {visible.map((m, i) => (
111
+ <span
112
+ key={i}
113
+ className={
114
+ m.from === "person"
115
+ ? `${BUBBLE} origin-bottom-right self-end rounded-br-md bg-ink text-surface`
116
+ : `${BUBBLE} origin-bottom-left self-start rounded-bl-md bg-card text-ink`
117
+ }
118
+ >
119
+ {m.text}
120
+ </span>
121
+ ))}
122
+ {typing && (
123
+ <span className="cg-bubble cg-typing flex origin-bottom-left gap-1 self-start rounded-[20px] rounded-bl-md bg-card px-4 py-3.5">
124
+ {[0, 1, 2].map((n) => (
125
+ <i key={n} style={{ "--n": n } as CSSProperties} className="h-1.5 w-1.5 rounded-full bg-ink-soft" />
126
+ ))}
127
+ </span>
128
+ )}
129
+ </div>
130
+ </div>
131
+ </div>
132
+
133
+ {state !== "whole" && (
134
+ <PauseButton paused={paused} onToggle={() => setPaused((p) => !p)} what={t.pause} className="right-0 top-0 lg:right-2" />
135
+ )}
136
+ </div>
137
+ );
138
+ }
@@ -0,0 +1,80 @@
1
+ import type { ElementType, ReactNode } from "react";
2
+ import { Byline, type Person } from "./Byline";
3
+ import type { PostCover } from "./PostCard";
4
+
5
+ export type FeaturedPost = {
6
+ cover: PostCover;
7
+ title: ReactNode;
8
+ /** The post's own page. */
9
+ href: string;
10
+ text: ReactNode;
11
+ /** Who said it (name, portrait): the text is their words, only cut. */
12
+ speaker: Person;
13
+ /** Link to the video the text came from ("Watch the video ↗"). */
14
+ video?: { href: string; label?: ReactNode; external?: boolean };
15
+ };
16
+
17
+ type Props = {
18
+ posts: FeaturedPost[];
19
+ headingLevel?: "h2" | "h3";
20
+ /** Default label of the video link. */
21
+ watchLabel?: ReactNode;
22
+ newTabLabel?: string;
23
+ linkComponent?: ElementType;
24
+ className?: string;
25
+ };
26
+
27
+ /**
28
+ * Featured posts of the Cuidado Global family — creeta's `Temas` ("in the
29
+ * words of who treats you"): what the team explains in the videos, as text.
30
+ * Each card: the cover above, the title and the text, and at the foot who
31
+ * said it (with the portrait) and the link to the video, because the text is
32
+ * their speech, only with cuts. Bento: the first post takes two columns with
33
+ * the cover beside the text; if the last would sit alone in its row, it too.
34
+ */
35
+ export function FeaturedPosts({ posts, headingLevel: Title = "h3", watchLabel = "Watch the video", newTabLabel, linkComponent, className = "" }: Props) {
36
+ const L: ElementType = linkComponent ?? "a";
37
+ return (
38
+ <ul className={`grid list-none gap-3 md:grid-cols-2 md:gap-5 ${className}`}>
39
+ {posts.map((p, i) => {
40
+ const wide = i === 0 || (i === posts.length - 1 && (posts.length - 1) % 2 === 1);
41
+ return (
42
+ <li key={i} className={`flex flex-col overflow-hidden rounded-[28px] bg-card ${wide ? "md:col-span-2 md:grid md:grid-cols-5" : ""}`}>
43
+ {/* cover: decoration (4:3); the title says the subject */}
44
+ <div className={`relative overflow-hidden ${wide ? "aspect-[4/3] md:col-span-2 md:aspect-auto md:min-h-[320px]" : "aspect-[4/3]"}`}>
45
+ {/* eslint-disable-next-line @next/next/no-img-element */}
46
+ <img
47
+ src={p.cover.src}
48
+ srcSet={p.cover.srcSet}
49
+ sizes={p.cover.sizes ?? (wide ? "(min-width: 1152px) 440px, (min-width: 768px) 40vw, 92vw" : "(min-width: 1152px) 540px, (min-width: 768px) 46vw, 92vw")}
50
+ alt=""
51
+ loading="lazy"
52
+ decoding="async"
53
+ className="absolute inset-0 h-full w-full object-cover object-[50%_30%]"
54
+ />
55
+ </div>
56
+
57
+ <div className={`flex flex-1 flex-col p-6 md:p-8 ${wide ? "md:col-span-3 md:justify-center" : ""}`}>
58
+ <Title className="font-heading text-xl font-medium leading-snug text-ink md:text-[1.375rem]">
59
+ <L
60
+ href={p.href}
61
+ className="underline decoration-transparent underline-offset-4 transition-[text-decoration-color] duration-150 hover:decoration-ink focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ink motion-reduce:transition-none"
62
+ >
63
+ {p.title}
64
+ </L>
65
+ </Title>
66
+ <p className="mt-3 leading-relaxed text-ink-body">{p.text}</p>
67
+ <Byline
68
+ className="mt-auto pt-6"
69
+ people={[p.speaker]}
70
+ action={p.video ? { label: p.video.label ?? watchLabel, href: p.video.href, external: p.video.external } : undefined}
71
+ newTabLabel={newTabLabel}
72
+ linkComponent={linkComponent}
73
+ />
74
+ </div>
75
+ </li>
76
+ );
77
+ })}
78
+ </ul>
79
+ );
80
+ }
@@ -6,6 +6,8 @@ import { List } from "./List";
6
6
  type IconType = ComponentType<{ size?: number; strokeWidth?: number; className?: string; "aria-hidden"?: boolean | "true" }>;
7
7
 
8
8
  export type FooterLink = { href: string; label: ReactNode };
9
+ /** A titled column of links, for footers with many pages ("Foundations", "Cards…"). */
10
+ export type FooterLinkGroup = { title: ReactNode; links: FooterLink[] };
9
11
  export type FooterContact = { href: string; label: ReactNode; Icon: IconType; external?: boolean };
10
12
  export type FooterLanguage = Language;
11
13
 
@@ -29,8 +31,11 @@ type Props = {
29
31
  /** The brand sentence, shown in quotes under the logo. */
30
32
  quote?: ReactNode;
31
33
  /** "Pages": title on the first line, links in two columns below. Optional:
32
- * a footer with no internal links drops the column (two-column grid). */
33
- pages?: { title: ReactNode; links: FooterLink[] };
34
+ * a footer with no internal links drops the column (two-column grid).
35
+ * Many links (a dozen or more): pass `groups` instead of `links` — each
36
+ * group is a column with a small heading, up to four side by side, so the
37
+ * list gets wider instead of taller (the 44px rows stay). */
38
+ pages?: { title: ReactNode; links?: FooterLink[]; groups?: FooterLinkGroup[] };
34
39
  /** "Where we are": address lines, opening hours, and the directions links
35
40
  * ("Directions ↗ · Waze ↗", external). */
36
41
  where: { title: ReactNode; address: ReactNode[]; hours?: ReactNode; directions?: FooterLink[] };
@@ -59,6 +64,14 @@ const ArrowUpRight = () => (
59
64
  );
60
65
 
61
66
  const title = "font-heading text-lg font-medium text-ink";
67
+ const groupTitle = "text-xs font-semibold uppercase tracking-[0.12em] text-ink-soft";
68
+ // whole class names: as many columns as groups, up to four
69
+ const groupCols: Record<number, string> = {
70
+ 1: "grid-cols-1",
71
+ 2: "grid-cols-2",
72
+ 3: "grid-cols-2 lg:grid-cols-3",
73
+ 4: "grid-cols-2 lg:grid-cols-4",
74
+ };
62
75
  const link =
63
76
  "flex min-h-11 items-center text-ink/80 underline decoration-transparent underline-offset-4 transition-[color,text-decoration-color] duration-150 hover:text-ink hover:decoration-ink motion-reduce:transition-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink";
64
77
  const strongLink =
@@ -100,12 +113,19 @@ export function Footer({
100
113
  // padding leaves room for the floating bar.
101
114
  const outer = navHiddenFrom === "md" ? "mt-16 pb-28 md:mt-24 md:pb-14" : "mt-16 pb-28 lg:mt-24 lg:pb-14";
102
115
  const noticeId = notice?.id ?? "footer-notice";
116
+ const groups = pages?.groups?.length ? pages.groups : undefined;
117
+ // grouped pages need the room: the pages column grows and the brand column shrinks
118
+ const columns = !pages
119
+ ? "md:grid-cols-[minmax(0,1fr)_auto]"
120
+ : groups
121
+ ? "md:grid-cols-[minmax(0,3.5fr)_minmax(0,6.5fr)_minmax(0,3fr)]"
122
+ : "md:grid-cols-[minmax(0,4fr)_minmax(0,5fr)_minmax(0,3fr)]";
103
123
 
104
124
  return (
105
125
  <footer className={`${outer} bg-card py-10 text-ink md:py-14 ${className}`}>
106
126
  <div className="mx-auto max-w-[1600px] px-4 md:px-6">
107
127
  {/* without the pages column, "where" keeps to the right edge (auto width) */}
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]"}`}>
128
+ <div className={`grid gap-x-10 gap-y-10 ${columns}`}>
109
129
  <div>
110
130
  {logo}
111
131
  {brandName && <p className="sr-only">{brandName}</p>}
@@ -117,13 +137,30 @@ export function Footer({
117
137
  {pages && (
118
138
  <nav aria-label={t.pagesNav}>
119
139
  <h2 className={title}>{pages.title}</h2>
120
- <div className="mt-3 grid gap-x-6 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2">
121
- {pages.links.map((p, i) => (
122
- <L key={`${i}-${p.href}`} href={p.href} className={link}>
123
- {p.label}
124
- </L>
125
- ))}
126
- </div>
140
+ {groups ? (
141
+ <div className={`mt-5 grid gap-x-6 gap-y-8 ${groupCols[Math.min(groups.length, 4)]}`}>
142
+ {groups.map((g, gi) => (
143
+ <div key={gi}>
144
+ <h3 className={groupTitle}>{g.title}</h3>
145
+ <div className="mt-2">
146
+ {g.links.map((p, i) => (
147
+ <L key={`${i}-${p.href}`} href={p.href} className={link}>
148
+ {p.label}
149
+ </L>
150
+ ))}
151
+ </div>
152
+ </div>
153
+ ))}
154
+ </div>
155
+ ) : (
156
+ <div className="mt-3 grid gap-x-6 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2">
157
+ {(pages.links ?? []).map((p, i) => (
158
+ <L key={`${i}-${p.href}`} href={p.href} className={link}>
159
+ {p.label}
160
+ </L>
161
+ ))}
162
+ </div>
163
+ )}
127
164
  </nav>
128
165
  )}
129
166
 
@@ -112,7 +112,9 @@ function Dropdown({ item, L, t }: { item: NavItem; L: ElementType; t: Labels })
112
112
  <L
113
113
  href={c.href}
114
114
  onClick={() => setOpen(false)}
115
- className="flex items-start gap-3 rounded-xl px-2.5 py-2 transition-colors hover:bg-ink/[0.05] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink"
115
+ // with a description the icon sits with the label's first line (creeta);
116
+ // label only: centred on the bubble, and a two-line label stays tight
117
+ className={`flex ${c.description ? "items-start" : "items-center"} gap-3 rounded-xl px-2.5 py-2 transition-colors hover:bg-ink/[0.05] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink`}
116
118
  >
117
119
  {c.icon && (
118
120
  <span className="flex h-9 w-9 flex-none items-center justify-center rounded-full bg-ink/[0.08] text-ink">
@@ -120,7 +122,7 @@ function Dropdown({ item, L, t }: { item: NavItem; L: ElementType; t: Labels })
120
122
  </span>
121
123
  )}
122
124
  <span>
123
- <span className="block font-heading text-[0.95rem] font-semibold text-ink">
125
+ <span className="block font-heading text-[0.95rem] leading-snug font-semibold text-ink">
124
126
  {c.label}
125
127
  </span>
126
128
  {c.description && (