@breathe-ds/ui 0.3.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -57,6 +57,21 @@ For a site without a ready-made token file, define the roles on `:root` itself:
57
57
  and `scroll-padding` (6rem top, 7rem bottom) so keyboard focus never lands
58
58
  hidden behind the sticky Header or the BottomNav (WCAG 2.4.11).
59
59
 
60
+ ## Using it in a site (Tailwind v4)
61
+
62
+ ```css
63
+ @import "tailwindcss";
64
+ @import "@breathe-ds/ui/theme.css"; /* the roles */
65
+ @import "@breathe-ds/ui/tokens/ttcab.css"; /* this brand's hex (or creeta.css, neutral.css) */
66
+ /* Tailwind only generates the classes it finds written: scan the components */
67
+ @source "../../node_modules/@breathe-ds/ui/components";
68
+ ```
69
+
70
+ The components are TSX sources, not a build: in Next.js add
71
+ `transpilePackages: ["@breathe-ds/ui"]` to `next.config`. The CSS entries of
72
+ the package are exported with the `style` condition, which Tailwind's resolver
73
+ requires (a plain string export fails with "Can't resolve").
74
+
60
75
  ## Token roles
61
76
 
62
77
  | Token (Tailwind) | Role | `--cg-*` |
@@ -69,7 +84,17 @@ hidden behind the sticky Header or the BottomNav (WCAG 2.4.11).
69
84
  | `ink-body` | reading body text | `--cg-ink-body` |
70
85
  | `action` | CTA / highlight — **never plain text** | `--cg-action` |
71
86
  | `action-text` | text in the action color (AA) | `--cg-action-text` |
87
+ | `on-action` | text ON the action color (the accent button) | `--cg-on-action` |
88
+ | `action-tint` | highlighted panel or card: a 6% wash of the action color over the surface | `--cg-action-tint` |
89
+ | `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
90
  | `line` | hairlines / borders | `--cg-line` |
91
+
92
+ **Dose of the action color** (what brands with a hot accent do, Cloudflare orange,
93
+ Airbnb red): the action color is the fill of ONE element per block, with
94
+ `on-action` text on it; links stay in `ink`/`ink-soft` with an underline; a
95
+ highlighted card is `action-tint` + `action-line`, not the full color; the
96
+ color as text only as `action-text`. The "Action dose" section of the playground
97
+ shows the disciplined and the loud version side by side.
73
98
  | `font-heading` / `font-body` | typography | `--cg-font-*` |
74
99
  | `radius-xl/2xl/pill` · `ease-cg` | radius and motion curve | fixed |
75
100
 
@@ -82,10 +107,11 @@ Values from the standard WCAG formula (the same ones `preview/` computes live):
82
107
  | surface `#fbf5e9` / ink `#144753` | 9.41:1 | AAA |
83
108
  | surface `#fbf5e9` / ink-soft `#54707a` | 4.86:1 | AA |
84
109
  | surface `#fbf5e9` / ink-body `#1e4f5b` | 8.32:1 | AAA |
85
- | surface `#fbf5e9` / action-text `#9a5d07` | 4.90:1 | AA |
110
+ | surface `#fbf5e9` / action-text `#bd3f0c` | 4.98:1 | AA |
111
+ | action `#e8541e` / on-action `#ffffff` (accent button) | 3.67:1 | 3:1 UI component; below AA text, conscious exception |
86
112
  | surface-panel `#144753` / cream text | 9.41:1 | AAA |
87
113
 
