@breathe-ds/ui 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +118 -13
  2. package/components/Accordion.tsx +6 -4
  3. package/components/AnchorNav.tsx +12 -4
  4. package/components/Avatar.tsx +37 -0
  5. package/components/BottomNav.tsx +1 -1
  6. package/components/Breadcrumb.tsx +45 -0
  7. package/components/BreatheMark.tsx +0 -13
  8. package/components/Button.tsx +11 -4
  9. package/components/Byline.tsx +105 -0
  10. package/components/CaptionBand.tsx +74 -0
  11. package/components/CareSteps.tsx +86 -0
  12. package/components/ChatPreview.tsx +138 -0
  13. package/components/CtaCard.tsx +52 -0
  14. package/components/FactStrip.tsx +55 -0
  15. package/components/FeaturedPosts.tsx +80 -0
  16. package/components/Footer.tsx +50 -30
  17. package/components/Header.tsx +4 -2
  18. package/components/Heading.tsx +86 -0
  19. package/components/InfoCard.tsx +116 -0
  20. package/components/LanguageSwitcher.tsx +1 -1
  21. package/components/List.tsx +81 -0
  22. package/components/Lockup.tsx +0 -18
  23. package/components/MapCard.tsx +158 -0
  24. package/components/MediaCard.tsx +266 -0
  25. package/components/PageVideoHero.tsx +65 -23
  26. package/components/PauseButton.tsx +41 -0
  27. package/components/PersonChip.tsx +49 -0
  28. package/components/PhotoSequence.tsx +99 -0
  29. package/components/Pill.tsx +42 -0
  30. package/components/PillList.tsx +20 -47
  31. package/components/PlanTimeline.tsx +66 -0
  32. package/components/PostArticle.tsx +102 -0
  33. package/components/PostCard.tsx +69 -0
  34. package/components/ProfessionalCard.tsx +446 -0
  35. package/components/ProfessionalModal.tsx +243 -0
  36. package/components/Rail.tsx +94 -0
  37. package/components/Rows.tsx +137 -0
  38. package/components/StoryPanel.tsx +107 -0
  39. package/components/TestimonialCard.tsx +138 -0
  40. package/components/Text.tsx +31 -0
  41. package/components/Timeline.tsx +69 -0
  42. package/components/VideoInviteCard.tsx +168 -0
  43. package/components/VideoStoryCard.tsx +382 -0
  44. package/components/useOnAppear.ts +39 -0
  45. package/package.json +34 -2
  46. package/theme.css +206 -0
  47. package/tokens/creeta.css +7 -2
  48. package/tokens/neutral.css +12 -4
  49. package/tokens/ttcab.css +14 -5
