@breathe-ds/ui 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -9
- package/components/Accordion.tsx +6 -4
- package/components/Avatar.tsx +37 -0
- package/components/BottomNav.tsx +1 -1
- package/components/Breadcrumb.tsx +45 -0
- package/components/BreatheMark.tsx +0 -13
- package/components/Byline.tsx +103 -0
- package/components/CaptionBand.tsx +74 -0
- package/components/CtaCard.tsx +52 -0
- package/components/FactStrip.tsx +55 -0
- package/components/Footer.tsx +3 -20
- package/components/Heading.tsx +86 -0
- package/components/InfoCard.tsx +116 -0
- package/components/LanguageSwitcher.tsx +1 -1
- package/components/List.tsx +81 -0
- package/components/Lockup.tsx +0 -18
- package/components/MapCard.tsx +158 -0
- package/components/MediaCard.tsx +266 -0
- package/components/PersonChip.tsx +49 -0
- package/components/Pill.tsx +42 -0
- package/components/PillList.tsx +20 -47
- package/components/ProfessionalCard.tsx +341 -0
- package/components/ProfessionalModal.tsx +243 -0
- package/components/Rows.tsx +131 -0
- package/components/TestimonialCard.tsx +138 -0
- package/components/Text.tsx +31 -0
- package/components/Timeline.tsx +69 -0
- package/components/VideoInviteCard.tsx +168 -0
- package/components/VideoStoryCard.tsx +382 -0
- package/package.json +22 -1
- package/theme.css +66 -0
- package/tokens/creeta.css +5 -2
- package/tokens/neutral.css +10 -4
- package/tokens/ttcab.css +1 -0
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@breathe-ds/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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,6 +46,16 @@
|
|
|
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/Avatar": "./components/Avatar.tsx",
|
|
52
|
+
"./components/Byline": "./components/Byline.tsx",
|
|
53
|
+
"./components/ProfessionalCard": "./components/ProfessionalCard.tsx",
|
|
54
|
+
"./components/ProfessionalModal": "./components/ProfessionalModal.tsx",
|
|
55
|
+
"./components/CaptionBand": "./components/CaptionBand.tsx",
|
|
56
|
+
"./components/PersonChip": "./components/PersonChip.tsx",
|
|
57
|
+
"./components/Breadcrumb": "./components/Breadcrumb.tsx",
|
|
58
|
+
"./components/Timeline": "./components/Timeline.tsx",
|
|
38
59
|
"./components/marks/ttcab": "./components/marks/ttcab.tsx",
|
|
39
60
|
"./components/marks/creeta": "./components/marks/creeta.tsx"
|
|
40
61
|
},
|
package/theme.css
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
/* ── Colors (roles) — value comes from the site's --cg-* ── */
|
|
19
19
|
--color-surface: var(--cg-surface); /* base background */
|
|
20
20
|
--color-surface-panel: var(--cg-surface-panel); /* dark panel (footer/CTA) */
|
|
21
|
+
--color-card: var(--cg-card, #ffffff); /* raised surface: cards, pills, menus over the page */
|
|
21
22
|
--color-ink: var(--cg-ink); /* primary text / headings */
|
|
22
23
|
--color-ink-soft: var(--cg-ink-soft); /* secondary text */
|
|
23
24
|
--color-ink-body: var(--cg-ink-body); /* body copy */
|
|
@@ -38,3 +39,68 @@
|
|
|
38
39
|
/* ── Motion — the family's signature curve ── */
|
|
39
40
|
--ease-cg: cubic-bezier(0.2, 0.8, 0.2, 1);
|
|
40
41
|
}
|
|
42
|
+
|
|
43
|
+
/* ── Base behaviour the whole family shares ──
|
|
44
|
+
Smooth scrolling for anchors (AnchorNav, "On this page", BottomNav); off
|
|
45
|
+
for people who asked for reduced motion. The header sticks at the top
|
|
46
|
+
(~5rem) and, on phones, the BottomNav sticks at the bottom: without this
|
|
47
|
+
padding the control that receives keyboard focus would stop hidden behind
|
|
48
|
+
them (WCAG 2.4.11). Anchored sections may still add their own scroll-margin. */
|
|
49
|
+
@layer base {
|
|
50
|
+
html {
|
|
51
|
+
scroll-behavior: smooth;
|
|
52
|
+
scroll-padding-top: 6rem;
|
|
53
|
+
scroll-padding-bottom: 7rem;
|
|
54
|
+
}
|
|
55
|
+
@media (prefers-reduced-motion: reduce) {
|
|
56
|
+
html {
|
|
57
|
+
scroll-behavior: auto;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* Outbound/anchor arrow on links: takes a small step toward where the link
|
|
63
|
+
goes, on hover. Used by Footer, List, Byline, PersonChip, PillList. */
|
|
64
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
65
|
+
.cg-arrow-go {
|
|
66
|
+
transition: transform 150ms var(--ease-cg);
|
|
67
|
+
}
|
|
68
|
+
a:hover .cg-arrow-go,
|
|
69
|
+
.group:hover .cg-arrow-go {
|
|
70
|
+
transform: translate(2px, -2px);
|
|
71
|
+
}
|
|
72
|
+
.cg-arrow-down {
|
|
73
|
+
transition: transform 150ms var(--ease-cg);
|
|
74
|
+
}
|
|
75
|
+
a:hover .cg-arrow-down,
|
|
76
|
+
.group:hover .cg-arrow-down {
|
|
77
|
+
transform: translateY(2px);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* ── BreatheMark: the symbol "breathes" (scale pulse); off under reduced motion ── */
|
|
82
|
+
@keyframes breathe-mark-inhale {
|
|
83
|
+
0%, 100% { transform: scale(0.93); }
|
|
84
|
+
50% { transform: scale(1.05); }
|
|
85
|
+
}
|
|
86
|
+
.breathe-mark__g {
|
|
87
|
+
transform-box: fill-box;
|
|
88
|
+
transform-origin: center;
|
|
89
|
+
animation: breathe-mark-inhale var(--breathe-dur, 6s) cubic-bezier(0.37, 0, 0.63, 1) infinite;
|
|
90
|
+
}
|
|
91
|
+
.breathe-mark--static .breathe-mark__g { animation: none; }
|
|
92
|
+
@media (prefers-reduced-motion: reduce) { .breathe-mark__g { animation: none; } }
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
/* ── Lockup entrance: symbol (scale + slight turn) → name (slides in) → tagline.
|
|
96
|
+
Same vocabulary as the Curtain; under reduced motion only a short fade. ── */
|
|
97
|
+
.cg-lockup--entrance .cg-lockup__mark{ animation: cg-lockup-mark 500ms var(--ease-cg,cubic-bezier(.2,.8,.2,1)) var(--cg-entrance-delay,0ms) both; }
|
|
98
|
+
.cg-lockup--entrance .cg-lockup__name{ animation: cg-lockup-name 420ms var(--ease-cg,cubic-bezier(.2,.8,.2,1)) calc(var(--cg-entrance-delay,0ms) + 420ms) both; }
|
|
99
|
+
.cg-lockup--entrance .cg-lockup__tag{ animation: cg-lockup-tag 360ms ease calc(var(--cg-entrance-delay,0ms) + 700ms) both; }
|
|
100
|
+
@keyframes cg-lockup-mark{ from{ opacity:0; transform:scale(.6) rotate(-16deg); } }
|
|
101
|
+
@keyframes cg-lockup-name{ from{ opacity:0; transform:translateX(-8px); } }
|
|
102
|
+
@keyframes cg-lockup-tag{ from{ opacity:0; transform:translateY(4px); } }
|
|
103
|
+
@media (prefers-reduced-motion:reduce){
|
|
104
|
+
.cg-lockup--entrance .cg-lockup__mark, .cg-lockup--entrance .cg-lockup__name, .cg-lockup--entrance .cg-lockup__tag{ animation: cg-lockup-fade 240ms ease var(--cg-entrance-delay,0ms) both; }
|
|
105
|
+
@keyframes cg-lockup-fade{ from{ opacity:0; } }
|
|
106
|
+
}
|
package/tokens/creeta.css
CHANGED
|
@@ -2,14 +2,17 @@
|
|
|
2
2
|
Creeta palette (RTA clinic in Floripa) — petrol + mist + magenta.
|
|
3
3
|
Values from novo.creeta. Measured contrasts (WCAG formula):
|
|
4
4
|
- surface #eef3f4 / ink #144753 ............ ~9.1:1 (AAA)
|
|
5
|
-
- surface #eef3f4 / ink-soft #
|
|
5
|
+
- surface #eef3f4 / ink-soft #266675 ....... 5.79:1 (AA) — the site's "teal-escuro":
|
|
6
|
+
second tone of two-tone headings, eyebrows, strong links
|
|
7
|
+
- surface #eef3f4 / ink-body #1e4f5b ....... 8.07:1 (AAA)
|
|
6
8
|
- action #ab2781 (magenta) is the CTA ("Agendar"); text on it = white.
|
|
7
9
|
*/
|
|
8
10
|
:root {
|
|
9
11
|
--cg-surface: #eef3f4; /* mist */
|
|
10
12
|
--cg-surface-panel: #144753; /* petrol */
|
|
13
|
+
--cg-card: #ffffff; /* cards, pills and menus over the page */
|
|
11
14
|
--cg-ink: #144753; /* petrol */
|
|
12
|
-
--cg-ink-soft: #
|
|
15
|
+
--cg-ink-soft: #266675; /* teal-escuro */
|
|
13
16
|
--cg-ink-body: #1e4f5b;
|
|
14
17
|
--cg-action: #ab2781; /* magenta (Agendar) */
|
|
15
18
|
--cg-action-text: #8f1f6c; /* darker magenta for text */
|
package/tokens/neutral.css
CHANGED
|
@@ -3,14 +3,20 @@
|
|
|
3
3
|
purpose: color comes from EACH application (ttcab, creeta…). Use this as the
|
|
4
4
|
base and override the --cg-* in your site's :root. Monochrome (gray/near-black)
|
|
5
5
|
to make it clear it is a "bring your own color" placeholder.
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
The page is a light gray and the cards are white: the family is tonal (mist on
|
|
7
|
+
creeta, cream on ttcab), so the neutral palette is too — a white page would
|
|
8
|
+
swallow the white cards.
|
|
9
|
+
- surface #f4f4f5 / ink #18181b ............ 16.1:1 (AAA)
|
|
10
|
+
- surface #f4f4f5 / ink-soft #65656e ....... 5.25:1 (AA)
|
|
11
|
+
- surface #f4f4f5 / ink-body #3f3f46 ....... 9.5:1 (AAA)
|
|
12
|
+
- card #ffffff / ink-soft #65656e .......... 5.77:1 (AA)
|
|
8
13
|
*/
|
|
9
14
|
:root {
|
|
10
|
-
--cg-surface: #
|
|
15
|
+
--cg-surface: #f4f4f5; /* page: light gray, so white cards read */
|
|
11
16
|
--cg-surface-panel: #18181b;
|
|
17
|
+
--cg-card: #ffffff;
|
|
12
18
|
--cg-ink: #18181b;
|
|
13
|
-
--cg-ink-soft: #
|
|
19
|
+
--cg-ink-soft: #65656e;
|
|
14
20
|
--cg-ink-body: #3f3f46;
|
|
15
21
|
--cg-action: #18181b;
|
|
16
22
|
--cg-action-text: #52525b;
|