@breathe-ds/ui 0.1.1 → 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/LICENSE +21 -0
- package/README.md +219 -142
- package/components/Accordion.tsx +17 -14
- package/components/AnchorNav.tsx +33 -21
- package/components/Avatar.tsx +37 -0
- package/components/BackgroundVideo.tsx +14 -14
- package/components/BottomNav.tsx +70 -69
- package/components/Breadcrumb.tsx +45 -0
- package/components/BreatheMark.tsx +12 -24
- package/components/Button.tsx +12 -12
- package/components/Byline.tsx +103 -0
- package/components/CaptionBand.tsx +74 -0
- package/components/Card.tsx +5 -5
- package/components/Container.tsx +4 -4
- package/components/CtaCard.tsx +52 -0
- package/components/Curtain.tsx +152 -0
- package/components/FactStrip.tsx +55 -0
- package/components/Footer.tsx +169 -77
- package/components/Header.tsx +32 -17
- package/components/Heading.tsx +86 -0
- package/components/InfoCard.tsx +116 -0
- package/components/LanguageSwitcher.tsx +139 -0
- package/components/List.tsx +81 -0
- package/components/Lockup.tsx +38 -55
- package/components/MapCard.tsx +158 -0
- package/components/MediaCard.tsx +266 -0
- package/components/PageVideoHero.tsx +53 -53
- package/components/PersonChip.tsx +49 -0
- package/components/Pill.tsx +42 -0
- package/components/PillList.tsx +21 -48
- package/components/ProfessionalCard.tsx +341 -0
- package/components/ProfessionalModal.tsx +243 -0
- package/components/Rows.tsx +131 -0
- package/components/SectionKicker.tsx +3 -3
- 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/components/icons.tsx +6 -6
- package/components/marks/creeta.tsx +22 -22
- package/components/marks/ttcab.tsx +13 -13
- package/package.json +28 -5
- package/theme.css +88 -22
- package/tokens/creeta.css +14 -12
- package/tokens/neutral.css +16 -10
- package/tokens/ttcab.css +9 -8
- package/components/Cortina.tsx +0 -152
|
@@ -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/components/icons.tsx
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Breathe icon library. Re-exports the ENTIRE lucide-react lib (hundreds of
|
|
3
|
+
* icons), so you can pick any of them when building interfaces:
|
|
4
4
|
* import { Heart, Wind, Baby } from "@breathe-ds/ui/components/icons";
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Icons use `currentColor` and accept `size`/`strokeWidth`. The DS components
|
|
6
|
+
* take icons through slots, so inject whichever you want.
|
|
7
7
|
*/
|
|
8
8
|
export * from "lucide-react";
|
|
9
9
|
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
Quote, Menu, X, ChevronDown, ArrowUpRight, Globe,
|
|
14
14
|
} from "lucide-react";
|
|
15
15
|
|
|
16
|
-
/*
|
|
16
|
+
/* Semantic aliases for the icons the family uses (the "core" ones). */
|
|
17
17
|
export const IconHome = House;
|
|
18
18
|
export const IconWind = Wind;
|
|
19
19
|
export const IconActivity = Activity;
|
|
@@ -39,7 +39,7 @@ export const IconChevronDown = ChevronDown;
|
|
|
39
39
|
export const IconArrowUpRight = ArrowUpRight;
|
|
40
40
|
export const IconGlobe = Globe;
|
|
41
41
|
|
|
42
|
-
/*
|
|
42
|
+
/* Map of the icons in use (core) — name → component. */
|
|
43
43
|
export const used = {
|
|
44
44
|
Home: IconHome, Wind: IconWind, Activity: IconActivity, Baby: IconBaby,
|
|
45
45
|
Search: IconSearch, Users: IconUsers, GraduationCap: IconGraduationCap,
|
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
import type { CSSProperties } from "react";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
* public/brand/creeta-simbolo.svg
|
|
6
|
-
*
|
|
7
|
-
* Lockup
|
|
8
|
-
*
|
|
4
|
+
* Creeta brand (Centro de Reequilíbrio Toracoabdominal), copied from the site's
|
|
5
|
+
* public/brand/creeta-simbolo.svg and creeta-tipo.svg. Symbol (three waves +
|
|
6
|
+
* magenta dot) and "creeta" wordmark kept separate, so the Curtain and the
|
|
7
|
+
* Lockup can assemble the arrangements. Do not edit by hand: the drawing is
|
|
8
|
+
* the one from the manual. The symbol colors are the logo's, not the site tokens.
|
|
9
9
|
*/
|
|
10
10
|
export const CREETA_COLORS = {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
midWave: "#3B9BAF",
|
|
12
|
+
arc: "#266675",
|
|
13
|
+
lightWave: "#63CACD",
|
|
14
|
+
dot: "#A2158A",
|
|
15
15
|
} as const;
|
|
16
16
|
|
|
17
17
|
type SymbolProps = {
|
|
18
|
-
/**
|
|
18
|
+
/** Side in px (square box, 86 × 86). */
|
|
19
19
|
size?: number;
|
|
20
|
-
/**
|
|
20
|
+
/** Single color for every part (e.g. "#fff" on a dark panel). Default: the brand colors. */
|
|
21
21
|
color?: string;
|
|
22
22
|
className?: string;
|
|
23
23
|
title?: string;
|
|
@@ -27,31 +27,31 @@ export function CreetaSymbol({ size = 48, color, className, title = "Creeta" }:
|
|
|
27
27
|
const c = (k: keyof typeof CREETA_COLORS) => color ?? CREETA_COLORS[k];
|
|
28
28
|
return (
|
|
29
29
|
<svg width={size} height={size} viewBox="0 0 86 86" fill="none" role="img" aria-label={title} className={className}>
|
|
30
|
-
<path d="M85.4746 46.2096C83.9959 65.2746 70.0266 80.8266 51.7546 84.696C51.6493 84.7214 51.544 84.7481 51.4507 84.761C48.7053 85.3293 45.8666 85.62 42.9613 85.6334H42.816C26.4706 84.973 13.4266 71.5053 13.4266 54.9892C13.4266 44.1626 19.0387 34.6309 27.5147 29.1908C30.7093 27.1309 34.3133 25.6397 38.196 24.9001C30.5106 26.8798 24.7547 33.6668 24.3453 41.8653C24.3187 42.1824 24.3186 42.4986 24.3186 42.8157C24.3186 43.1334 24.3187 43.449 24.3453 43.7668C24.8347 60.2574 38.3666 73.473 54.9893 73.473C61.1026 73.473 66.7919 71.6908 71.5719 68.6012C79.1506 63.7293 84.4319 55.6096 85.4746 46.2096Z" fill={c("
|
|
31
|
-
<path d="M55.98 12.16V12.173C55.9667 18.8012 50.672 24.1746 44.084 24.32H43.8067C43.6747 24.32 43.5427 24.32 43.4107 24.3334H43.3587C43.1733 24.32 43.0014 24.32 42.8174 24.32C41.2854 24.32 39.7933 24.5038 38.38 24.861C38.3147 24.8746 38.2614 24.8881 38.196 24.9012C34.3147 25.6397 30.7094 27.132 27.5147 29.1918C19.0387 34.6309 13.428 44.1642 13.428 54.9892C13.428 71.5064 26.472 84.973 42.8174 85.6334C19.1707 85.6334 0 66.4626 0 42.8173C0 19.1705 19.1707 -3.8147e-05 42.8174 -3.8147e-05C43.028 -3.8147e-05 43.2387 -3.52981e-05 43.4507 0.013298C43.9 0.013298 44.3613 0.0266218 44.8107 0.0399551C51.056 0.554622 55.98 5.78262 55.98 12.16Z" fill={c("
|
|
32
|
-
<path d="M73.4467 31.2278C70.6867 31.2278 68.152 32.1387 66.1187 33.684C63.188 35.9012 61.2867 39.4267 61.2867 43.3882V42.8335C61.2867 53.0387 53.0093 61.3174 42.8027 61.3174C32.9013 61.3174 24.8214 53.5534 24.332 43.784C24.8214 60.2747 38.3534 73.4908 54.976 73.4908C61.0894 73.4908 66.7787 71.708 71.5587 68.619C79.0947 63.7731 84.3547 55.7158 85.44 46.3824C85.4694 45.6944 85.6067 43.4491 85.6067 43.1892C85.5014 36.5611 80.1013 31.2278 73.4467 31.2278Z" fill={c("
|
|
33
|
-
<path d="M71.2799 26.5798C77.1253 26.5798 81.8626 21.8252 81.8626 15.9585C81.8626 10.0933 77.1253 5.33731 71.2799 5.33731C65.4346 5.33731 60.6973 10.0933 60.6973 15.9585C60.6973 21.8252 65.4346 26.5798 71.2799 26.5798Z" fill={c("
|
|
30
|
+
<path d="M85.4746 46.2096C83.9959 65.2746 70.0266 80.8266 51.7546 84.696C51.6493 84.7214 51.544 84.7481 51.4507 84.761C48.7053 85.3293 45.8666 85.62 42.9613 85.6334H42.816C26.4706 84.973 13.4266 71.5053 13.4266 54.9892C13.4266 44.1626 19.0387 34.6309 27.5147 29.1908C30.7093 27.1309 34.3133 25.6397 38.196 24.9001C30.5106 26.8798 24.7547 33.6668 24.3453 41.8653C24.3187 42.1824 24.3186 42.4986 24.3186 42.8157C24.3186 43.1334 24.3187 43.449 24.3453 43.7668C24.8347 60.2574 38.3666 73.473 54.9893 73.473C61.1026 73.473 66.7919 71.6908 71.5719 68.6012C79.1506 63.7293 84.4319 55.6096 85.4746 46.2096Z" fill={c("midWave")} />
|
|
31
|
+
<path d="M55.98 12.16V12.173C55.9667 18.8012 50.672 24.1746 44.084 24.32H43.8067C43.6747 24.32 43.5427 24.32 43.4107 24.3334H43.3587C43.1733 24.32 43.0014 24.32 42.8174 24.32C41.2854 24.32 39.7933 24.5038 38.38 24.861C38.3147 24.8746 38.2614 24.8881 38.196 24.9012C34.3147 25.6397 30.7094 27.132 27.5147 29.1918C19.0387 34.6309 13.428 44.1642 13.428 54.9892C13.428 71.5064 26.472 84.973 42.8174 85.6334C19.1707 85.6334 0 66.4626 0 42.8173C0 19.1705 19.1707 -3.8147e-05 42.8174 -3.8147e-05C43.028 -3.8147e-05 43.2387 -3.52981e-05 43.4507 0.013298C43.9 0.013298 44.3613 0.0266218 44.8107 0.0399551C51.056 0.554622 55.98 5.78262 55.98 12.16Z" fill={c("arc")} />
|
|
32
|
+
<path d="M73.4467 31.2278C70.6867 31.2278 68.152 32.1387 66.1187 33.684C63.188 35.9012 61.2867 39.4267 61.2867 43.3882V42.8335C61.2867 53.0387 53.0093 61.3174 42.8027 61.3174C32.9013 61.3174 24.8214 53.5534 24.332 43.784C24.8214 60.2747 38.3534 73.4908 54.976 73.4908C61.0894 73.4908 66.7787 71.708 71.5587 68.619C79.0947 63.7731 84.3547 55.7158 85.44 46.3824C85.4694 45.6944 85.6067 43.4491 85.6067 43.1892C85.5014 36.5611 80.1013 31.2278 73.4467 31.2278Z" fill={c("lightWave")} />
|
|
33
|
+
<path d="M71.2799 26.5798C77.1253 26.5798 81.8626 21.8252 81.8626 15.9585C81.8626 10.0933 77.1253 5.33731 71.2799 5.33731C65.4346 5.33731 60.6973 10.0933 60.6973 15.9585C60.6973 21.8252 65.4346 26.5798 71.2799 26.5798Z" fill={c("dot")} />
|
|
34
34
|
</svg>
|
|
35
35
|
);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
type WordmarkProps = {
|
|
39
|
-
/**
|
|
40
|
-
*
|
|
39
|
+
/** Height. Default `var(--cg-wordmark, 1em)`: inside the Lockup/Curtain the
|
|
40
|
+
* variable comes from the manual's ratio (`wordmarkPerSymbol`); standalone, 1em. */
|
|
41
41
|
height?: string;
|
|
42
|
-
/**
|
|
42
|
+
/** Default `currentColor`: inherits `ink` (or white on a panel). */
|
|
43
43
|
color?: string;
|
|
44
44
|
className?: string;
|
|
45
45
|
style?: CSSProperties;
|
|
46
46
|
title?: string;
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
-
/**
|
|
50
|
-
*
|
|
51
|
-
/**
|
|
49
|
+
/** Ratio from the manual: wordmark height ÷ symbol height in the horizontal
|
|
50
|
+
* logo (40.3 / 86, with the name at 1.4643× in the horizontal logo). Pass it as `wordmarkRatio` to the Lockup/Curtain. */
|
|
51
|
+
/** In creeta the symbol sits to the left of the name (the family default). */
|
|
52
52
|
export const CREETA_LOCKUP = { wordmarkPerSymbol: 0.469, symbolSide: "left" } as const;
|
|
53
53
|
|
|
54
|
-
/**
|
|
54
|
+
/** Box tight to the letterforms: 116.66 × 27.53. */
|
|
55
55
|
export function CreetaWordmark({ height = "var(--cg-wordmark, 1em)", color = "currentColor", className, style, title = "creeta" }: WordmarkProps) {
|
|
56
56
|
return (
|
|
57
57
|
<svg
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import type { CSSProperties } from "react";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* wordmark
|
|
7
|
-
*
|
|
4
|
+
* TTCAB brand (Torcicolo Congênito e Assimetrias do Bebê), copied from the
|
|
5
|
+
* site's public/brand files. Symbol (mother + baby, orange) and "TTCAB"
|
|
6
|
+
* wordmark kept separate, so the Curtain and the Lockup can assemble the
|
|
7
|
+
* arrangements. Do not edit by hand: the drawing is the one from the brand manual.
|
|
8
8
|
*/
|
|
9
9
|
type SymbolProps = {
|
|
10
|
-
/**
|
|
10
|
+
/** Width in px; height follows the aspect ratio (136:126). */
|
|
11
11
|
size?: number;
|
|
12
|
-
/**
|
|
12
|
+
/** Single color (e.g. "currentColor" or "#fff" on a dark panel). Default: the brand orange. */
|
|
13
13
|
color?: string;
|
|
14
14
|
className?: string;
|
|
15
15
|
title?: string;
|
|
@@ -28,22 +28,22 @@ export function TtcabSymbol({ size = 48, color = TTCAB_ORANGE, className, title
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
type WordmarkProps = {
|
|
31
|
-
/**
|
|
32
|
-
*
|
|
31
|
+
/** Height. Default `var(--cg-wordmark, 1em)`: inside the Lockup/Curtain the
|
|
32
|
+
* variable comes from the manual's ratio (`wordmarkPerSymbol`); standalone, 1em. */
|
|
33
33
|
height?: string;
|
|
34
|
-
/**
|
|
34
|
+
/** Default `currentColor`: inherits `ink` (or white on a panel). */
|
|
35
35
|
color?: string;
|
|
36
36
|
className?: string;
|
|
37
37
|
style?: CSSProperties;
|
|
38
38
|
title?: string;
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
-
/**
|
|
42
|
-
*
|
|
43
|
-
/**
|
|
41
|
+
/** Ratio from the manual: wordmark height ÷ symbol height in the horizontal
|
|
42
|
+
* logo (43.67 / 78.39). Pass it as `wordmarkRatio` to the Lockup/Curtain. */
|
|
43
|
+
/** In TTCAB the symbol sits to the RIGHT of the name (the manual's horizontal logo). */
|
|
44
44
|
export const TTCAB_LOCKUP = { wordmarkPerSymbol: 0.557, symbolSide: "right" } as const;
|
|
45
45
|
|
|
46
|
-
/**
|
|
46
|
+
/** Box tight to the letterforms: 149.75 × 43.67. */
|
|
47
47
|
export function TtcabWordmark({ height = "var(--cg-wordmark, 1em)", color = "currentColor", className, style, title = "TTCAB" }: WordmarkProps) {
|
|
48
48
|
return (
|
|
49
49
|
<svg
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@breathe-ds/ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Breathe DS — design system
|
|
5
|
-
"license": "
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Breathe DS — the design system of the Cuidado Global family of sites (creeta, ttcab, metodorta). Themeable tokens + React components.",
|
|
5
|
+
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": [
|
|
8
8
|
"*.css"
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"theme.css",
|
|
12
12
|
"tokens",
|
|
13
13
|
"components",
|
|
14
|
-
"README.md"
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
15
16
|
],
|
|
16
17
|
"exports": {
|
|
17
18
|
"./theme.css": "./theme.css",
|
|
@@ -23,16 +24,38 @@
|
|
|
23
24
|
"./components/Button": "./components/Button.tsx",
|
|
24
25
|
"./components/Container": "./components/Container.tsx",
|
|
25
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",
|
|
26
37
|
"./components/Card": "./components/Card.tsx",
|
|
27
38
|
"./components/PillList": "./components/PillList.tsx",
|
|
39
|
+
"./components/Pill": "./components/Pill.tsx",
|
|
28
40
|
"./components/Header": "./components/Header.tsx",
|
|
29
41
|
"./components/BottomNav": "./components/BottomNav.tsx",
|
|
30
42
|
"./components/BreatheMark": "./components/BreatheMark.tsx",
|
|
31
|
-
"./components/
|
|
43
|
+
"./components/Curtain": "./components/Curtain.tsx",
|
|
32
44
|
"./components/Lockup": "./components/Lockup.tsx",
|
|
33
45
|
"./tokens/neutral.css": "./tokens/neutral.css",
|
|
34
46
|
"./components/icons": "./components/icons.tsx",
|
|
35
47
|
"./components/AnchorNav": "./components/AnchorNav.tsx",
|
|
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",
|
|
36
59
|
"./components/marks/ttcab": "./components/marks/ttcab.tsx",
|
|
37
60
|
"./components/marks/creeta": "./components/marks/creeta.tsx"
|
|
38
61
|
},
|