88
- `action` `#f6a22a` is decorative/CTA only — do not use it as text on cream.
114
+ `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
115
 
90
116
  ## Family typography
91
117
 
@@ -200,9 +226,17 @@ from color, not from extra markup.
200
226
  replace the arrow). The ProfessionalCard is built on them.
201
227
  - **ProfessionalCard** / **ProfessionalModal** / **CaptionBand**: the team card of
202
228
  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.
229
+ chip), name, role, registration (**always**: COFFITO 414/2012) and generic
230
+ `rows` (collapse with text or links, link with arrow or icon — on creeta:
231
+ trajectory, guidance, Instagram); the modal plays the presentation with sound,
232
+ the live caption band (active WebVTT cue) above the name, and the full `bio`.
233
+ Three sizes (`size`): `full` (team page), `medium` (the home's tile — 3:4
234
+ portrait, name, "Meet" badge on hover, a link to the full card; `shortName`,
235
+ `href`) and `micro` (the "who treats" row — 72px photo, name, role, link).
236
+ - **Rail** / **RailItem**: novo.creeta's `Trilho` — on the phone a snapping
237
+ horizontal rail with a progress bar underneath (shown only when it scrolls),
238
+ a grid from md (`grid` classes written in full). RailItem presets the phone
239
+ width: `wide` (treatments, videos) or `narrow` (team).
206
240
  - **Byline** / **PersonChip** / **Avatar**: who signs or explains content
207
241
  (novo.creeta) — stacked portraits + eyebrow + linked names, or portrait + name
208
242
  + "Watch the video ↗"; the person chip (portrait, name, role · registration,
@@ -234,9 +268,56 @@ from color, not from extra markup.
234
268
  - **CtaCard** / **VideoInviteCard**: the final call of a novo.creeta page — white
235
269
  card (title, lead, CTA slot) beside a video card: muted loop with the speaker's
236
270
  name over it, pause control, play button that plays the full video with sound.
271
+ - **CareSteps**: "how the care works" of novo.creeta — a bento mosaic of
272
+ numbered steps (drawing above, text below; the first takes two columns) with
273
+ the invite on the dark panel closing the sequence. The drawings are slots:
274
+ **ChatPreview** (a sample chat, bubble by bubble, the clinic "types"; loops
275
+ while on screen, pause button), **PlanTimeline** (three stages, the line comes
276
+ down and each dot lights up; 6s loop, pause button), **PhotoSequence** (photos
277
+ with fade and a slow zoom, CSS only, paused by a hidden checkbox), or a still
278
+ image. All stand still and whole with reduced motion. `useOnAppear` (animate
279
+ when half on screen, only while on screen) and `PauseButton` (44px, 32px
280
+ circle) are exported for your own drawings.
281
+ - **Blog**: creeta's "guidance" area — **PostCard** (the card of the list:
282
+ cover, category, title as the link stretched over the card, excerpt in three
283
+ lines, author · date), **FeaturedPosts** ("in the words of who treats you":
284
+ bento, the first post wide with the cover beside the text, a Byline with the
285
+ speaker and "Watch the video ↗" at the foot) and **PostArticle** (the page:
286
+ trail, title, PersonChip and date, the text with its notice, the video beside
287
+ it sticky from md, the transcript open; children for "other posts" and the
288
+ final call).
289
+ - **SidePanel**: Luma's drawer — a native `<dialog>` that slides in from the
290
+ right, full height, up to 32rem wide (full width on the phone), with a top
291
+ bar (close on the left, actions such as "Copy link" and "Course page ↗" on
292
+ the right) and a scrolling body. Esc, the backdrop and the button close it;
293
+ focus goes back to what opened it. The playground opens one from a
294
+ CourseCard (`onSelect`) with the whole class inside: cover, facts,
295
+ RegistrationBox with the button straight to the school's cart, programme,
296
+ discounts, rules.
297
+ - **CourseCard**: the class card (ttcab's "Formação", the school's class list)
298
+ in the family's own vocabulary: course name as the category line, the city as
299
+ the title with the seats pill, the facts as EventFacts tiles (DateBadge for
300
+ the start, pin for the venue with its map link, clock for the workload), who
301
+ teaches as a Byline, a cover photo at the top, and at the foot the price with
302
+ its note beside the Button `accent`; `soldOut` puts the pill in the button's
303
+ place; `onSelect` makes the card a button stretched over it (the links inside
304
+ keep working) to open its details. By tone: no border, no shadow, no lift.
305
+ - **Events** (after Luma and Meetup, for the school's classes): **EventList**
306
+ (days down a dotted rail, each with its rows), **EventRow** (time with an
307
+ accented end, title as the stretched link, organizers, location, who is going
308
+ with "+N", square cover, an optional badge), **EventFacts** (date on a
309
+ calendar tile, venue on a pin tile with its link), **DateBadge** and
310
+ **RegistrationBox** (a strip title, ticket options as a radio group with the
311
+ remaining pill and the price, the one button at the foot).
312
+ - **StoryPanel**: creeta's "origin" block — one dark panel (surface-panel)
313
+ grouping a story: media at the top with a film that fades into the panel, the
314
+ logo at its base, title, lead, three pillars in columns with a hairline above
315
+ each, and the outbound links. For "who we are" and "why us" blocks.
237
316
  - **Footer**: the site-footer of novo.creeta, by tokens — full width like the
238
317
  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
318
+ columns; with many pages, `pages.groups` — titled columns, up to four side by
319
+ side, so the list gets wider instead of taller), "Where we are" (address, hours,
320
+ "Directions ↗ · Waze ↗"), an optional
240
321
  notice band on `surface`, the legal line with the **CNPJ (always required in
241
322
  the family)** plus the contact chips, and the languages as plain links.
242
323
  `navHiddenFrom` keeps room at the bottom for the BottomNav.
@@ -264,7 +345,7 @@ from color, not from extra markup.
264
345
  **Data rule:** every professional MUST have a **CREFITO** attached (COFFITO requirement).
265
346
  - [x] **Pill / PillList** — the Atalho of novo.creeta (white 44px pill, arrow bubble: ↓ anchor, ↗ other page, optional meta); wrapping row + "See all"
266
347
  - [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**
348
+ - [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
349
  - [x] **Site brand marks** (`components/marks/`): real symbol + wordmark for TTCAB and Creeta (10/29)
269
350
  - [ ] **ThreadRail** (side graphic device — a thread running down the left linking sections) — approved,
270
351
  **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,139 @@
1
+ import type { ElementType, ReactNode } from "react";
2
+ import { Clock } from "lucide-react";
3
+ import { Byline, type Person } from "./Byline";
4
+ import { EventFacts, DateBadge, type EventFact } from "./EventFacts";
5
+
6
+ type Labels = {
7
+ /** Prefix before the instructors' names ("With"). */
8
+ with: ReactNode;
9
+ /** The pill on the title line and the one in the button's place when sold out. */
10
+ soldOut: ReactNode;
11
+ };
12
+ const EN: Labels = { with: "With", soldOut: "Sold out" };
13
+
14
+ type Props = {
15
+ /** Cover photo at the top (the school's pages always have one), 4:3,
16
+ * focused near the top for the portrait photos the school uses. */
17
+ cover?: { src: string; alt?: string; position?: string };
18
+ /** The course ("Basic RTA course 2026"): the heading of the card. */
19
+ course?: ReactNode;
20
+ /** The class ("City · ST"): the line under the heading, with the seats pill.
21
+ * Without `course`, it is the heading itself. */
22
+ title: ReactNode;
23
+ /** The start, for the calendar tile. */
24
+ start: { month: ReactNode; day: ReactNode };
25
+ /** The dates: "3 to 9 November 2026 · 08:30–17:30" and, below, the extra day. */
26
+ when: { title: ReactNode; subtitle?: ReactNode };
27
+ /** The venue (a link to the map when it has an href) and the address. */
28
+ where?: { title: ReactNode; subtitle?: ReactNode; href?: string; external?: boolean };
29
+ /** "69 class hours". */
30
+ workload?: ReactNode;
31
+ /** Seats, as a pill on the title line ("28 seats"). */
32
+ seats?: ReactNode;
33
+ /** Who teaches: portraits and names, each a link when it has an href. */
34
+ instructors?: Person[];
35
+ /** The price beside the button ("R$ 7.280") and a small note ("up to 12 instalments"). */
36
+ price?: { value: ReactNode; note?: ReactNode };
37
+ /** Small note at the foot ("Only physiotherapists can attend."). */
38
+ footnote?: ReactNode;
39
+ /** The call to action: a Button `accent`, the one full-colour element of the card. */
40
+ action?: ReactNode;
41
+ /** No seats left: the pill says so and takes the button's place. */
42
+ soldOut?: boolean;
43
+ /** Makes the card clickable (the title becomes a button stretched over the
44
+ * card) to open its details, e.g. in a SidePanel. Links inside (the map,
45
+ * the instructors, the action) keep working on their own. */
46
+ onSelect?: () => void;
47
+ headingLevel?: "h2" | "h3";
48
+ linkComponent?: ElementType;
49
+ labels?: Partial<Labels>;
50
+ className?: string;
51
+ };
52
+
53
+ const PILL = "inline-flex items-center rounded-pill bg-ink/[0.08] px-2.5 py-0.5 text-xs font-semibold text-ink";
54
+
55
+ /**
56
+ * Course card of the Cuidado Global family — the class card (ttcab's
57
+ * "Formação", the school's class list), in the family's own vocabulary: the
58
+ * course name as the category line, the city as the title with the seats pill
59
+ * (as the EventRow), the facts as tiles (the DateBadge for the start, the pin
60
+ * for the venue, a clock for the workload, as the EventFacts), who teaches as
61
+ * a Byline, and the Button accent pinned at the foot. By tone: no border, no
62
+ * shadow, no lift.
63
+ */
64
+ export function CourseCard({ cover, course, title, start, when, where, workload, seats, instructors, price, footnote, action, soldOut = false, onSelect, headingLevel: Title = "h3", linkComponent, labels, className = "" }: Props) {
65
+ const t = { ...EN, ...labels };
66
+ const facts: EventFact[] = [{ tile: <DateBadge month={start.month} day={start.day} />, title: when.title, subtitle: when.subtitle }];
67
+ if (where) facts.push({ tile: "pin", title: where.title, subtitle: where.subtitle, href: where.href, external: where.external });
68
+ if (workload)
69
+ facts.push({
70
+ tile: (
71
+ <span aria-hidden="true" className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-ink/[0.06] text-ink">
72
+ <Clock size={20} strokeWidth={2} />
73
+ </span>
74
+ ),
75
+ title: workload,
76
+ });
77
+ const pill = soldOut ? t.soldOut : seats;
78
+
79
+ const foot = soldOut ? (
80
+ <span className="inline-flex min-h-11 items-center rounded-pill bg-ink/[0.08] px-5 font-heading font-semibold text-ink">{t.soldOut}</span>
81
+ ) : (
82
+ action
83
+ );
84
+
85
+ return (
86
+ <article className={`relative flex flex-col overflow-hidden rounded-[28px] bg-card ${onSelect ? "transition-transform duration-200 ease-out active:scale-[0.99] motion-reduce:transition-none motion-reduce:active:scale-100 [&_a]:relative [&_a]:z-10 [&_button]:relative [&_button]:z-10" : ""} ${className}`}>
87
+ {cover && (
88
+ <div className="relative aspect-[4/3] overflow-hidden">
89
+ {/* eslint-disable-next-line @next/next/no-img-element */}
90
+ <img src={cover.src} alt={cover.alt ?? ""} loading="lazy" decoding="async" className="absolute inset-0 h-full w-full object-cover" style={{ objectPosition: cover.position ?? "50% 25%" }} />
91
+ </div>
92
+ )}
93
+ <div className="flex flex-1 flex-col p-6 md:p-7">
94
+ {/* the course is what the person looks for: it is the heading; the city
95
+ is the variant, on the line below with the seats pill (2026-10-29) */}
96
+ <Title className="font-heading text-xl font-medium leading-snug text-ink md:text-[1.375rem]">
97
+ {onSelect ? (
98
+ // the stretched hit area of the card: this button is the exception to [&_button]:z-10 (static, after: covers the card)
99
+ <button type="button" onClick={onSelect} className="static! z-auto! text-left after:absolute after:inset-0 after:z-0 after:rounded-[28px] focus-visible:outline-none focus-visible:after:outline focus-visible:after:outline-2 focus-visible:after:-outline-offset-2 focus-visible:after:outline-ink">
100
+ {course ?? title}
101
+ </button>
102
+ ) : (
103
+ course ?? title
104
+ )}
105
+ </Title>
106
+ {(course || pill) && (
107
+ <p className="mt-2 flex flex-wrap items-center gap-2 text-base font-semibold text-ink">
108
+ {course && <span>{title}</span>}
109
+ {pill && <span className={PILL}>{pill}</span>}
110
+ </p>
111
+ )}
112
+
113
+ <EventFacts className="mt-5" facts={facts} linkComponent={linkComponent} />
114
+
115
+ {instructors && instructors.length > 0 && (
116
+ <div className="mt-5 flex items-center gap-3 text-sm text-ink-body">
117
+ <span className="shrink-0 text-ink-soft">{t.with}</span>
118
+ <Byline people={instructors} linkComponent={linkComponent} className="min-w-0" />
119
+ </div>
120
+ )}
121
+
122
+ {footnote && <p className="mt-5 text-sm text-ink-soft">{footnote}</p>}
123
+
124
+ {/* at the foot: the price on the left, the button on the right; cards side by side align here */}
125
+ {(foot || price) && (
126
+ <div className="mt-auto flex flex-wrap items-center justify-between gap-x-4 gap-y-3 pt-6">
127
+ {price && (
128
+ <p className="leading-tight">
129
+ <span className="block font-heading text-xl font-semibold text-ink">{price.value}</span>
130
+ {price.note && <span className="block text-sm text-ink-soft">{price.note}</span>}
131
+ </p>
132
+ )}
133
+ {foot}
134
+ </div>
135
+ )}
136
+ </div>
137
+ </article>
138
+ );
139
+ }
@@ -0,0 +1,24 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ type Props = {
4
+ /** Short month ("Nov"). */
5
+ month: ReactNode;
6
+ /** Day of the month. */
7
+ day: ReactNode;
8
+ /** 48px (default) or 56px. */
9
+ size?: "md" | "lg";
10
+ className?: string;
11
+ };
12
+
13
+ /** The date as a small calendar tile (Luma's "NOV / 6"): the month small on
14
+ * top, the day large below, on a tinted bubble. Decorative beside the written
15
+ * date; alone, give the parent an accessible text. */
16
+ export function DateBadge({ month, day, size = "md", className = "" }: Props) {
17
+ const box = size === "lg" ? "h-14 w-14 rounded-2xl" : "h-12 w-12 rounded-xl";
18
+ return (
19
+ <span aria-hidden="true" className={`flex ${box} shrink-0 flex-col items-center justify-center bg-ink/[0.06] text-ink ${className}`}>
20
+ <span className="text-[0.6rem] font-bold uppercase leading-none tracking-[0.12em] text-ink-soft">{month}</span>
21
+ <span className={`mt-1 font-heading font-bold leading-none ${size === "lg" ? "text-2xl" : "text-lg"}`}>{day}</span>
22
+ </span>
23
+ );
24
+ }