@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,266 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, type ComponentType, type ElementType, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
/** lucide-style icon component (the badge sets the size). */
|
|
6
|
+
type IconType = ComponentType<{ size?: number; strokeWidth?: number; className?: string; "aria-hidden"?: boolean | "true" }>;
|
|
7
|
+
|
|
8
|
+
/** Event the card videos listen to when the person pauses/resumes them all
|
|
9
|
+
* (dispatch it on `window` after toggling the attribute below). */
|
|
10
|
+
export const MEDIA_CARD_VIDEO_PAUSE_EVENT = "cg-media-card-videos";
|
|
11
|
+
/** Attribute on <html> while the card videos are paused (WCAG 2.2.2: what
|
|
12
|
+
* moves for more than 5s must be pausable; the button lives outside the
|
|
13
|
+
* card, which is one whole link). */
|
|
14
|
+
export const MEDIA_CARD_VIDEO_PAUSED_ATTR = "data-media-card-videos-paused";
|
|
15
|
+
|
|
16
|
+
export type MediaCardVideo = {
|
|
17
|
+
src: string;
|
|
18
|
+
poster?: string;
|
|
19
|
+
/** `always` (default): loops while the card is on screen (a quarter in view
|
|
20
|
+
* counts). `hover`: only with the mouse over, or keyboard focus on, the
|
|
21
|
+
* card; on touch the photo stays. */
|
|
22
|
+
when?: "always" | "hover";
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type Props = {
|
|
26
|
+
href: string;
|
|
27
|
+
title: ReactNode;
|
|
28
|
+
/** Supporting text. Accepts elements, to break the line where it makes sense. */
|
|
29
|
+
text?: ReactNode;
|
|
30
|
+
/** Top-left corner, over dark glass: a MediaCardBadge (icon, label, address). */
|
|
31
|
+
badge?: ReactNode;
|
|
32
|
+
/** Small line above the title ("Who created the method"), lighter. */
|
|
33
|
+
eyebrow?: ReactNode;
|
|
34
|
+
/** Text link line under the text ("See the team ↗"). The card is already the
|
|
35
|
+
* link, so this is a label with the arrow, not a second anchor. */
|
|
36
|
+
linkLabel?: ReactNode;
|
|
37
|
+
/** The round arrow button top-right. Off for the portrait card, which says
|
|
38
|
+
* where it goes with `linkLabel` instead. */
|
|
39
|
+
arrowButton?: boolean;
|
|
40
|
+
/** h2 on list pages, h3 inside a section that already has an h2. */
|
|
41
|
+
headingLevel?: "h2" | "h3";
|
|
42
|
+
/** Background media rendered to cover the card: an <img>, a video element or
|
|
43
|
+
* the app's next/image (with `fill`, pass it `className="object-cover"`). */
|
|
44
|
+
media?: ReactNode;
|
|
45
|
+
/** Plain image alternative to `media`. Decorative by default (`alt=""`): the
|
|
46
|
+
* card already has title and text. */
|
|
47
|
+
src?: string;
|
|
48
|
+
alt?: string;
|
|
49
|
+
srcSet?: string;
|
|
50
|
+
sizes?: string;
|
|
51
|
+
/** true only when the card is the page's LCP: eager + high fetch priority. */
|
|
52
|
+
priority?: boolean;
|
|
53
|
+
/** Short muted clip that plays over the photo. Without it, or if it does not
|
|
54
|
+
* play, the photo stands. */
|
|
55
|
+
video?: MediaCardVideo;
|
|
56
|
+
/** Aspect-ratio class of the card. Default `aspect-[3/4]`; replaces the
|
|
57
|
+
* default instead of fighting it (e.g. `aspect-[4/5] md:aspect-auto md:min-h-[460px]`). */
|
|
58
|
+
aspect?: string;
|
|
59
|
+
/** Padding class of the card. Default `p-4` (e.g. `p-6 md:p-8`). */
|
|
60
|
+
padding?: string;
|
|
61
|
+
/** Link component of the app (e.g. next-intl Link). Default: <a>. */
|
|
62
|
+
linkComponent?: ElementType;
|
|
63
|
+
className?: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Media card of the Cuidado Global family — the `CartaoFoto` of novo.creeta
|
|
68
|
+
* ("Get to know the clinic", the treatment cards) with the Breathe tokens. A
|
|
69
|
+
* tall card with the photo (or video) bleeding to the edges and the text over
|
|
70
|
+
* it; the whole card is the link. The ink film at the bottom keeps the text
|
|
71
|
+
* readable over any photo. On hover (mouse only) the photo zooms in slowly
|
|
72
|
+
* and steadily, like a Ken Burns: 2.5s to reach 107%; it comes back in half a
|
|
73
|
+
* second and does not move for people who asked for less motion. The round
|
|
74
|
+
* arrow button turns `action` on hover.
|
|
75
|
+
*/
|
|
76
|
+
export function MediaCard({
|
|
77
|
+
href,
|
|
78
|
+
title,
|
|
79
|
+
text,
|
|
80
|
+
badge,
|
|
81
|
+
eyebrow,
|
|
82
|
+
linkLabel,
|
|
83
|
+
arrowButton = true,
|
|
84
|
+
headingLevel: Heading = "h3",
|
|
85
|
+
media,
|
|
86
|
+
src,
|
|
87
|
+
alt = "",
|
|
88
|
+
srcSet,
|
|
89
|
+
sizes,
|
|
90
|
+
priority = false,
|
|
91
|
+
video,
|
|
92
|
+
aspect = "aspect-[3/4]",
|
|
93
|
+
padding = "p-4",
|
|
94
|
+
linkComponent,
|
|
95
|
+
className = "",
|
|
96
|
+
}: Props) {
|
|
97
|
+
const L: ElementType = linkComponent ?? "a";
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<L
|
|
101
|
+
href={href}
|
|
102
|
+
className={`group relative isolate flex ${aspect} flex-col justify-between overflow-hidden rounded-2xl bg-ink ${padding} text-white transition-transform duration-200 ease-out active:scale-[0.98] motion-reduce:transition-none motion-reduce:active:scale-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink ${className}`}
|
|
103
|
+
>
|
|
104
|
+
{/* Zooms in slowly with the mouse over, at a constant pace, like a Ken
|
|
105
|
+
Burns: 2.5s to reach 107%. Only where there is a mouse: on a phone
|
|
106
|
+
the tap would fire a false "hover". Comes back in half a second, and
|
|
107
|
+
stays still for people who asked for less motion. */}
|
|
108
|
+
<div
|
|
109
|
+
className="absolute inset-0 -z-20 transition-transform duration-500 ease-[var(--ease-cg)] motion-reduce:transition-none [@media(hover:hover)_and_(pointer:fine)]:group-hover:scale-[1.07] [@media(hover:hover)_and_(pointer:fine)]:group-hover:duration-[2500ms] [@media(hover:hover)_and_(pointer:fine)]:group-hover:ease-linear motion-reduce:[@media(hover:hover)_and_(pointer:fine)]:group-hover:scale-100 [&>img]:h-full [&>img]:w-full [&>img]:object-cover [&>video]:h-full [&>video]:w-full [&>video]:object-cover"
|
|
110
|
+
>
|
|
111
|
+
{media ??
|
|
112
|
+
(src && (
|
|
113
|
+
<img
|
|
114
|
+
src={src}
|
|
115
|
+
srcSet={srcSet}
|
|
116
|
+
sizes={sizes}
|
|
117
|
+
alt={alt}
|
|
118
|
+
loading={priority ? "eager" : "lazy"}
|
|
119
|
+
decoding="async"
|
|
120
|
+
// React 18 only knows the lowercase attribute (19 accepts fetchPriority too)
|
|
121
|
+
{...({ fetchpriority: priority ? "high" : undefined } as Record<string, unknown>)}
|
|
122
|
+
/>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
{/* between the photo and the film: the text keeps the same contrast */}
|
|
126
|
+
{video && <CardVideo {...video} className="absolute inset-0 -z-20" />}
|
|
127
|
+
<span aria-hidden="true" className="absolute inset-0 -z-10 bg-gradient-to-t from-ink via-ink/75 via-40% to-ink/15" />
|
|
128
|
+
|
|
129
|
+
{(badge || arrowButton) && (
|
|
130
|
+
<div className="flex items-start justify-between gap-3">
|
|
131
|
+
<div>{badge}</div>
|
|
132
|
+
{arrowButton && (
|
|
133
|
+
<span
|
|
134
|
+
aria-hidden="true"
|
|
135
|
+
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-card text-ink transition-colors duration-200 group-hover:bg-action group-hover:text-on-action motion-reduce:transition-none"
|
|
136
|
+
>
|
|
137
|
+
<svg viewBox="0 0 24 24" width={20} height={20} className="cg-arrow-go" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
|
|
138
|
+
</span>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
<div className={badge || arrowButton ? "" : "mt-auto"}>
|
|
144
|
+
{eyebrow && <span className="block text-sm text-white/75">{eyebrow}</span>}
|
|
145
|
+
<Heading className={`font-heading text-2xl font-medium leading-tight ${eyebrow ? "mt-1" : ""}`}>{title}</Heading>
|
|
146
|
+
{text && <p className="m-0 mt-2 text-sm leading-relaxed text-white/85">{text}</p>}
|
|
147
|
+
{linkLabel && (
|
|
148
|
+
<span className="mt-3 inline-flex items-center gap-1.5 text-sm font-semibold underline underline-offset-4">
|
|
149
|
+
{linkLabel}
|
|
150
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={16} height={16} className="cg-arrow-go" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
|
|
151
|
+
</span>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
</L>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Badge for the corner of the card. Dark glass, not light: over the bright
|
|
160
|
+
* part of a photo, white text on white glass measured 1.59:1. With `Icon`
|
|
161
|
+
* alone (no children) it becomes a 44px round icon badge.
|
|
162
|
+
*/
|
|
163
|
+
export function MediaCardBadge({ Icon, children, className = "" }: { Icon?: IconType; children?: ReactNode; className?: string }) {
|
|
164
|
+
const iconOnly = Boolean(Icon) && children === undefined;
|
|
165
|
+
return (
|
|
166
|
+
<span
|
|
167
|
+
className={`inline-flex items-center gap-1.5 rounded-full border border-white/15 bg-ink/80 px-3 py-2 text-xs font-medium text-white backdrop-blur-md ${iconOnly ? "h-11 w-11 justify-center p-0" : ""} ${className}`}
|
|
168
|
+
>
|
|
169
|
+
{Icon && <Icon aria-hidden="true" size={iconOnly ? 20 : 14} strokeWidth={2} />}
|
|
170
|
+
{children}
|
|
171
|
+
</span>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Short muted clip over the photo of a card (creeta's `VideoDoCartao`).
|
|
177
|
+
*
|
|
178
|
+
* Invisible until it has frames to show, then fades in: without this it would
|
|
179
|
+
* flash black while the file arrives. If it does not play, the card is the
|
|
180
|
+
* usual one, with the photo.
|
|
181
|
+
*
|
|
182
|
+
* Does not play, in either mode: for people who asked for less motion; with
|
|
183
|
+
* data saver on (the video is not even requested: `preload="none"`); while
|
|
184
|
+
* the person has paused the card videos (MEDIA_CARD_VIDEO_PAUSED_ATTR on <html>).
|
|
185
|
+
*
|
|
186
|
+
* In `always`, pauses when the card leaves the screen: three videos running
|
|
187
|
+
* with nobody watching waste battery for nothing.
|
|
188
|
+
*
|
|
189
|
+
* Muted, no controls, out of the screen-reader flow: it is decoration, the
|
|
190
|
+
* card already has title and text. The card is the link (<a>) around it.
|
|
191
|
+
*/
|
|
192
|
+
function CardVideo({ src, poster, when = "always", className = "" }: MediaCardVideo & { className?: string }) {
|
|
193
|
+
const video = useRef<HTMLVideoElement>(null);
|
|
194
|
+
const [playing, setPlaying] = useState(false);
|
|
195
|
+
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
const v = video.current;
|
|
198
|
+
const card = v?.closest("a") ?? v?.parentElement;
|
|
199
|
+
if (!v || !card || typeof window.matchMedia !== "function") return;
|
|
200
|
+
|
|
201
|
+
const saveData = (navigator as Navigator & { connection?: { saveData?: boolean } }).connection?.saveData === true;
|
|
202
|
+
const lessMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
203
|
+
const paused = () => document.documentElement.hasAttribute(MEDIA_CARD_VIDEO_PAUSED_ATTR);
|
|
204
|
+
const play = () => {
|
|
205
|
+
if (lessMotion() || saveData || paused()) return;
|
|
206
|
+
v.play()
|
|
207
|
+
.then(() => setPlaying(true))
|
|
208
|
+
.catch(() => {});
|
|
209
|
+
};
|
|
210
|
+
const stop = () => {
|
|
211
|
+
setPlaying(false);
|
|
212
|
+
v.pause();
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
if (when === "hover") {
|
|
216
|
+
const withMouse = () => window.matchMedia("(hover: hover) and (pointer: fine)").matches;
|
|
217
|
+
const enter = () => withMouse() && play();
|
|
218
|
+
card.addEventListener("pointerenter", enter);
|
|
219
|
+
card.addEventListener("pointerleave", stop);
|
|
220
|
+
card.addEventListener("focusin", enter);
|
|
221
|
+
card.addEventListener("focusout", stop);
|
|
222
|
+
return () => {
|
|
223
|
+
card.removeEventListener("pointerenter", enter);
|
|
224
|
+
card.removeEventListener("pointerleave", stop);
|
|
225
|
+
card.removeEventListener("focusin", enter);
|
|
226
|
+
card.removeEventListener("focusout", stop);
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (typeof IntersectionObserver !== "function") return;
|
|
231
|
+
let onScreen = false;
|
|
232
|
+
// a quarter of the card in view already counts
|
|
233
|
+
const watcher = new IntersectionObserver(
|
|
234
|
+
([e]) => {
|
|
235
|
+
onScreen = e.isIntersecting;
|
|
236
|
+
if (onScreen) play();
|
|
237
|
+
else stop();
|
|
238
|
+
},
|
|
239
|
+
{ threshold: 0.25 },
|
|
240
|
+
);
|
|
241
|
+
watcher.observe(card);
|
|
242
|
+
// the person paused or resumed the videos
|
|
243
|
+
const onNotice = () => (paused() || !onScreen ? stop() : play());
|
|
244
|
+
window.addEventListener(MEDIA_CARD_VIDEO_PAUSE_EVENT, onNotice);
|
|
245
|
+
return () => {
|
|
246
|
+
watcher.disconnect();
|
|
247
|
+
window.removeEventListener(MEDIA_CARD_VIDEO_PAUSE_EVENT, onNotice);
|
|
248
|
+
};
|
|
249
|
+
}, [when]);
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<video
|
|
253
|
+
ref={video}
|
|
254
|
+
src={src}
|
|
255
|
+
poster={poster}
|
|
256
|
+
muted
|
|
257
|
+
loop
|
|
258
|
+
playsInline
|
|
259
|
+
preload="none"
|
|
260
|
+
aria-hidden="true"
|
|
261
|
+
tabIndex={-1}
|
|
262
|
+
data-media-card-video={when}
|
|
263
|
+
className={`pointer-events-none h-full w-full object-cover transition-opacity duration-500 ease-out motion-reduce:transition-none ${playing ? "opacity-100" : "opacity-0"} ${className}`}
|
|
264
|
+
/>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
@@ -5,7 +5,7 @@ import { BackgroundVideo } from "./BackgroundVideo";
|
|
|
5
5
|
|
|
6
6
|
type Panel = { kicker?: ReactNode; title: ReactNode; lead?: ReactNode };
|
|
7
7
|
|
|
8
|
-
/**
|
|
8
|
+
/** Local background clip (loop mode): muted, looping. */
|
|
9
9
|
type LoopPreview = {
|
|
10
10
|
src: string;
|
|
11
11
|
srcLarge?: string;
|
|
@@ -15,40 +15,40 @@ type LoopPreview = {
|
|
|
15
15
|
objectPosition?: string;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
* - loop:
|
|
20
|
-
* - player:
|
|
21
|
-
* (16:9,
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
18
|
+
/** Two kinds of video hero:
|
|
19
|
+
* - loop: a local clip on repeat (no player). Background = BackgroundVideo.
|
|
20
|
+
* - player: the YouTube video ITSELF plays muted, on repeat, as the background
|
|
21
|
+
* (16:9, covering the card, no false crop/zoom). Turning the sound on
|
|
22
|
+
* remounts it from zero — with audio and controls — and the close bar
|
|
23
|
+
* appears at the bottom. Keeps things consistent: the preview and the
|
|
24
|
+
* video are the same footage. */
|
|
25
25
|
type VideoLoop = { mode: "loop"; preview: LoopPreview };
|
|
26
26
|
type VideoPlayer = {
|
|
27
27
|
mode: "player";
|
|
28
28
|
youtubeId: string;
|
|
29
|
-
/**
|
|
30
|
-
*
|
|
29
|
+
/** Still frame (reduced motion / data saver). Without it, uses YouTube's own
|
|
30
|
+
* thumbnail (maxresdefault). */
|
|
31
31
|
poster?: string;
|
|
32
|
-
|
|
32
|
+
playerCaption?: ReactNode;
|
|
33
33
|
};
|
|
34
34
|
|
|
35
35
|
type Props = {
|
|
36
|
-
/**
|
|
36
|
+
/** Video background — defines the hero kind (loop or player). */
|
|
37
37
|
video: VideoLoop | VideoPlayer;
|
|
38
|
-
/**
|
|
38
|
+
/** Top-left slot (location badge, "← back" + icon). */
|
|
39
39
|
topLeft?: ReactNode;
|
|
40
|
-
/**
|
|
40
|
+
/** Short caption in the bottom-left corner (pill) — simple version. */
|
|
41
41
|
caption?: ReactNode;
|
|
42
|
-
/**
|
|
42
|
+
/** Bottom-left content panel (kicker + title + lead). */
|
|
43
43
|
panel?: Panel;
|
|
44
|
-
/** CTAs
|
|
45
|
-
*
|
|
44
|
+
/** CTAs in the bottom-right corner. In player mode, omitting this shows
|
|
45
|
+
* "Watch the video"; null hides it. In loop mode, only what is passed here appears. */
|
|
46
46
|
actions?: ReactNode | null;
|
|
47
|
-
/**
|
|
47
|
+
/** Label + meta of the default "Watch the video" button (player mode). */
|
|
48
48
|
watch?: { label?: string; meta?: ReactNode };
|
|
49
|
-
/**
|
|
49
|
+
/** Label of the close button in the bottom bar (player mode, playing). */
|
|
50
50
|
closeLabel?: string;
|
|
51
|
-
/**
|
|
51
|
+
/** Labels of the background pause button (player mode, muted preview). */
|
|
52
52
|
previewLabels?: { pause?: string; play?: string };
|
|
53
53
|
videoTitle?: string;
|
|
54
54
|
};
|
|
@@ -61,13 +61,13 @@ function XGlyph({ size = 18 }: { size?: number }) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
|
-
*
|
|
65
|
-
* (
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* (cqw/cqh),
|
|
69
|
-
*
|
|
70
|
-
*
|
|
64
|
+
* YouTube video background playing MUTED, on repeat, covering the card
|
|
65
|
+
* (player mode, before the sound starts). It is decorative: hidden from screen
|
|
66
|
+
* readers. Respects reduced motion and data saver (then it stays on the still
|
|
67
|
+
* frame without requesting the video). The "cover" is done with container units
|
|
68
|
+
* (cqw/cqh), so the 16:9 ratio is never distorted or falsely cropped.
|
|
69
|
+
* Has a pause button (WCAG 2.2.2). Clicks pass through (pointer-events-none):
|
|
70
|
+
* the watch button on top is what receives the tap.
|
|
71
71
|
*/
|
|
72
72
|
function YouTubeBackground({
|
|
73
73
|
id,
|
|
@@ -88,14 +88,14 @@ function YouTubeBackground({
|
|
|
88
88
|
const lessMotion =
|
|
89
89
|
typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
90
90
|
const saveData = (navigator as Navigator & { connection?: { saveData?: boolean } }).connection?.saveData;
|
|
91
|
-
if (lessMotion || saveData) return; //
|
|
91
|
+
if (lessMotion || saveData) return; // stay on the still frame
|
|
92
92
|
setShow(true);
|
|
93
93
|
}, []);
|
|
94
94
|
|
|
95
95
|
function toggle() {
|
|
96
96
|
const w = frame.current?.contentWindow;
|
|
97
97
|
if (!w) return;
|
|
98
|
-
// API
|
|
98
|
+
// YouTube API via postMessage (enablejsapi=1): pause/resume without loading the script.
|
|
99
99
|
w.postMessage(JSON.stringify({ event: "command", func: paused ? "playVideo" : "pauseVideo", args: [] }), "*");
|
|
100
100
|
setPaused((p) => !p);
|
|
101
101
|
}
|
|
@@ -107,7 +107,7 @@ function YouTubeBackground({
|
|
|
107
107
|
{show ? (
|
|
108
108
|
<iframe
|
|
109
109
|
ref={frame}
|
|
110
|
-
title={title ?? "
|
|
110
|
+
title={title ?? "Video"}
|
|
111
111
|
tabIndex={-1}
|
|
112
112
|
aria-hidden="true"
|
|
113
113
|
src={`https://www.youtube-nocookie.com/embed/${id}?autoplay=1&mute=1&loop=1&playlist=${id}&controls=0&disablekb=1&fs=0&modestbranding=1&rel=0&playsinline=1&iv_load_policy=3&enablejsapi=1`}
|
|
@@ -116,7 +116,7 @@ function YouTubeBackground({
|
|
|
116
116
|
style={{ width: "max(100cqw, 177.78cqh)", height: "max(100cqh, 56.25cqw)" }}
|
|
117
117
|
/>
|
|
118
118
|
) : (
|
|
119
|
-
//
|
|
119
|
+
// YouTube's own thumbnail (16:9) when the video is not playing
|
|
120
120
|
// eslint-disable-next-line @next/next/no-img-element
|
|
121
121
|
<img src={posterSrc} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />
|
|
122
122
|
)}
|
|
@@ -124,7 +124,7 @@ function YouTubeBackground({
|
|
|
124
124
|
<button
|
|
125
125
|
type="button"
|
|
126
126
|
onClick={toggle}
|
|
127
|
-
aria-label={paused ? labels?.play ?? "
|
|
127
|
+
aria-label={paused ? labels?.play ?? "Play video" : labels?.pause ?? "Pause video"}
|
|
128
128
|
className="absolute top-4 right-4 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-ink/85 text-surface backdrop-blur-md transition-colors duration-200 hover:bg-ink focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-surface motion-reduce:transition-none"
|
|
129
129
|
>
|
|
130
130
|
{paused ? (
|
|
@@ -139,15 +139,15 @@ function YouTubeBackground({
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
/**
|
|
142
|
-
*
|
|
143
|
-
* novo.creeta (
|
|
144
|
-
*
|
|
142
|
+
* Full-bleed hero of the Cuidado Global family — mirrors the "TopoVideo" of
|
|
143
|
+
* novo.creeta (exact sizes/spacing: max-w-1600, rounded-32, min-h 560/820),
|
|
144
|
+
* in the Breathe language (tokens: ink/surface).
|
|
145
145
|
*
|
|
146
|
-
* - loop:
|
|
147
|
-
* - player:
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
* "
|
|
146
|
+
* - loop: the background is a local clip on repeat (BackgroundVideo).
|
|
147
|
+
* - player: the background is the YouTube video ITSELF playing muted, on repeat.
|
|
148
|
+
* Turning the sound on remounts it from zero (with audio and controls)
|
|
149
|
+
* inside the card and opens the close bar at the bottom (caption on
|
|
150
|
+
* the left, "Close" on the right).
|
|
151
151
|
*/
|
|
152
152
|
export function PageVideoHero({
|
|
153
153
|
video,
|
|
@@ -156,7 +156,7 @@ export function PageVideoHero({
|
|
|
156
156
|
panel,
|
|
157
157
|
actions,
|
|
158
158
|
watch,
|
|
159
|
-
closeLabel = "
|
|
159
|
+
closeLabel = "Close video",
|
|
160
160
|
previewLabels,
|
|
161
161
|
videoTitle,
|
|
162
162
|
}: Props) {
|
|
@@ -165,23 +165,23 @@ export function PageVideoHero({
|
|
|
165
165
|
const watchRef = useRef<HTMLButtonElement>(null);
|
|
166
166
|
const playedOnce = useRef(false);
|
|
167
167
|
|
|
168
|
-
//
|
|
168
|
+
// On close, focus returns to the watch button.
|
|
169
169
|
useEffect(() => {
|
|
170
170
|
if (playing) playedOnce.current = true;
|
|
171
171
|
else if (playedOnce.current) watchRef.current?.focus();
|
|
172
172
|
}, [playing]);
|
|
173
173
|
|
|
174
|
-
const
|
|
174
|
+
const playerCaption = isPlayer ? (video as VideoPlayer).playerCaption : undefined;
|
|
175
175
|
|
|
176
176
|
return (
|
|
177
177
|
<div className="px-4 pt-2 md:px-6 md:pt-6">
|
|
178
178
|
<section className="relative isolate mx-auto flex min-h-[min(calc(100svh-7.5rem),560px)] max-w-[1600px] flex-col justify-end overflow-hidden rounded-[32px] bg-ink text-surface lg:min-h-[min(calc(100svh-7.5rem),820px)]">
|
|
179
|
-
{/*
|
|
179
|
+
{/* media — background (preview) or, when playing, the full video with sound */}
|
|
180
180
|
<div className={`absolute inset-x-0 top-0 ${playing ? "bottom-[4.5rem]" : "bottom-0 -z-10"}`}>
|
|
181
181
|
{playing ? (
|
|
182
182
|
<iframe
|
|
183
|
-
key="cg-hero-
|
|
184
|
-
title={videoTitle ?? "
|
|
183
|
+
key="cg-hero-sound"
|
|
184
|
+
title={videoTitle ?? "Video"}
|
|
185
185
|
src={`https://www.youtube-nocookie.com/embed/${(video as VideoPlayer).youtubeId}?autoplay=1&mute=0&start=0&rel=0&modestbranding=1&playsinline=1&iv_load_policy=3`}
|
|
186
186
|
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture; fullscreen"
|
|
187
187
|
allowFullScreen
|
|
@@ -208,7 +208,7 @@ export function PageVideoHero({
|
|
|
208
208
|
className="absolute inset-0"
|
|
209
209
|
/>
|
|
210
210
|
)}
|
|
211
|
-
{/*
|
|
211
|
+
{/* scrims — legibility of the text (bottom) and of the top elements */}
|
|
212
212
|
<div aria-hidden="true" className="pointer-events-none absolute inset-0 bg-gradient-to-t from-ink/70 via-ink/10 to-transparent" />
|
|
213
213
|
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 h-28 bg-gradient-to-b from-ink/35 to-transparent" />
|
|
214
214
|
</>
|
|
@@ -217,10 +217,10 @@ export function PageVideoHero({
|
|
|
217
217
|
|
|
218
218
|
{!playing && (
|
|
219
219
|
<>
|
|
220
|
-
{/*
|
|
220
|
+
{/* top-left */}
|
|
221
221
|
{topLeft && <div className="absolute top-4 left-4 z-10">{topLeft}</div>}
|
|
222
222
|
|
|
223
|
-
{/*
|
|
223
|
+
{/* panel / caption — in flow (justify-end), m-6 */}
|
|
224
224
|
<div className="relative z-10 p-6 md:m-6 md:p-0">
|
|
225
225
|
{panel ? (
|
|
226
226
|
<div className="inline-flex max-w-full items-center rounded-[24px] bg-ink/75 px-6 py-4 backdrop-blur-md md:max-w-[calc(100%-16rem)] lg:px-7 lg:py-5">
|
|
@@ -239,7 +239,7 @@ export function PageVideoHero({
|
|
|
239
239
|
) : null}
|
|
240
240
|
</div>
|
|
241
241
|
|
|
242
|
-
{/*
|
|
242
|
+
{/* bottom-right action */}
|
|
243
243
|
{actions === null ? null : actions ? (
|
|
244
244
|
<div className="absolute right-4 bottom-4 z-10 flex flex-wrap items-center justify-end gap-2 md:right-6 md:bottom-6">{actions}</div>
|
|
245
245
|
) : isPlayer ? (
|
|
@@ -253,7 +253,7 @@ export function PageVideoHero({
|
|
|
253
253
|
<PlayGlyph size={20} />
|
|
254
254
|
</span>
|
|
255
255
|
<span className="flex flex-col items-start leading-tight">
|
|
256
|
-
{watch?.label ?? "
|
|
256
|
+
{watch?.label ?? "Watch the video"}
|
|
257
257
|
{watch?.meta && <span className="text-sm font-normal text-surface/80">{watch.meta}</span>}
|
|
258
258
|
</span>
|
|
259
259
|
</button>
|
|
@@ -261,10 +261,10 @@ export function PageVideoHero({
|
|
|
261
261
|
</>
|
|
262
262
|
)}
|
|
263
263
|
|
|
264
|
-
{/*
|
|
264
|
+
{/* close bar — bottom of the playing video (caption on the left, close on the right) */}
|
|
265
265
|
{playing && (
|
|
266
266
|
<div className="relative z-10 flex h-[4.5rem] items-center justify-between gap-6 bg-ink px-6">
|
|
267
|
-
<p className="min-w-0 truncate text-sm text-surface/80">{
|
|
267
|
+
<p className="min-w-0 truncate text-sm text-surface/80">{playerCaption}</p>
|
|
268
268
|
<button
|
|
269
269
|
type="button"
|
|
270
270
|
onClick={() => setPlaying(false)}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ElementType, ReactNode } from "react";
|
|
2
|
+
import { Avatar } from "./Avatar";
|
|
3
|
+
|
|
4
|
+
type Props = {
|
|
5
|
+
name: string;
|
|
6
|
+
/** Second line: role · registration ("Physiotherapist · CREFITO-10/…"). The
|
|
7
|
+
* family rule: a health professional always shows the council number. */
|
|
8
|
+
description?: ReactNode;
|
|
9
|
+
photo?: string;
|
|
10
|
+
avatar?: ReactNode;
|
|
11
|
+
/** With an href the chip is a link (to the person's card) and shows the arrow bubble. */
|
|
12
|
+
href?: string;
|
|
13
|
+
linkComponent?: ElementType;
|
|
14
|
+
className?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Person chip — the "who signs" of a novo.creeta article: one pill with the
|
|
19
|
+
* portrait, the name and, below, role and registration. With a link it leads
|
|
20
|
+
* to the person's card and the arrow says so, like the site's shortcuts.
|
|
21
|
+
*/
|
|
22
|
+
export function PersonChip({ name, description, photo, avatar, href, linkComponent, className = "" }: Props) {
|
|
23
|
+
const L: ElementType = linkComponent ?? "a";
|
|
24
|
+
const inner = (
|
|
25
|
+
<>
|
|
26
|
+
<Avatar name={name} photo={photo} size={44}>{avatar}</Avatar>
|
|
27
|
+
<span className="leading-snug">
|
|
28
|
+
<span className="block font-semibold text-ink">{name}</span>
|
|
29
|
+
{description && <span className="block text-[0.8125rem] text-ink/80">{description}</span>}
|
|
30
|
+
</span>
|
|
31
|
+
</>
|
|
32
|
+
);
|
|
33
|
+
/* The avatar (photo or initials) always sits 6px from the edge, like the
|
|
34
|
+
arrow bubble on the other side; a chip that ends in text gets 20px (Pill). */
|
|
35
|
+
const box = "inline-flex min-h-11 items-center gap-3 rounded-full bg-card py-1.5 pl-1.5";
|
|
36
|
+
if (!href) return <p className={`m-0 ${box} pr-5 ${className}`}>{inner}</p>;
|
|
37
|
+
return (
|
|
38
|
+
<L
|
|
39
|
+
href={href}
|
|
40
|
+
className={`${box} pr-1.5 transition-transform duration-150 ease-out active:scale-[0.98] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none motion-reduce:active:scale-100 ${className}`}
|
|
41
|
+
>
|
|
42
|
+
{inner}
|
|
43
|
+
<span aria-hidden="true" className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-ink/[0.12] text-ink">
|
|
44
|
+
<svg viewBox="0 0 24 24" width={15} height={15} className="cg-arrow-go" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
|
|
45
|
+
</span>
|
|
46
|
+
</L>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ElementType, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
type Props = {
|
|
4
|
+
href: string;
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
/** Lighter supporting info after the label, e.g. "4 videos". */
|
|
7
|
+
meta?: ReactNode;
|
|
8
|
+
/** Link component of the app for page links (anchors always use <a>). */
|
|
9
|
+
linkComponent?: ElementType;
|
|
10
|
+
className?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Pill that leads somewhere — the Atalho of novo.creeta, the site's only white
|
|
15
|
+
* pill (information-only pills are labels, not this).
|
|
16
|
+
*
|
|
17
|
+
* What says "tap me" is not the color, it is the shape: a full pill, 44px
|
|
18
|
+
* tall, with an arrow in a circle. The arrow also says where it goes: down
|
|
19
|
+
* when it is on the same page (an anchor), diagonal when it opens another page.
|
|
20
|
+
*/
|
|
21
|
+
export function Pill({ href, children, meta, linkComponent, className = "" }: Props) {
|
|
22
|
+
const onPage = href.startsWith("#");
|
|
23
|
+
const L: ElementType = onPage ? "a" : linkComponent ?? "a";
|
|
24
|
+
const classes = `group inline-flex min-h-11 items-center gap-2 rounded-full bg-card py-1.5 pl-4 pr-1.5 text-sm font-medium text-ink shadow-[0_1px_2px_rgba(0,0,0,0.12)] transition duration-200 hover:bg-ink hover:text-surface active:scale-[0.98] motion-reduce:active:scale-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none ${className}`;
|
|
25
|
+
return (
|
|
26
|
+
<L href={href} className={classes}>
|
|
27
|
+
<span>{children}</span>
|
|
28
|
+
{meta && <span className="font-normal text-ink/80 group-hover:text-surface/85">{meta}</span>}
|
|
29
|
+
<span
|
|
30
|
+
aria-hidden="true"
|
|
31
|
+
data-arrow={onPage ? "down" : "other-page"}
|
|
32
|
+
className="ml-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-ink/[0.12] transition-colors duration-200 group-hover:bg-surface/15 motion-reduce:transition-none"
|
|
33
|
+
>
|
|
34
|
+
{onPage ? (
|
|
35
|
+
<svg viewBox="0 0 24 24" width={16} height={16} className="cg-arrow-down" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M19 12l-7 7-7-7" /></svg>
|
|
36
|
+
) : (
|
|
37
|
+
<svg viewBox="0 0 24 24" width={16} height={16} className="cg-arrow-go" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
|
|
38
|
+
)}
|
|
39
|
+
</span>
|
|
40
|
+
</L>
|
|
41
|
+
);
|
|
42
|
+
}
|