@@ -0,0 +1,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
+ }
@@ -17,20 +17,33 @@ type LoopPreview = {
17
17
 
18
18
  /** Two kinds of video hero:
19
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. */
20
+ * - player: the video ITSELF plays muted, on repeat, as the background.
21
+ * Turning the sound on remounts it from zero — with audio and controls —
22
+ * and the close bar appears at the bottom. Keeps things consistent: the
23
+ * preview and the video are the same footage. The source is either a
24
+ * YouTube id (16:9 iframe covering the card, no false crop/zoom) or an
25
+ * mp4 hosted by the site (`src` + `poster`; the sound version plays in
26
+ * the browser's own player, letterboxed like the iframe). */
25
27
  type VideoLoop = { mode: "loop"; preview: LoopPreview };
26
- type VideoPlayer = {
27
- mode: "player";
28
+ type PlayerCommon = { mode: "player"; playerCaption?: ReactNode };
29
+ type VideoPlayerYouTube = PlayerCommon & {
28
30
  youtubeId: string;
29
31
  /** Still frame (reduced motion / data saver). Without it, uses YouTube's own
30
32
  * thumbnail (maxresdefault). */
31
33
  poster?: string;
32
- playerCaption?: ReactNode;
33
34
  };
35
+ type VideoPlayerFile = PlayerCommon & {
36
+ /** The mp4 itself: muted and looping as the background, with sound after the tap. */
37
+ src: string;
38
+ /** Larger file for wide screens (background only). */
39
+ srcLarge?: string;
40
+ /** Still frame (reduced motion / data saver, and the <video> poster). */
41
+ poster: string;
42
+ posterLarge?: string;
43
+ objectPosition?: string;
44
+ };
45
+ type VideoPlayer = VideoPlayerYouTube | VideoPlayerFile;
46
+ const isFile = (v: VideoPlayer): v is VideoPlayerFile => "src" in v;
34
47
 
35
48
  type Props = {
36
49
  /** Video background — defines the hero kind (loop or player). */
@@ -144,10 +157,10 @@ function YouTubeBackground({
144
157
  * in the Breathe language (tokens: ink/surface).
145
158
  *
146
159
  * - 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).
160
+ * - player: the background is the video ITSELF (YouTube id or the site's mp4)
161
+ * playing muted, on repeat. Turning the sound on remounts it from zero
162
+ * (with audio and controls) inside the card and opens the close bar at
163
+ * the bottom (caption on the left, "Close" on the right).
151
164
  */
152
165
  export function PageVideoHero({
153
166
  video,
@@ -179,20 +192,49 @@ export function PageVideoHero({
179
192
  {/* media — background (preview) or, when playing, the full video with sound */}
180
193
  <div className={`absolute inset-x-0 top-0 ${playing ? "bottom-[4.5rem]" : "bottom-0 -z-10"}`}>
181
194
  {playing ? (
182
- <iframe
183
- key="cg-hero-sound"
184
- title={videoTitle ?? "Video"}
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
- allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture; fullscreen"
187
- allowFullScreen
188
- className="absolute inset-0 h-full w-full border-0 bg-black"
189
- />
195
+ isFile(video as VideoPlayer) ? (
196
+ // the site's own mp4, with sound and the browser's controls; the
197
+ // click came from the person, so autoplay with audio is allowed
198
+ <video
199
+ key="cg-hero-sound"
200
+ src={(video as VideoPlayerFile).src}
201
+ poster={(video as VideoPlayerFile).poster}
202
+ controls
203
+ autoPlay
204
+ playsInline
205
+ aria-label={videoTitle ?? "Video"}
206
+ onEnded={() => setPlaying(false)}
207
+ className="absolute inset-0 h-full w-full bg-black object-contain"
208
+ />
209
+ ) : (
210
+ <iframe
211
+ key="cg-hero-sound"
212
+ title={videoTitle ?? "Video"}
213
+ src={`https://www.youtube-nocookie.com/embed/${(video as VideoPlayerYouTube).youtubeId}?autoplay=1&mute=0&start=0&rel=0&modestbranding=1&playsinline=1&iv_load_policy=3`}
214
+ allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture; fullscreen"
215
+ allowFullScreen
216
+ className="absolute inset-0 h-full w-full border-0 bg-black"
217
+ />
218
+ )
190
219
  ) : (
191
220
  <>
192
- {isPlayer ? (
221
+ {isPlayer && isFile(video as VideoPlayer) ? (
222
+ // the mp4 itself, muted and on repeat (BackgroundVideo: reduced
223
+ // motion, data saver and the pause button come with it)
224
+ <BackgroundVideo
225
+ key={(video as VideoPlayerFile).src}
226
+ src={(video as VideoPlayerFile).src}
227
+ srcLarge={(video as VideoPlayerFile).srcLarge}
228
+ poster={(video as VideoPlayerFile).poster}
229
+ posterLarge={(video as VideoPlayerFile).posterLarge}
230
+ objectPosition={(video as VideoPlayerFile).objectPosition}
231
+ labels={previewLabels}
232
+ className="absolute inset-0"
233
+ />
234
+ ) : isPlayer ? (
193
235
  <YouTubeBackground
194
- id={(video as VideoPlayer).youtubeId}
195
- poster={(video as VideoPlayer).poster}
236
+ id={(video as VideoPlayerYouTube).youtubeId}
237
+ poster={(video as VideoPlayerYouTube).poster}
196
238
  title={videoTitle}
197
239
  labels={previewLabels}
198
240
  />
@@ -0,0 +1,41 @@
1
+ import { Pause, Play } from "lucide-react";
2
+
3
+ type Labels = {
4
+ /** sr-only name: "Pause {what}" / "Resume {what}". */
5
+ pause: (what: string) => string;
6
+ resume: (what: string) => string;
7
+ };
8
+ const EN: Labels = { pause: (what) => `Pause ${what}`, resume: (what) => `Resume ${what}` };
9
+
10
+ type Props = {
11
+ paused: boolean;
12
+ onToggle: () => void;
13
+ /** What it pauses, in the page's language: "the sample conversation". */
14
+ what: string;
15
+ /** Colour of the circle: card (white) over the surface, surface over a card. */
16
+ bubble?: "card" | "surface";
17
+ /** Position (absolute) inside the drawing: "right-0 top-0". */
18
+ className?: string;
19
+ labels?: Partial<Labels>;
20
+ };
21
+
22
+ /** Pause/resume button of the looping drawings (creeta's `Pausa`). What moves
23
+ * by itself for more than 5s must be pausable (WCAG 2.2.2). 44px tap area,
24
+ * 32px circle. */
25
+ export function PauseButton({ paused, onToggle, what, bubble = "card", className = "", labels }: Props) {
26
+ const t = { ...EN, ...labels };
27
+ const Icon = paused ? Play : Pause;
28
+ return (
29
+ <button
30
+ type="button"
31
+ onClick={onToggle}
32
+ aria-pressed={paused}
33
+ className={`absolute flex h-11 w-11 items-center justify-center rounded-full text-ink transition-transform duration-150 ease-out active:scale-[0.94] focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-ink motion-reduce:transition-none motion-reduce:active:scale-100 ${className}`}
34
+ >
35
+ <span className={`flex h-8 w-8 items-center justify-center rounded-full ${bubble === "card" ? "bg-card" : "bg-surface"}`}>
36
+ <Icon aria-hidden="true" size={14} strokeWidth={2} className={paused ? "ml-0.5" : ""} />
37
+ </span>
38
+ <span className="sr-only">{paused ? t.resume(what) : t.pause(what)}</span>
39
+ </button>
40
+ );
41
+ }
@@ -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,99 @@
1
+ import type { CSSProperties } from "react";
2
+ import { Pause, Play } from "lucide-react";
3
+
4
+ export type SequencePhoto = { src: string; alt: string; srcSet?: string; sizes?: string };
5
+
6
+ type Labels = {
7
+ /** sr-only of the pause control. */
8
+ pause: string;
9
+ };
10
+ const EN: Labels = { pause: "Pause the photos" };
11
+
12
+ type Props = {
13
+ photos: SequencePhoto[];
14
+ /** Unique in the page: ties the pause control to the sequence. */
15
+ id: string;
16
+ /** Seconds each photo stays, not counting the fade. */
17
+ seconds?: number;
18
+ labels?: Partial<Labels>;
19
+ className?: string;
20
+ };
21
+
22
+ /** Seconds of fade between one photo and the next. */
23
+ const FADE = 1;
24
+ /** Ken Burns: how much the photo grows while it shows. */
25
+ const ZOOM = 1.08;
26
+ const CURVE = "cubic-bezier(0.77, 0, 0.175, 1)";
27
+ const pct = (n: number) => `${Number(n.toFixed(3))}%`;
28
+
29
+ /** Keyframes for a sequence of `n` photos. The cycle lasts n × seconds and
30
+ * each photo owns its slice: middle ones fade in, stay and vanish at once
31
+ * when the next has covered them; the last fades out revealing the first;
32
+ * the zoom grows while the photo shows and resets while hidden. */
33
+ export function sequenceKeyframes(n: number, seconds: number): string {
34
+ const cycle = n * seconds;
35
+ const enter = (FADE / cycle) * 100;
36
+ const stay = (seconds / cycle) * 100;
37
+ const leave = ((seconds + FADE) / cycle) * 100;
38
+ return [
39
+ `@keyframes cg-seq-mid-${n}{0%{opacity:0}${pct(enter)}{opacity:1}${pct(leave)}{opacity:1}${pct(leave + 0.01)},100%{opacity:0}}`,
40
+ `@keyframes cg-seq-end-${n}{0%{opacity:0}${pct(enter)}{opacity:1}${pct(stay)}{opacity:1}${pct(leave)},100%{opacity:0}}`,
41
+ `@keyframes cg-seq-zoom-${n}{0%{transform:scale(1)}${pct(leave)}{transform:scale(${ZOOM})}${pct(leave + 0.01)},100%{transform:scale(1)}}`,
42
+ ].join("");
43
+ }
44
+
45
+ /** Photos that alternate with a fade and slowly zoom in, no JavaScript
46
+ * (creeta's `SequenciaFotos`). Only opacity and transform. Content that moves
47
+ * by itself for more than 5s must be pausable (WCAG 2.2.2): the hidden
48
+ * checkbox does it through CSS (theme.css). Reduced motion: first photo, still. */
49
+ export function PhotoSequence({ photos, id, seconds = 4, labels, className = "" }: Props) {
50
+ const t = { ...EN, ...labels };
51
+ const n = photos.length;
52
+ const cycle = n * seconds;
53
+
54
+ const animation = (i: number): CSSProperties => {
55
+ const zoom = `cg-seq-zoom-${n} ${cycle}s linear infinite`;
56
+ if (i === 0) return { animation: zoom };
57
+ const fade = `${i === n - 1 ? `cg-seq-end-${n}` : `cg-seq-mid-${n}`} ${cycle}s ${CURVE} infinite`;
58
+ return { animation: `${fade}, ${zoom}`, animationDelay: `${i * seconds}s` };
59
+ };
60
+
61
+ // position and radius are defaults: a className with `absolute` or `rounded-*`
62
+ // (filling a CareSteps frame) takes their place instead of fighting them
63
+ const position = /\babsolute\b|\bfixed\b/.test(className) ? "" : "relative";
64
+ const radius = /\brounded/.test(className) ? "" : "rounded-[28px]";
65
+ return (
66
+ <div className={`${position} ${radius} overflow-hidden bg-ink ${className}`}>
67
+ {n > 1 && <style dangerouslySetInnerHTML={{ __html: sequenceKeyframes(n, seconds) }} />}
68
+ <input id={id} type="checkbox" className="cg-seq-pause peer sr-only" />
69
+
70
+ <div className="cg-seq absolute inset-0">
71
+ {photos.map((p, i) => (
72
+ // eslint-disable-next-line @next/next/no-img-element
73
+ <img
74
+ key={p.src}
75
+ src={p.src}
76
+ srcSet={p.srcSet}
77
+ sizes={p.sizes}
78
+ alt={p.alt}
79
+ loading="lazy"
80
+ decoding="async"
81
+ style={n > 1 ? animation(i) : undefined}
82
+ className="absolute inset-0 h-full w-full object-cover"
83
+ />
84
+ ))}
85
+ </div>
86
+
87
+ {n > 1 && (
88
+ <label
89
+ htmlFor={id}
90
+ className="cg-seq-control absolute bottom-3 right-3 flex h-11 w-11 cursor-pointer items-center justify-center rounded-full bg-ink/85 text-surface backdrop-blur-md peer-checked:[&_.cg-on-pause]:block peer-checked:[&_.cg-on-play]:hidden peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-surface"
91
+ >
92
+ <Pause aria-hidden="true" size={18} strokeWidth={2} className="cg-on-play" />
93
+ <Play aria-hidden="true" size={18} strokeWidth={2} className="cg-on-pause ml-0.5 hidden" />
94
+ <span className="sr-only">{t.pause}</span>
95
+ </label>
96
+ )}
97
+ </div>
98
+ );
99
+ }
@@ -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
+ }