@breathe-ds/ui 0.2.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.
Files changed (49) hide show
  1. package/README.md +118 -13
  2. package/components/Accordion.tsx +6 -4
  3. package/components/AnchorNav.tsx +12 -4
  4. package/components/Avatar.tsx +37 -0
  5. package/components/BottomNav.tsx +1 -1
  6. package/components/Breadcrumb.tsx +45 -0
  7. package/components/BreatheMark.tsx +0 -13
  8. package/components/Button.tsx +11 -4
  9. package/components/Byline.tsx +105 -0
  10. package/components/CaptionBand.tsx +74 -0
  11. package/components/CareSteps.tsx +86 -0
  12. package/components/ChatPreview.tsx +138 -0
  13. package/components/CtaCard.tsx +52 -0
  14. package/components/FactStrip.tsx +55 -0
  15. package/components/FeaturedPosts.tsx +80 -0
  16. package/components/Footer.tsx +50 -30
  17. package/components/Header.tsx +4 -2
  18. package/components/Heading.tsx +86 -0
  19. package/components/InfoCard.tsx +116 -0
  20. package/components/LanguageSwitcher.tsx +1 -1
  21. package/components/List.tsx +81 -0
  22. package/components/Lockup.tsx +0 -18
  23. package/components/MapCard.tsx +158 -0
  24. package/components/MediaCard.tsx +266 -0
  25. package/components/PageVideoHero.tsx +65 -23
  26. package/components/PauseButton.tsx +41 -0
  27. package/components/PersonChip.tsx +49 -0
  28. package/components/PhotoSequence.tsx +99 -0
  29. package/components/Pill.tsx +42 -0
  30. package/components/PillList.tsx +20 -47
  31. package/components/PlanTimeline.tsx +66 -0
  32. package/components/PostArticle.tsx +102 -0
  33. package/components/PostCard.tsx +69 -0
  34. package/components/ProfessionalCard.tsx +446 -0
  35. package/components/ProfessionalModal.tsx +243 -0
  36. package/components/Rail.tsx +94 -0
  37. package/components/Rows.tsx +137 -0
  38. package/components/StoryPanel.tsx +107 -0
  39. package/components/TestimonialCard.tsx +138 -0
  40. package/components/Text.tsx +31 -0
  41. package/components/Timeline.tsx +69 -0
  42. package/components/VideoInviteCard.tsx +168 -0
  43. package/components/VideoStoryCard.tsx +382 -0
  44. package/components/useOnAppear.ts +39 -0
  45. package/package.json +34 -2
  46. package/theme.css +206 -0
  47. package/tokens/creeta.css +7 -2
  48. package/tokens/neutral.css +12 -4
  49. package/tokens/ttcab.css +14 -5
@@ -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,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
+ }
@@ -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
+ }
@@ -1,10 +1,13 @@
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" }>;
6
7
 
7
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[] };
8
11
  export type FooterContact = { href: string; label: ReactNode; Icon: IconType; external?: boolean };
9
12
  export type FooterLanguage = Language;
10
13
 
@@ -28,8 +31,11 @@ type Props = {
28
31
  /** The brand sentence, shown in quotes under the logo. */
29
32
  quote?: ReactNode;
30
33
  /** "Pages": title on the first line, links in two columns below. Optional:
31
- * a footer with no internal links drops the column (two-column grid). */
32
- 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[] };
33
39
  /** "Where we are": address lines, opening hours, and the directions links
34
40
  * ("Directions ↗ · Waze ↗", external). */
35
41
  where: { title: ReactNode; address: ReactNode[]; hours?: ReactNode; directions?: FooterLink[] };
@@ -56,11 +62,16 @@ type Props = {
56
62
  const ArrowUpRight = () => (
57
63
  <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
64
  );
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
65
 
63
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
+ };
64
75
  const link =
65
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";
66
77
  const strongLink =
@@ -102,13 +113,19 @@ export function Footer({
102
113
  // padding leaves room for the floating bar.
103
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";
104
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)]";
105
123
 
