@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,168 @@
1
+ "use client";
2
+
3
+ import { useState, type ReactNode } from "react";
4
+ import { BackgroundVideo } from "./BackgroundVideo";
5
+
6
+ /** Muted looping clip shown while idle (same fields as BackgroundVideo). */
7
+ export type InviteLoop = {
8
+ src: string;
9
+ /** Larger version, downloaded only on wide screens (from `from` up). */
10
+ srcLarge?: string;
11
+ /** Minimum width (px) for the larger version. Defaults to 1024. */
12
+ from?: 768 | 1024;
13
+ /** Still frame: shown before the clip loads, without JS, and with reduced motion. */
14
+ poster: string;
15
+ posterLarge?: string;
16
+ };
17
+
18
+ /** Text track drawn by the browser over the full video (.vtt). */
19
+ export type InviteTrack = {
20
+ src: string;
21
+ /** BCP 47 language tag, e.g. "pt-BR", "en". */
22
+ srcLang: string;
23
+ /** Name shown in the browser's track menu, e.g. "Português". */
24
+ label: string;
25
+ kind?: "captions" | "subtitles";
26
+ default?: boolean;
27
+ };
28
+
29
+ /** Full video with sound, played on tap. Only downloaded then. */
30
+ export type InviteVideo = {
31
+ src: string;
32
+ poster?: string;
33
+ tracks?: InviteTrack[];
34
+ };
35
+
36
+ type Labels = {
37
+ /** Screen-reader label of the play button (the circle, bottom-right). */
38
+ listen: string;
39
+ /** aria-label of the full video while it plays. */
40
+ video: string;
41
+ /** Labels of the loop's pause button (top-right), passed to BackgroundVideo. */
42
+ pause: string;
43
+ play: string;
44
+ };
45
+
46
+ const EN: Labels = {
47
+ listen: "Listen to the invitation",
48
+ video: "Video invitation, with sound",
49
+ pause: "Pause the video",
50
+ play: "Play the video",
51
+ };
52
+
53
+ export type VideoInviteCardProps = {
54
+ /** Overlay title, top-left, in both phases (on the site: who appears). Ink text, no background. */
55
+ title: ReactNode;
56
+ /** Second line under the title (on the site: the person's role). */
57
+ description?: ReactNode;
58
+ /** Idle phase: muted looping clip. */
59
+ loop: InviteLoop;
60
+ /** Tap phase: the full video with sound, browser controls and captions. */
61
+ video: InviteVideo;
62
+ /** Called when the person taps play (e.g. to pause other media on the page). */
63
+ onPlay?: () => void;
64
+ /** Called when the full video ends and the card goes back to the loop. */
65
+ onEnded?: () => void;
66
+ labels?: Partial<Labels>;
67
+ /** Size/position of the frame (aspect, min-height, grid order…). */
68
+ className?: string;
69
+ };
70
+
71
+ function PlayGlyph({ size = 22 }: { size?: number }) {
72
+ return <svg viewBox="0 0 24 24" width={size} height={size} className="ml-0.5" fill="currentColor" aria-hidden="true"><path d="M6 3v18l15-9z" /></svg>;
73
+ }
74
+
75
+ /**
76
+ * Video invitation card of the Cuidado Global family — mirrors the
77
+ * `VideoConvite` of novo.creeta with the Breathe tokens, in two phases.
78
+ *
79
+ * Idle, it is a background video: the speaker on a muted loop (BackgroundVideo:
80
+ * respects reduced motion and data saver, has the pause button top-right —
81
+ * WCAG 2.2.2). Over it, the title and description top-left and a play button
82
+ * bottom-right, outside the face. On tap, the second video takes over, with
83
+ * sound: the invitation itself, with captions and the browser's controls.
84
+ * Because play is a click by the person, the browser allows the audio. When
85
+ * the invitation ends, the card goes back to the loop.
86
+ *
87
+ * The full video only downloads on tap. The frame is a 28px-radius card
88
+ * (`rounded-2xl` in the DS scale); the consumer sets aspect/min-height via
89
+ * `className`.
90
+ */
91
+ export function VideoInviteCard({ title, description, loop, video, onPlay, onEnded, labels, className = "" }: VideoInviteCardProps) {
92
+ const [inviting, setInviting] = useState(false);
93
+ const t = { ...EN, ...labels };
94
+
95
+ /* No background, straight over the video, in ink top-left, where the frame
96
+ is the light wall in both phases (measured on the site's footage: 6.18:1
97
+ median on the loop, 5.86:1 on the invitation). The light text-shadow
98
+ holds it on the darker frames. */
99
+ const caption = (
100
+ <p className="inline-block text-sm font-medium leading-snug text-ink [text-shadow:0_1px_2px_rgba(255,255,255,0.5)]">
101
+ <span className="block">{title}</span>
102
+ {description && <span className="block font-normal">{description}</span>}
103
+ </p>
104
+ );
105
+
106
+ const frame = `relative overflow-hidden rounded-2xl bg-ink ${className}`;
107
+
108
+ if (inviting) {
109
+ return (
110
+ <div className={frame}>
111
+ <video
112
+ src={video.src}
113
+ poster={video.poster}
114
+ controls
115
+ autoPlay
116
+ playsInline
117
+ onEnded={() => {
118
+ setInviting(false);
119
+ onEnded?.();
120
+ }}
121
+ aria-label={t.video}
122
+ className="absolute inset-0 h-full w-full bg-ink object-cover"
123
+ >
124
+ {video.tracks?.map((track) => (
125
+ <track
126
+ key={track.src}
127
+ kind={track.kind ?? "captions"}
128
+ src={track.src}
129
+ srcLang={track.srcLang}
130
+ label={track.label}
131
+ default={track.default}
132
+ />
133
+ ))}
134
+ </video>
135
+ <div className="pointer-events-none absolute top-4 left-4 right-4">{caption}</div>
136
+ </div>
137
+ );
138
+ }
139
+
140
+ return (
141
+ <div className={frame}>
142
+ <BackgroundVideo
143
+ src={loop.src}
144
+ srcLarge={loop.srcLarge}
145
+ from={loop.from}
146
+ poster={loop.poster}
147
+ posterLarge={loop.posterLarge}
148
+ labels={{ pause: t.pause, play: t.play }}
149
+ className="absolute inset-0"
150
+ />
151
+ {/* right-16: the loop's pause button sits in the top-right corner. */}
152
+ <div className="pointer-events-none absolute top-4 left-4 right-16">{caption}</div>
153
+ {/* Only the circle is a button, so the loop's pause button (top corner)
154
+ stays reachable. Bottom-right, outside the face. */}
155
+ <button
156
+ type="button"
157
+ onClick={() => {
158
+ onPlay?.();
159
+ setInviting(true);
160
+ }}
161
+ className="group absolute right-4 bottom-4 z-10 flex h-14 w-14 items-center justify-center rounded-full bg-card text-ink shadow-[0_8px_24px_-8px_rgba(0,0,0,0.6)] transition-transform duration-200 ease-out hover:scale-105 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-surface motion-reduce:transition-none motion-reduce:hover:scale-100 motion-reduce:active:scale-100"
162
+ >
163
+ <PlayGlyph size={22} />
164
+ <span className="sr-only">{t.listen}</span>
165
+ </button>
166
+ </div>
167
+ );
168
+ }
@@ -0,0 +1,382 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState, type ElementType, type ReactNode } from "react";
4
+ import { Check, Play } from "lucide-react";
5
+
6
+ /* ── Pause-all coordination (ported from creeta's `pausa-dos-videos.tsx`) ──
7
+ A page-level "pause the card videos" button (WCAG 2.2.2) does two things:
8
+ `document.documentElement.toggleAttribute(CARD_VIDEOS_PAUSED_ATTR, paused)`
9
+ and `window.dispatchEvent(new Event(CARD_VIDEOS_PAUSE_EVENT))`. The hover
10
+ preview of this card checks the attribute before playing, and listens to
11
+ the event to stop a preview that is already running. The button itself
12
+ lives in the site (it is one per page, next to the grid), not here. */
13
+
14
+ /** Event name the card previews listen to. */
15
+ export const CARD_VIDEOS_PAUSE_EVENT = "cg-card-videos";
16
+ /** Attribute on <html> while the card previews are paused by the person. */
17
+ export const CARD_VIDEOS_PAUSED_ATTR = "data-card-videos-paused";
18
+
19
+ /** Seconds → "1 min 39 s" (the site's `duracaoPorExtenso`, English units).
20
+ * Pass an already formatted string to `duration` for other languages. */
21
+ export function formatDuration(seconds: number): string {
22
+ const m = Math.floor(seconds / 60);
23
+ const s = seconds % 60;
24
+ if (m === 0) return `${s} s`;
25
+ return s === 0 ? `${m} min` : `${m} min ${s} s`;
26
+ }
27
+
28
+ type Labels = {
29
+ /** sr-only text of the poster button: "Watch: {title}, {duration}". */
30
+ watch: (title: string, duration: string) => string;
31
+ /** sr-only text of the poster button once watched: "Watched. Watch again: …". */
32
+ watchAgain: (title: string, duration: string) => string;
33
+ /** The badge shown on the poster once the video has been watched to the end. */
34
+ watched: string;
35
+ };
36
+ const EN: Labels = {
37
+ watch: (title, duration) => `Watch: ${title}, ${duration}`,
38
+ watchAgain: (title, duration) => `Watched. Watch again: ${title}, ${duration}`,
39
+ watched: "Watched",
40
+ };
41
+
42
+ /** localStorage key of the set of watched clips (by `src`), with `remember`. */
43
+ export const WATCHED_STORAGE_KEY = "cg-watched";
44
+
45
+ function readWatched(): string[] {
46
+ try {
47
+ const raw = localStorage.getItem(WATCHED_STORAGE_KEY);
48
+ const list = raw ? JSON.parse(raw) : [];
49
+ return Array.isArray(list) ? list : [];
50
+ } catch {
51
+ return [];
52
+ }
53
+ }
54
+
55
+ function storeWatched(src: string) {
56
+ try {
57
+ const list = readWatched();
58
+ if (!list.includes(src)) localStorage.setItem(WATCHED_STORAGE_KEY, JSON.stringify([...list, src]));
59
+ } catch {
60
+ /* private mode, blocked storage: the badge still shows for this visit */
61
+ }
62
+ }
63
+
64
+ export type VideoStoryCardCaptions = {
65
+ /** WebVTT file in the page's language. */
66
+ src: string;
67
+ /** BCP 47 tag of the captions ("en", "pt-BR"). */
68
+ lang: string;
69
+ };
70
+
71
+ type Props = {
72
+ /** Title of the clip (the heading of the card). */
73
+ title: string;
74
+ /** Who speaks, under the title ("Mariangela Fonseca"). */
75
+ speaker?: ReactNode;
76
+ /** One sentence. Collapsed by default; opens on hover (mouse only) or on
77
+ * keyboard focus. In the side layout it is always visible. */
78
+ lead?: ReactNode;
79
+ /** The clip. Downloaded only when the person presses play (with sound and
80
+ * the browser's controls). */
81
+ src: string;
82
+ /** Still frame shown before play (a few kB: with ten cards on a page this is
83
+ * what makes it load on 4G). Also used as the <video> poster. */
84
+ poster: string;
85
+ /** Responsive variants of the poster ("/v/x-480.webp 480w"). */
86
+ posterSrcSet?: string;
87
+ /** `sizes` of the poster. Default: one of three columns from md up. */
88
+ sizes?: string;
89
+ /** Replaces the default <img>: the site's own image component. Must fill
90
+ * the box (h-full w-full object-cover). */
91
+ media?: ReactNode;
92
+ /** Dominant color of the poster, shown while it loads. */
93
+ color?: string;
94
+ /** "1 min 39 s" (string) or the seconds (formatted in English by formatDuration). */
95
+ duration: string | number;
96
+ /** Optional muted, looping clip layered over the poster while the mouse is
97
+ * on the card or the card has keyboard focus (the `hover` mode of creeta's
98
+ * `VideoDoCartao`). Only where a mouse exists (hover: hover and pointer:
99
+ * fine); never with reduced motion, data saver, or while the page's
100
+ * pause-all is on. Fades in once it is really playing; otherwise the
101
+ * poster stays. Not in the source card: opt-in. */
102
+ previewSrc?: string;
103
+ /** Hidden subtitle track + a caption band under the video while it plays
104
+ * (creeta's `legenda.tsx`): many clips already have burned-in captions,
105
+ * so the browser's overlay would collide with them. */
106
+ captions?: VideoStoryCardCaptions;
107
+ /** h2 on list pages, h3 inside a section that already has an h2. */
108
+ headingLevel?: "h2" | "h3";
109
+ /** `stacked` (default): title, speaker and lead over the poster, bottom
110
+ * gradient. `side`: from md the video takes one column of three and the
111
+ * text sits beside it (the card is alone in its group). */
112
+ layout?: "stacked" | "side";
113
+ /** Element rendered as the card root (default <article>); `li` in a list. */
114
+ as?: ElementType;
115
+ /** id of the root (anchor target: scroll-mt-24 is built in). */
116
+ id?: string;
117
+ /** Marks the card as watched from the start (the site keeps its own record).
118
+ * Either way the card marks itself once the video plays to the end. */
119
+ watched?: boolean;
120
+ /** Called once when the video plays to the end (to store it on the site). */
121
+ onWatched?: () => void;
122
+ /** Remembers watched clips in localStorage (by `src`) across visits, for a
123
+ * page with many videos. Off by default: the site decides. */
124
+ remember?: boolean;
125
+ /** What goes right under the video: a transcript, on the videos page. */
126
+ children?: ReactNode;
127
+ labels?: Partial<Labels>;
128
+ className?: string;
129
+ };
130
+
131
+ /** The spoken line right now, read from the hidden <track>. 'hidden' mode:
132
+ * the browser loads and fires cuechange but does not draw. */
133
+ function useCaptionLine() {
134
+ const [line, setLine] = useState("");
135
+ const attach = useCallback((el: HTMLTrackElement | null) => {
136
+ const track = el?.track;
137
+ if (!track) return;
138
+ track.mode = "hidden";
139
+ track.addEventListener("cuechange", () => {
140
+ const cue = track.activeCues?.[0] as VTTCue | undefined;
141
+ setLine(cue ? cue.text : "");
142
+ });
143
+ }, []);
144
+ return { line, attach };
145
+ }
146
+
147
+ /** Caption band under the video: fixed height of three lines so the page does
148
+ * not jump at every line; hidden from screen readers (the transcript is on
149
+ * the page). On mount it scrolls the least needed to be in view: on phones it
150
+ * would be born behind the bottom bar. */
151
+ function CaptionLine({ text, lang }: { text: string; lang: string }) {
152
+ const band = useRef<HTMLParagraphElement>(null);
153
+ useEffect(() => {
154
+ const calm = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
155
+ band.current?.scrollIntoView?.({ block: "nearest", behavior: calm ? "auto" : "smooth" });
156
+ }, []);
157
+ return (
158
+ <p
159
+ ref={band}
160
+ aria-hidden="true"
161
+ lang={lang}
162
+ className="mt-2 flex min-h-[4.75rem] scroll-mb-28 items-center justify-center rounded-[20px] bg-ink px-4 py-2.5 text-center text-[0.9375rem] font-medium leading-snug text-white md:scroll-mb-6"
163
+ >
164
+ {text}
165
+ </p>
166
+ );
167
+ }
168
+
169
+ /**
170
+ * Vertical (9:16) video card of the Cuidado Global family — the Instagram-post
171
+ * card of novo.creeta (`VideoCartao`), by tokens. Until the person presses
172
+ * play it is a poster with the round play button and the duration badge in the
173
+ * top-left corner; the title and the speaker sit on a short ink gradient at the
174
+ * bottom. On hover (mouse) or keyboard focus the gradient grows and the lead
175
+ * slides open (grid-rows 0fr → 1fr, no height animation). Tap/click swaps the
176
+ * poster for the <video> with sound and controls, and the overlay text leaves.
177
+ * Optional muted preview on hover (`previewSrc`) and caption band (`captions`).
178
+ * When the video ends the card goes back to the poster with a "Watched" badge
179
+ * (check + word, never color alone) in the top-right corner, opposite the play:
180
+ * on a page with many clips it says which ones are done.
181
+ */
182
+ export function VideoStoryCard({
183
+ title,
184
+ speaker,
185
+ lead,
186
+ src,
187
+ poster,
188
+ posterSrcSet,
189
+ sizes = "(min-width: 1152px) 360px, (min-width: 768px) 33vw, 78vw",
190
+ media,
191
+ color,
192
+ duration,
193
+ previewSrc,
194
+ captions,
195
+ headingLevel: Heading = "h3",
196
+ layout = "stacked",
197
+ as: Root = "article",
198
+ id,
199
+ watched: watchedProp = false,
200
+ onWatched,
201
+ remember = false,
202
+ children,
203
+ labels,
204
+ className = "",
205
+ }: Props) {
206
+ const t = { ...EN, ...labels };
207
+ const side = layout === "side";
208
+ const durationText = typeof duration === "number" ? formatDuration(duration) : duration;
209
+ const [playing, setPlaying] = useState(false);
210
+ const { line, attach } = useCaptionLine();
211
+
212
+ // ── watched: from the prop, from storage (after mount, so SSR and the first
213
+ // paint agree), or from the video reaching the end ──
214
+ const [watchedHere, setWatchedHere] = useState(false);
215
+ useEffect(() => {
216
+ if (remember && readWatched().includes(src)) setWatchedHere(true);
217
+ }, [remember, src]);
218
+ const watched = watchedProp || watchedHere;
219
+ const onEnded = () => {
220
+ setPlaying(false);
221
+ if (!watched) onWatched?.();
222
+ setWatchedHere(true);
223
+ if (remember) storeWatched(src);
224
+ };
225
+
226
+ // ── hover preview (muted loop over the poster) ──
227
+ const card = useRef<HTMLElement>(null);
228
+ const preview = useRef<HTMLVideoElement>(null);
229
+ const [previewing, setPreviewing] = useState(false);
230
+
231
+ useEffect(() => {
232
+ const v = preview.current;
233
+ const root = card.current;
234
+ if (!previewSrc || !v || !root || typeof window.matchMedia !== "function") return;
235
+
236
+ const saveData = (navigator as Navigator & { connection?: { saveData?: boolean } }).connection?.saveData === true;
237
+ const calm = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
238
+ const pausedByPerson = () => document.documentElement.hasAttribute(CARD_VIDEOS_PAUSED_ATTR);
239
+ const play = () => {
240
+ if (calm() || saveData || pausedByPerson()) return;
241
+ v.play()
242
+ .then(() => setPreviewing(true))
243
+ .catch(() => {});
244
+ };
245
+ const stop = () => {
246
+ setPreviewing(false);
247
+ v.pause();
248
+ };
249
+ const withMouse = () => window.matchMedia("(hover: hover) and (pointer: fine)").matches;
250
+ const enter = () => withMouse() && play();
251
+ // the person pressed the page's pause-all while the preview was running
252
+ const onPauseAll = () => pausedByPerson() && stop();
253
+
254
+ root.addEventListener("pointerenter", enter);
255
+ root.addEventListener("pointerleave", stop);
256
+ root.addEventListener("focusin", enter);
257
+ root.addEventListener("focusout", stop);
258
+ window.addEventListener(CARD_VIDEOS_PAUSE_EVENT, onPauseAll);
259
+ return () => {
260
+ root.removeEventListener("pointerenter", enter);
261
+ root.removeEventListener("pointerleave", stop);
262
+ root.removeEventListener("focusin", enter);
263
+ root.removeEventListener("focusout", stop);
264
+ window.removeEventListener(CARD_VIDEOS_PAUSE_EVENT, onPauseAll);
265
+ };
266
+ }, [previewSrc, playing]);
267
+
268
+ const overlayTitle = `font-heading text-xl font-medium leading-snug md:text-[1.375rem]`;
269
+
270
+ return (
271
+ <Root
272
+ ref={card}
273
+ id={id}
274
+ className={`group/card scroll-mt-24 ${side ? "md:grid md:grid-cols-3 md:items-center md:gap-5" : ""} ${className}`}
275
+ >
276
+ {/* the video and what is glued to it: the caption band and the transcript */}
277
+ <div>
278
+ <div className="relative aspect-[9/16] overflow-hidden rounded-[28px]" style={color ? { backgroundColor: color } : undefined}>
279
+ {playing ? (
280
+ <video
281
+ src={src}
282
+ poster={poster}
283
+ controls
284
+ autoPlay
285
+ playsInline
286
+ onEnded={onEnded}
287
+ className="absolute inset-0 h-full w-full bg-ink object-contain"
288
+ >
289
+ {captions && <track ref={attach} kind="subtitles" src={captions.src} srcLang={captions.lang} default />}
290
+ </video>
291
+ ) : (
292
+ <button
293
+ type="button"
294
+ onClick={() => setPlaying(true)}
295
+ className="group absolute inset-0 h-full w-full focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-white"
296
+ >
297
+ {media ?? (
298
+ // eslint-disable-next-line @next/next/no-img-element
299
+ <img
300
+ src={poster}
301
+ srcSet={posterSrcSet}
302
+ sizes={posterSrcSet ? sizes : undefined}
303
+ alt=""
304
+ width={540}
305
+ height={960}
306
+ loading="lazy"
307
+ decoding="async"
308
+ className="h-full w-full object-cover"
309
+ />
310
+ )}
311
+ {/* muted preview: invisible until it really plays, then fades in
312
+ (otherwise it would flash black while the file arrives) */}
313
+ {previewSrc && (
314
+ <video
315
+ ref={preview}
316
+ src={previewSrc}
317
+ muted
318
+ loop
319
+ playsInline
320
+ preload="none"
321
+ aria-hidden="true"
322
+ tabIndex={-1}
323
+ className={`pointer-events-none absolute inset-0 h-full w-full object-cover transition-opacity duration-500 ease-out motion-reduce:transition-none ${previewing ? "opacity-100" : "opacity-0"}`}
324
+ />
325
+ )}
326
+ {/* watched: top-right, opposite the play, check and word (not color alone) */}
327
+ {watched && (
328
+ <span aria-hidden="true" className="absolute right-3 top-3 flex items-center gap-1.5 rounded-full bg-card/90 py-1.5 pl-2.5 pr-3 text-xs font-semibold text-ink backdrop-blur-md">
329
+ <Check size={14} strokeWidth={2.5} />
330
+ {t.watched}
331
+ </span>
332
+ )}
333
+ {/* play and duration, together in the top corner */}
334
+ <span aria-hidden="true" className="absolute left-3 top-3 flex items-center gap-2">
335
+ <span className="flex h-11 w-11 items-center justify-center rounded-full bg-card text-ink shadow-[0_8px_24px_-8px_rgba(0,0,0,0.6)] transition-transform duration-200 ease-out group-hover:scale-105 group-active:scale-95 motion-reduce:transition-none motion-reduce:group-hover:scale-100 motion-reduce:group-active:scale-100">
336
+ <Play size={18} strokeWidth={2} className="ml-0.5 fill-current" />
337
+ </span>
338
+ <span className="rounded-full bg-ink/85 px-3 py-1.5 text-xs font-medium text-white backdrop-blur-md">
339
+ {durationText}
340
+ </span>
341
+ </span>
342
+ {/* the gradient that holds the text: full ink at the bottom,
343
+ nothing at the top; grows when the lead appears */}
344
+ <span
345
+ aria-hidden="true"
346
+ className="pointer-events-none absolute inset-x-0 bottom-0 h-[45%] bg-gradient-to-t from-ink from-20% via-ink/80 via-55% to-transparent transition-[height] duration-300 ease-out group-focus-within/card:h-[70%] motion-reduce:transition-none [@media(hover:hover)_and_(pointer:fine)]:group-hover/card:h-[70%]"
347
+ />
348
+ <span className="sr-only">{watched ? t.watchAgain(title, durationText) : t.watch(title, durationText)}</span>
349
+ </button>
350
+ )}
351
+ {/* Caption over the gradient. Outside the button so the title stays a
352
+ heading. Leaves when the video plays. */}
353
+ {!playing && !side && (
354
+ <div className="pointer-events-none absolute inset-x-0 bottom-0 p-5 text-white md:p-6">
355
+ <Heading className={overlayTitle}>{title}</Heading>
356
+ {speaker && <p className="mt-1 text-sm font-semibold text-white/80">{speaker}</p>}
357
+ {/* the lead: collapsed, opens on hover or on focus of the play
358
+ (grid-rows 0fr → 1fr, no height animation) */}
359
+ {lead && (
360
+ <div className="grid grid-rows-[0fr] transition-[grid-template-rows] duration-300 ease-out group-focus-within/card:grid-rows-[1fr] motion-reduce:transition-none [@media(hover:hover)_and_(pointer:fine)]:group-hover/card:grid-rows-[1fr]">
361
+ <p className="min-h-0 overflow-hidden text-sm leading-relaxed text-white/85 opacity-0 transition-opacity duration-300 ease-out group-focus-within/card:opacity-100 motion-reduce:transition-none [@media(hover:hover)_and_(pointer:fine)]:group-hover/card:opacity-100">
362
+ <span className="block pt-1.5">{lead}</span>
363
+ </p>
364
+ </div>
365
+ )}
366
+ </div>
367
+ )}
368
+ </div>
369
+ {playing && captions && <CaptionLine text={line} lang={captions.lang} />}
370
+ {children}
371
+ </div>
372
+ {/* Side layout (card alone in its group): the text sits beside the video. */}
373
+ {side && (
374
+ <div className="px-6 pt-4 md:col-span-2 md:max-w-md md:pt-0">
375
+ <Heading className={`${overlayTitle} text-ink`}>{title}</Heading>
376
+ {speaker && <p className="mt-1 text-sm font-semibold text-ink">{speaker}</p>}
377
+ {lead && <p className="mt-1.5 text-[0.9375rem] leading-relaxed text-ink/80">{lead}</p>}
378
+ </div>
379
+ )}
380
+ </Root>
381
+ );
382
+ }
@@ -0,0 +1,39 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+
5
+ /** whole: the drawing finished, not animating (no JavaScript, no
6
+ * IntersectionObserver, or reduced motion).
7
+ * waiting: hidden, until it shows up on screen.
8
+ * playing: it showed up; the animation runs. */
9
+ export type AppearState = "whole" | "waiting" | "playing";
10
+
11
+ export const reducedMotion = () =>
12
+ typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
13
+
14
+ /** For drawings that animate once half of them is on screen (creeta's
15
+ * `useAoAparecer`). The default is the finished drawing: it only hides to
16
+ * animate when it can animate. `onScreen` follows the drawing the whole time:
17
+ * a looping animation only runs while someone can see it. */
18
+ export function useOnAppear<T extends HTMLElement>() {
19
+ const ref = useRef<T>(null);
20
+ const [state, setState] = useState<AppearState>("whole");
21
+ const [onScreen, setOnScreen] = useState(false);
22
+
23
+ useEffect(() => {
24
+ const el = ref.current;
25
+ if (!el || typeof IntersectionObserver === "undefined" || reducedMotion()) return;
26
+ setState("waiting");
27
+ const eye = new IntersectionObserver(
28
+ ([e]) => {
29
+ setOnScreen(!!e.isIntersecting);
30
+ if (e.isIntersecting) setState("playing");
31
+ },
32
+ { threshold: 0.5 },
33
+ );
34
+ eye.observe(el);
35
+ return () => eye.disconnect();
36
+ }, []);
37
+
38
+ return { ref, state, onScreen };
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@breathe-ds/ui",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Breathe DS — the design system of the Cuidado Global family of sites (creeta, ttcab, metodorta). Themeable tokens + React components.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,8 +24,19 @@
24
24
  "./components/Button": "./components/Button.tsx",
25
25
  "./components/Container": "./components/Container.tsx",
26
26
  "./components/SectionKicker": "./components/SectionKicker.tsx",
27
+ "./components/Heading": "./components/Heading.tsx",
28
+ "./components/CtaCard": "./components/CtaCard.tsx",
29
+ "./components/InfoCard": "./components/InfoCard.tsx",
30
+ "./components/FactStrip": "./components/FactStrip.tsx",
31
+ "./components/VideoStoryCard": "./components/VideoStoryCard.tsx",
32
+ "./components/TestimonialCard": "./components/TestimonialCard.tsx",
33
+ "./components/MediaCard": "./components/MediaCard.tsx",
34
+ "./components/MapCard": "./components/MapCard.tsx",
35
+ "./components/VideoInviteCard": "./components/VideoInviteCard.tsx",
36
+ "./components/Text": "./components/Text.tsx",
27
37
  "./components/Card": "./components/Card.tsx",
28
38
  "./components/PillList": "./components/PillList.tsx",
39
+ "./components/Pill": "./components/Pill.tsx",
29
40
  "./components/Header": "./components/Header.tsx",
30
41
  "./components/BottomNav": "./components/BottomNav.tsx",
31
42
  "./components/BreatheMark": "./components/BreatheMark.tsx",
@@ -35,8 +46,29 @@
35
46
  "./components/icons": "./components/icons.tsx",
36
47
  "./components/AnchorNav": "./components/AnchorNav.tsx",
37
48
  "./components/LanguageSwitcher": "./components/LanguageSwitcher.tsx",
49
+ "./components/List": "./components/List.tsx",
50
+ "./components/Rows": "./components/Rows.tsx",
51
+ "./components/Rail": "./components/Rail.tsx",
52
+ "./components/Avatar": "./components/Avatar.tsx",
53
+ "./components/Byline": "./components/Byline.tsx",
54
+ "./components/ProfessionalCard": "./components/ProfessionalCard.tsx",
55
+ "./components/ProfessionalModal": "./components/ProfessionalModal.tsx",
56
+ "./components/CaptionBand": "./components/CaptionBand.tsx",
57
+ "./components/PersonChip": "./components/PersonChip.tsx",
58
+ "./components/Breadcrumb": "./components/Breadcrumb.tsx",
59
+ "./components/Timeline": "./components/Timeline.tsx",
38
60
  "./components/marks/ttcab": "./components/marks/ttcab.tsx",
39
- "./components/marks/creeta": "./components/marks/creeta.tsx"
61
+ "./components/marks/creeta": "./components/marks/creeta.tsx",
62
+ "./components/CareSteps": "./components/CareSteps.tsx",
63
+ "./components/ChatPreview": "./components/ChatPreview.tsx",
64
+ "./components/PlanTimeline": "./components/PlanTimeline.tsx",
65
+ "./components/PhotoSequence": "./components/PhotoSequence.tsx",
66
+ "./components/PauseButton": "./components/PauseButton.tsx",
67
+ "./components/useOnAppear": "./components/useOnAppear.ts",
68
+ "./components/StoryPanel": "./components/StoryPanel.tsx",
69
+ "./components/PostCard": "./components/PostCard.tsx",
70
+ "./components/FeaturedPosts": "./components/FeaturedPosts.tsx",
71
+ "./components/PostArticle": "./components/PostArticle.tsx"
40
72
  },
41
73
  "publishConfig": {
42
74
  "access": "public"