106
124
  return (
107
- <footer className={`${outer} bg-white py-10 text-ink md:py-14 ${className}`}>
108
- <style>{CSS}</style>
125
+ <footer className={`${outer} bg-card py-10 text-ink md:py-14 ${className}`}>
109
126
  <div className="mx-auto max-w-[1600px] px-4 md:px-6">
110
127
  {/* without the pages column, "where" keeps to the right edge (auto width) */}
111
- <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}`}>
112
129
  <div>
113
130
  {logo}
114
131
  {brandName && <p className="sr-only">{brandName}</p>}
@@ -120,13 +137,30 @@ export function Footer({
120
137
  {pages && (
121
138
  <nav aria-label={t.pagesNav}>
122
139
  <h2 className={title}>{pages.title}</h2>
123
- <div className="mt-3 grid gap-x-6 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2">
124
- {pages.links.map((p, i) => (
125
- <L key={`${i}-${p.href}`} href={p.href} className={link}>
126
- {p.label}
127
- </L>
128
- ))}
129
- </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
+ )}
130
164
  </nav>
131
165
  )}
132
166
 
@@ -169,14 +203,7 @@ export function Footer({
169
203
  <h2 id={noticeId} className="font-heading text-2xl font-medium leading-snug md:text-[1.75rem]">
170
204
  {notice.title}
171
205
  </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>
206
+ <List marker="arrow" className="mt-4" items={notice.items.map((label) => ({ label }))} />
180
207
  </div>
181
208
  {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
209
  </div>
@@ -215,10 +242,3 @@ export function Footer({
215
242
  );
216
243
  }
217
244
 
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
- `;
@@ -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 && (
@@ -0,0 +1,86 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ type Level = "h1" | "h2" | "h3" | "h4";
4
+ /** The family's heading scale (novo.creeta):
5
+ * display — page title (SectionHeading as h1) · hero — title over a full-bleed photo/video
6
+ * section — section title (h2) · card — title inside a card · small — h3 */
7
+ export type HeadingSize = "display" | "hero" | "section" | "card" | "small";
8
+
9
+ export const HEADING_SIZE: Record<HeadingSize, string> = {
10
+ display: "text-[2.5rem] leading-[1.04] tracking-[-0.02em] md:text-6xl",
11
+ hero: "text-[2rem] leading-[1.06] tracking-[-0.02em] md:text-5xl xl:text-6xl xl:leading-[1.04]",
12
+ section: "text-[1.75rem] leading-[1.12] tracking-[-0.01em] md:text-4xl",
13
+ card: "text-[1.375rem] leading-snug md:text-2xl",
14
+ small: "text-xl leading-snug",
15
+ };
16
+ const DEFAULT_SIZE: Record<Level, HeadingSize> = { h1: "display", h2: "section", h3: "small", h4: "small" };
17
+
18
+ type HeadingProps = {
19
+ as?: Level;
20
+ size?: HeadingSize;
21
+ children: ReactNode;
22
+ /** Second sentence of the same heading, on its own line, one tone below
23
+ * (`ink-soft`): "Aprenda com quem atende. / Quem criou o método explica." */
24
+ accent?: ReactNode;
25
+ id?: string;
26
+ className?: string;
27
+ };
28
+
29
+ /**
30
+ * Heading — Quicksand medium in `ink`; the optional `accent` sentence sits on
31
+ * the next line in `ink-soft`. Two tones, same heading: the hierarchy comes
32
+ * from color, not from a second element.
33
+ */
34
+ export function Heading({ as: Tag = "h2", size, children, accent, id, className = "" }: HeadingProps) {
35
+ const s = size ?? DEFAULT_SIZE[Tag];
36
+ return (
37
+ <Tag id={id} className={`m-0 font-heading font-medium text-ink ${HEADING_SIZE[s]} ${className}`}>
38
+ {children}
39
+ {accent && (
40
+ <>
41
+ {" "}
42
+ <span className="block text-ink-soft">{accent}</span>
43
+ </>
44
+ )}
45
+ </Tag>
46
+ );
47
+ }
48
+
49
+ type SectionHeadingProps = {
50
+ /** `h1` on an inner page (its main title), `h2` for a home section. */
51
+ as?: "h1" | "h2";
52
+ /** Only when the content really is a sequence ("01"). */
53
+ number?: string;
54
+ eyebrow?: ReactNode;
55
+ title: ReactNode;
56
+ /** Second sentence, one tone below. */
57
+ accent?: ReactNode;
58
+ /** Supporting paragraph right under the title. */
59
+ lead?: ReactNode;
60
+ id?: string;
61
+ className?: string;
62
+ };
63
+
64
+ /**
65
+ * SectionHeading — the SectionHeading of novo.creeta: eyebrow/number line in
66
+ * `ink-soft` semibold, the title (display as h1, section as h2) with its
67
+ * accent sentence, and the lead (text-lg, reading width). Carries the bottom
68
+ * margin of a section opening.
69
+ */
70
+ export function SectionHeading({ as: Tag = "h2", number, eyebrow, title, accent, lead, id, className = "" }: SectionHeadingProps) {
71
+ const main = Tag === "h1";
72
+ return (
73
+ <div className={`${main ? "mb-8 md:mb-12" : "mb-6 md:mb-9"} ${className}`}>
74
+ {(number || eyebrow) && (
75
+ <p className="m-0 mb-3 flex items-center gap-2 text-sm font-semibold text-ink-soft">
76
+ {number && <span>{number}</span>}
77
+ {eyebrow && <span>{eyebrow}</span>}
78
+ </p>
79
+ )}
80
+ <Heading as={Tag} size={main ? "display" : "section"} accent={accent} id={id}>
81
+ {title}
82
+ </Heading>
83
+ {lead && <p className="m-0 mt-4 max-w-xl text-lg leading-relaxed text-ink-body">{lead}</p>}
84
+ </div>
85
+ );
86
+ }