@breathe-ds/ui 0.5.4 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -2
- package/components/Accordion.tsx +6 -3
- package/components/AnchorNav.tsx +10 -5
- package/components/Avatar.tsx +1 -1
- package/components/BottomNav.tsx +12 -5
- package/components/Button.tsx +5 -2
- package/components/Byline.tsx +25 -1
- package/components/Card.tsx +11 -3
- package/components/ContactRow.tsx +118 -0
- package/components/CourseCard.tsx +3 -3
- package/components/CtaCard.tsx +3 -1
- package/components/DateBadge.tsx +1 -1
- package/components/EmptyState.tsx +54 -0
- package/components/EventFacts.tsx +1 -1
- package/components/EventRow.tsx +3 -3
- package/components/FactStrip.tsx +1 -1
- package/components/FilterBar.tsx +50 -0
- package/components/FilterChips.tsx +63 -0
- package/components/FilterPill.tsx +35 -0
- package/components/Footer.tsx +1 -1
- package/components/Header.tsx +61 -33
- package/components/Heading.tsx +3 -2
- package/components/InfoCard.tsx +7 -2
- package/components/List.tsx +1 -1
- package/components/PageHero.tsx +74 -0
- package/components/PageVideoHero.tsx +15 -5
- package/components/PersonChip.tsx +1 -1
- package/components/Pill.tsx +1 -1
- package/components/ProfessionalCard.tsx +60 -5
- package/components/ProfileSpotlight.tsx +94 -0
- package/components/PublicationCard.tsx +309 -0
- package/components/PullQuote.tsx +87 -0
- package/components/RegistrationBox.tsx +1 -1
- package/components/SearchField.tsx +85 -0
- package/components/SelectField.tsx +75 -0
- package/components/SidePanel.tsx +6 -3
- package/components/StickyGroupHeading.tsx +38 -0
- package/components/TestimonialCard.tsx +179 -23
- package/components/Text.tsx +1 -1
- package/components/Timeline.tsx +1 -1
- package/components/VideoInviteCard.tsx +20 -6
- package/package.json +14 -2
- package/theme.css +50 -0
- package/tokens/creeta.css +1 -0
- package/tokens/ttcab.css +6 -1
package/components/SidePanel.tsx
CHANGED
|
@@ -34,7 +34,10 @@ export function SidePanel({ open, onClose, title, actions, children, labels, cla
|
|
|
34
34
|
const box = useRef<HTMLDialogElement>(null);
|
|
35
35
|
const headingId = useId();
|
|
36
36
|
|
|
37
|
-
// open/close follow the prop; the way out waits for the slide to finish
|
|
37
|
+
// open/close follow the prop; the way out waits for the slide to finish.
|
|
38
|
+
// Closed, the dialog is display:none (hidden open:flex): a plain `flex` would
|
|
39
|
+
// keep it rendered off-screen, its shadow bleeding into the right edge and its
|
|
40
|
+
// content reachable.
|
|
38
41
|
useEffect(() => {
|
|
39
42
|
const d = box.current;
|
|
40
43
|
if (!d) return;
|
|
@@ -76,14 +79,14 @@ export function SidePanel({ open, onClose, title, actions, children, labels, cla
|
|
|
76
79
|
<dialog
|
|
77
80
|
ref={box}
|
|
78
81
|
aria-labelledby={headingId}
|
|
79
|
-
className={`fixed inset-y-0 right-0 left-auto m-0 flex h-dvh max-h-none w-[min(100vw,32rem)] max-w-none translate-x-full flex-col overflow-hidden bg-card p-0 text-ink shadow-2xl [transition:translate_280ms_var(--ease-cg)] data-open:translate-x-0 motion-reduce:transition-none backdrop:bg-ink/50 backdrop:backdrop-blur-sm backdrop:opacity-0 backdrop:[transition:opacity_240ms_ease-out] [&[data-open]::backdrop]:opacity-100 ${className}`}
|
|
82
|
+
className={`fixed inset-y-0 right-0 left-auto m-0 hidden open:flex h-dvh max-h-none w-[min(100vw,32rem)] max-w-none translate-x-full flex-col overflow-hidden bg-card p-0 text-ink shadow-2xl [transition:translate_280ms_var(--ease-cg)] data-open:translate-x-0 motion-reduce:transition-none backdrop:bg-ink/50 backdrop:backdrop-blur-sm backdrop:opacity-0 backdrop:[transition:opacity_240ms_ease-out] [&[data-open]::backdrop]:opacity-100 ${className}`}
|
|
80
83
|
>
|
|
81
84
|
<h2 id={headingId} className="sr-only">{title}</h2>
|
|
82
85
|
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-ink/10 px-4 py-3">
|
|
83
86
|
<button
|
|
84
87
|
type="button"
|
|
85
88
|
onClick={onClose}
|
|
86
|
-
className="flex h-11 w-11 items-center justify-center rounded-full text-ink transition-colors duration-150 hover:bg-
|
|
89
|
+
className="flex h-11 w-11 items-center justify-center rounded-full text-ink transition-colors duration-150 hover:bg-tint focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none"
|
|
87
90
|
>
|
|
88
91
|
<X aria-hidden="true" size={20} strokeWidth={2} />
|
|
89
92
|
<span className="sr-only">{t.close}</span>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
type Props = {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
/** Lighter info after the title, e.g. the count ("51 profissionais"). */
|
|
6
|
+
meta?: ReactNode;
|
|
7
|
+
id?: string;
|
|
8
|
+
/** h2 by default; h3 inside a section that already has an h2. */
|
|
9
|
+
as?: "h2" | "h3";
|
|
10
|
+
/** Where it sticks: below the site's sticky header. Pass a literal Tailwind
|
|
11
|
+
* class written in your source (`top-20` = an 80px header), so it gets generated. */
|
|
12
|
+
stickyTop?: string;
|
|
13
|
+
className?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* StickyGroupHeading — the title of a group in a long list that stays at the
|
|
18
|
+
* top while its items scroll by, and is pushed away by the next group's title
|
|
19
|
+
* (the section headers of Apple's lists, Contacts A–Z). Used on ttcab's
|
|
20
|
+
* professionals directory, one group per state.
|
|
21
|
+
*
|
|
22
|
+
* It sticks inside its own group: put it as the first child of the group's
|
|
23
|
+
* wrapper (a <section>), so the next wrapper pushes it out. The band bleeds
|
|
24
|
+
* 16/24px past the content so the hairline and the blurred page colour cover
|
|
25
|
+
* the cards passing under it; `surface` at 90% keeps the title at full
|
|
26
|
+
* contrast (ink on surface, 9.41:1 on ttcab) even without backdrop-filter.
|
|
27
|
+
*/
|
|
28
|
+
export function StickyGroupHeading({ children, meta, id, as: Tag = "h2", stickyTop = "top-20", className = "" }: Props) {
|
|
29
|
+
return (
|
|
30
|
+
<Tag
|
|
31
|
+
id={id}
|
|
32
|
+
className={`sticky ${stickyTop} z-10 -mx-4 flex items-baseline gap-3 border-b border-line bg-surface/90 px-4 py-3 font-heading text-xl font-medium text-ink backdrop-blur-md md:-mx-6 md:px-6 ${className}`}
|
|
33
|
+
>
|
|
34
|
+
{children}
|
|
35
|
+
{meta && <span className="text-sm font-normal text-ink-soft tabular-nums">{meta}</span>}
|
|
36
|
+
</Tag>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState, type ElementType, type ReactNode } from "react";
|
|
3
|
+
import { useLayoutEffect, useRef, useState, type ElementType, type ReactNode } from "react";
|
|
4
4
|
import { Play } from "lucide-react";
|
|
5
|
+
import { Avatar } from "./Avatar";
|
|
5
6
|
import { Byline } from "./Byline";
|
|
7
|
+
import { SidePanel } from "./SidePanel";
|
|
6
8
|
|
|
7
9
|
type Labels = {
|
|
8
10
|
/** sr-only text of the play button: "Watch: {description}". */
|
|
9
11
|
watch: (description: string) => string;
|
|
10
12
|
/** sr-only after the outbound link. */
|
|
11
13
|
newTab: string;
|
|
14
|
+
/** Button under a clamped quote that opens the whole testimonial. */
|
|
15
|
+
readMore: string;
|
|
16
|
+
/** Same button when the panel holds the video (mediaPlacement="panel"). */
|
|
17
|
+
watchFull: string;
|
|
18
|
+
/** Close button of the side panel. */
|
|
19
|
+
close: string;
|
|
20
|
+
};
|
|
21
|
+
const EN: Labels = {
|
|
22
|
+
watch: (description) => `Watch: ${description}`,
|
|
23
|
+
newTab: " (opens in a new tab)",
|
|
24
|
+
readMore: "Read the full testimonial",
|
|
25
|
+
watchFull: "Watch the testimonial",
|
|
26
|
+
close: "Close",
|
|
12
27
|
};
|
|
13
|
-
const EN: Labels = { watch: (description) => `Watch: ${description}`, newTab: " (opens in a new tab)" };
|
|
14
28
|
|
|
15
29
|
export type TestimonialCaptions = {
|
|
16
30
|
/** WebVTT file in the page's language. Drawn by the browser over the video:
|
|
@@ -23,10 +37,13 @@ export type TestimonialCaptions = {
|
|
|
23
37
|
};
|
|
24
38
|
|
|
25
39
|
export type TestimonialVideo = {
|
|
26
|
-
/** The clip. Downloaded only on tap. */
|
|
27
|
-
src
|
|
28
|
-
/**
|
|
29
|
-
|
|
40
|
+
/** The clip (the site's own mp4). Downloaded only on tap. */
|
|
41
|
+
src?: string;
|
|
42
|
+
/** Or a YouTube id: the player (youtube-nocookie) loads only on tap. */
|
|
43
|
+
youtubeId?: string;
|
|
44
|
+
/** Still frame shown until the person taps. With `youtubeId` and no poster,
|
|
45
|
+
* YouTube's own thumbnail. */
|
|
46
|
+
poster?: string;
|
|
30
47
|
/** What the video shows, for screen readers ("Tereza's mother tells… (1 min 39 s)"). */
|
|
31
48
|
description: string;
|
|
32
49
|
/** Plays with audio (testimonials have speech). Default: true. The play is
|
|
@@ -50,8 +67,34 @@ type Props = {
|
|
|
50
67
|
authors?: { name: string; photo?: string; avatar?: ReactNode }[];
|
|
51
68
|
/** The public source of the quote ("View on Instagram ↗"), opened in a new tab. */
|
|
52
69
|
link?: { href: string; label: ReactNode };
|
|
53
|
-
/** The family's video, on the left.
|
|
54
|
-
|
|
70
|
+
/** The family's video, on the left. Omit it for a text testimonial (quote +
|
|
71
|
+
* portrait), as on ttcab: the card becomes a single column. */
|
|
72
|
+
video?: TestimonialVideo;
|
|
73
|
+
/** A still portrait on the left, in the video's place (text testimonials with a
|
|
74
|
+
* real photo). `media` replaces the <img> (the site's image component; must
|
|
75
|
+
* fill the box). */
|
|
76
|
+
photo?: { src: string; alt: string; position?: string; media?: ReactNode };
|
|
77
|
+
/** Where the photo or the video shows: on the card and in the panel (default),
|
|
78
|
+
* or only in the panel — the card stays a clean quote with the avatar. */
|
|
79
|
+
mediaPlacement?: "card" | "panel";
|
|
80
|
+
/** The card shows only this excerpt, large, as a pull quote (the person's own
|
|
81
|
+
* words, never rewritten); the whole `quote` goes to the panel. */
|
|
82
|
+
excerpt?: ReactNode;
|
|
83
|
+
/** Quote marks around the excerpt. false for a context line that is not the
|
|
84
|
+
* person's speech (e.g. the description of a video). Default: true. */
|
|
85
|
+
excerptMarks?: boolean;
|
|
86
|
+
/** Every card opens the whole testimonial in the SidePanel (the button always
|
|
87
|
+
* shows): a header saying what it is (`eyebrow`, e.g. "Testimonial") with the
|
|
88
|
+
* names, the video or the photo, then the full quote. */
|
|
89
|
+
panel?: { eyebrow: ReactNode };
|
|
90
|
+
/** Joiners between two or more names (", " and " e " in Portuguese). */
|
|
91
|
+
joiners?: { sep: string; last: string };
|
|
92
|
+
/** Small line under the name(s) ("Physiotherapist", "Tereza's mother"). */
|
|
93
|
+
role?: ReactNode;
|
|
94
|
+
/** Long testimonials: the quote on the card stops after this many lines and
|
|
95
|
+
* a "Read the full testimonial" button opens it whole in the SidePanel. The
|
|
96
|
+
* button only shows when the text really overflows. */
|
|
97
|
+
clampLines?: number;
|
|
55
98
|
/** Element rendered as the card root (default <div>); `li` inside a <ul>. */
|
|
56
99
|
as?: ElementType;
|
|
57
100
|
labels?: Partial<Labels>;
|
|
@@ -62,9 +105,25 @@ type Props = {
|
|
|
62
105
|
* play button (creeta's `VideoAoToque`). Before the tap it is just the poster:
|
|
63
106
|
* the browser's controls (0:00, volume) over the image clashed. No aspect
|
|
64
107
|
* ratio: the height comes from the grid row (the text beside it). */
|
|
65
|
-
function TapVideo({ src, poster, description, withSound = true, captions, media, label }: TestimonialVideo & { label: string }) {
|
|
108
|
+
function TapVideo({ src, youtubeId, poster: posterProp, description, withSound = true, captions, media, label, frameClass }: TestimonialVideo & { label: string; frameClass?: string }) {
|
|
66
109
|
const [playing, setPlaying] = useState(false);
|
|
67
|
-
|
|
110
|
+
// stacked on phones: 16:9 (videos are landscape); side by side: the row's height
|
|
111
|
+
const frame = frameClass ?? "relative aspect-video overflow-hidden bg-ink sm:aspect-auto sm:min-h-[16rem]";
|
|
112
|
+
const poster = posterProp ?? (youtubeId ? `https://i.ytimg.com/vi/${youtubeId}/hqdefault.jpg` : "");
|
|
113
|
+
|
|
114
|
+
if (playing && youtubeId) {
|
|
115
|
+
return (
|
|
116
|
+
<div className={frame}>
|
|
117
|
+
<iframe
|
|
118
|
+
src={`https://www.youtube-nocookie.com/embed/${youtubeId}?autoplay=1&rel=0&modestbranding=1&playsinline=1`}
|
|
119
|
+
title={description}
|
|
120
|
+
allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
|
|
121
|
+
allowFullScreen
|
|
122
|
+
className="absolute inset-0 h-full w-full border-0"
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
68
127
|
|
|
69
128
|
if (playing) {
|
|
70
129
|
return (
|
|
@@ -116,23 +175,120 @@ function TapVideo({ src, poster, description, withSound = true, captions, media,
|
|
|
116
175
|
* check the source. The quote is a real <blockquote> with <cite>: it is someone
|
|
117
176
|
* else's words and the screen reader announces it as such.
|
|
118
177
|
*/
|
|
119
|
-
|
|
178
|
+
// whole class names, so Tailwind generates them
|
|
179
|
+
const CLAMP: Record<number, string> = { 3: "line-clamp-3", 4: "line-clamp-4", 5: "line-clamp-5", 6: "line-clamp-6", 8: "line-clamp-8" };
|
|
180
|
+
|
|
181
|
+
export function TestimonialCard({ quote, author, authorPhoto, authorAvatar, authors, link, video, photo, mediaPlacement = "card", role, joiners, panel, excerpt, excerptMarks = true, clampLines, as: Root = "div", labels, className = "" }: Props) {
|
|
120
182
|
const t = { ...EN, ...labels };
|
|
121
183
|
const people = authors ?? [{ name: author ?? "", photo: authorPhoto, avatar: authorAvatar }];
|
|
184
|
+
const cardPhoto = mediaPlacement === "card" ? photo : undefined;
|
|
185
|
+
const cardVideo = mediaPlacement === "card" ? video : undefined;
|
|
186
|
+
const quoteRef = useRef<HTMLQuoteElement>(null);
|
|
187
|
+
const [overflows, setOverflows] = useState(false);
|
|
188
|
+
const [open, setOpen] = useState(false);
|
|
189
|
+
useLayoutEffect(() => {
|
|
190
|
+
const el = quoteRef.current;
|
|
191
|
+
if (!el || !clampLines) return;
|
|
192
|
+
const check = () => setOverflows(el.scrollHeight > el.clientHeight + 1);
|
|
193
|
+
check();
|
|
194
|
+
if (typeof ResizeObserver !== "function") return;
|
|
195
|
+
const ro = new ResizeObserver(check);
|
|
196
|
+
ro.observe(el);
|
|
197
|
+
return () => ro.disconnect();
|
|
198
|
+
}, [clampLines]);
|
|
199
|
+
const sep = joiners ?? { sep: ", ", last: " and " };
|
|
200
|
+
const names = people.map((p) => p.name).reduce((acc, n, i, all) => (i === 0 ? n : acc + (i === all.length - 1 ? sep.last : sep.sep) + n), "");
|
|
201
|
+
const byline = (
|
|
202
|
+
<Byline
|
|
203
|
+
cite
|
|
204
|
+
people={people}
|
|
205
|
+
action={link ? { label: link.label, href: link.href, external: true } : undefined}
|
|
206
|
+
newTabLabel={t.newTab.trim()}
|
|
207
|
+
subtitle={role}
|
|
208
|
+
joiners={joiners}
|
|
209
|
+
/>
|
|
210
|
+
);
|
|
122
211
|
return (
|
|
123
|
-
<Root className={
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
<
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
212
|
+
<Root className={`${cardVideo || cardPhoto ? "grid grid-cols-1 sm:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]" : "flex"} overflow-hidden rounded-[28px] bg-card ${className}`}>
|
|
213
|
+
{cardVideo ? (
|
|
214
|
+
<TapVideo {...cardVideo} label={t.watch(cardVideo.description)} />
|
|
215
|
+
) : cardPhoto ? (
|
|
216
|
+
<div className="relative aspect-[4/3] overflow-hidden bg-tint sm:aspect-auto sm:min-h-[16rem]">
|
|
217
|
+
{cardPhoto.media ?? (
|
|
218
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
219
|
+
<img src={cardPhoto.src} alt={cardPhoto.alt} loading="lazy" decoding="async" className="absolute inset-0 h-full w-full object-cover" style={{ objectPosition: cardPhoto.position }} />
|
|
220
|
+
)}
|
|
221
|
+
</div>
|
|
222
|
+
) : null}
|
|
223
|
+
<figure className={`flex flex-1 flex-col justify-between gap-5 ${cardVideo || cardPhoto ? "p-5 md:p-7" : "p-6 md:p-8"}`}>
|
|
224
|
+
<div>
|
|
225
|
+
{excerpt ? (
|
|
226
|
+
<blockquote className="m-0">
|
|
227
|
+
{/* the family's drawn mark (same as PullQuote) above the line; the line itself stays clean */}
|
|
228
|
+
{excerptMarks && (
|
|
229
|
+
<svg aria-hidden="true" viewBox="0 0 48 36" className="mb-4 block h-6 w-8 fill-action-line">
|
|
230
|
+
<path d="M0 36V22C0 10.5 6 2.6 18.2 0l2.3 5.2C14.6 7.4 11.4 11 11 16h8.6v20H0Zm27.4 0V22c0-11.5 6-19.4 18.2-22l2.3 5.2C42 7.4 38.8 11 38.4 16H47v20H27.4Z" />
|
|
231
|
+
</svg>
|
|
232
|
+
)}
|
|
233
|
+
<p className="m-0 font-heading text-[1.3rem] leading-snug font-semibold text-balance text-ink md:text-[1.5rem]">{excerpt}</p>
|
|
234
|
+
</blockquote>
|
|
235
|
+
) : (
|
|
236
|
+
<blockquote
|
|
237
|
+
ref={quoteRef}
|
|
238
|
+
className={`font-heading text-[0.9375rem] font-medium leading-relaxed text-ink md:text-base ${clampLines ? (CLAMP[clampLines] ?? "line-clamp-5") : ""}`}
|
|
239
|
+
>
|
|
240
|
+
{quote}
|
|
241
|
+
</blockquote>
|
|
242
|
+
)}
|
|
243
|
+
{(overflows || panel) && (
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
onClick={() => setOpen(true)}
|
|
247
|
+
className="mt-3 inline-flex min-h-11 items-center gap-1 text-left font-semibold text-ink underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink"
|
|
248
|
+
>
|
|
249
|
+
{video && !cardVideo ? t.watchFull : t.readMore}
|
|
250
|
+
</button>
|
|
251
|
+
)}
|
|
252
|
+
</div>
|
|
253
|
+
<figcaption>{byline}</figcaption>
|
|
135
254
|
</figure>
|
|
255
|
+
{(clampLines || panel) && (
|
|
256
|
+
<SidePanel open={open} onClose={() => setOpen(false)} title={names} labels={{ close: t.close }}>
|
|
257
|
+
<article className="flex flex-col gap-6 px-6 pt-2 pb-10 md:px-8">
|
|
258
|
+
{panel && video ? (
|
|
259
|
+
<div className="overflow-hidden rounded-2xl">
|
|
260
|
+
<TapVideo {...video} label={t.watch(video.description)} frameClass="relative aspect-video overflow-hidden bg-ink" />
|
|
261
|
+
</div>
|
|
262
|
+
) : panel && photo ? (
|
|
263
|
+
<div className="overflow-hidden rounded-2xl bg-tint">
|
|
264
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
265
|
+
<img src={photo.src} alt={photo.alt} className="aspect-[4/5] max-h-[55vh] w-full object-cover" style={{ objectPosition: photo.position }} />
|
|
266
|
+
</div>
|
|
267
|
+
) : null}
|
|
268
|
+
{panel && (
|
|
269
|
+
<header>
|
|
270
|
+
<p className="m-0 font-heading text-[0.82rem] font-bold tracking-[0.14em] text-ink-soft uppercase">{panel.eyebrow}</p>
|
|
271
|
+
{/* the same portraits as on the card: the panel is the full version of it */}
|
|
272
|
+
<div className="mt-3 flex items-center gap-3">
|
|
273
|
+
<span aria-hidden="true" className={`flex shrink-0 ${people.length > 1 ? "-space-x-2" : ""}`}>
|
|
274
|
+
{people.map((p) => (
|
|
275
|
+
<Avatar key={p.name} name={p.name} photo={p.photo} size={48} ring={people.length > 1}>{p.avatar}</Avatar>
|
|
276
|
+
))}
|
|
277
|
+
</span>
|
|
278
|
+
<div className="min-w-0">
|
|
279
|
+
<h2 className="m-0 font-heading text-2xl leading-tight font-semibold text-ink">{names}</h2>
|
|
280
|
+
{role && <p className="m-0 mt-0.5 text-sm text-ink-soft">{role}</p>}
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
</header>
|
|
284
|
+
)}
|
|
285
|
+
<figure className="m-0 flex flex-col gap-6">
|
|
286
|
+
<blockquote className="font-heading text-lg leading-relaxed font-medium text-ink">{quote}</blockquote>
|
|
287
|
+
{!panel && <figcaption>{byline}</figcaption>}
|
|
288
|
+
</figure>
|
|
289
|
+
</article>
|
|
290
|
+
</SidePanel>
|
|
291
|
+
)}
|
|
136
292
|
</Root>
|
|
137
293
|
);
|
|
138
294
|
}
|
package/components/Text.tsx
CHANGED
|
@@ -27,5 +27,5 @@ export function Text({ as: Tag = "p", variant = "body", children, className = ""
|
|
|
27
27
|
|
|
28
28
|
/** Prose — a run of paragraphs at reading width: 1rem between them, relaxed leading, `ink-body`. */
|
|
29
29
|
export function Prose({ children, className = "" }: { children: ReactNode; className?: string }) {
|
|
30
|
-
return <div className={`max-w-2xl
|
|
30
|
+
return <div className={`flex max-w-2xl flex-col gap-4 leading-relaxed text-ink-body [&>p]:m-0 ${className}`}>{children}</div>;
|
|
31
31
|
}
|
package/components/Timeline.tsx
CHANGED
|
@@ -43,7 +43,7 @@ export function Timeline({ steps, headingLevel = "h3", linkComponent, newTabLabe
|
|
|
43
43
|
<li key={i} className={`relative grid grid-cols-[2.75rem_minmax(0,1fr)] gap-x-4 ${last ? "" : "pb-7"}`}>
|
|
44
44
|
{/* the line linking this drawing to the next */}
|
|
45
45
|
{!last && <span aria-hidden="true" className="absolute bottom-1 left-[1.375rem] top-12 w-px -translate-x-1/2 bg-ink/15" />}
|
|
46
|
-
<span aria-hidden="true" className="flex h-11 w-11 items-center justify-center rounded-full bg-
|
|
46
|
+
<span aria-hidden="true" className="flex h-11 w-11 items-center justify-center rounded-full bg-tint text-ink">
|
|
47
47
|
<Icon size={20} strokeWidth={1.75} />
|
|
48
48
|
</span>
|
|
49
49
|
<div>
|
|
@@ -13,6 +13,8 @@ export type InviteLoop = {
|
|
|
13
13
|
/** Still frame: shown before the clip loads, without JS, and with reduced motion. */
|
|
14
14
|
poster: string;
|
|
15
15
|
posterLarge?: string;
|
|
16
|
+
/** object-position of the loop (e.g. "center 20%" keeps the face when the frame crops it). */
|
|
17
|
+
position?: string;
|
|
16
18
|
};
|
|
17
19
|
|
|
18
20
|
/** Text track drawn by the browser over the full video (.vtt). */
|
|
@@ -57,8 +59,17 @@ export type VideoInviteCardProps = {
|
|
|
57
59
|
description?: ReactNode;
|
|
58
60
|
/** Idle phase: muted looping clip. */
|
|
59
61
|
loop: InviteLoop;
|
|
60
|
-
/** Tap phase: the full video with sound, browser controls and captions.
|
|
61
|
-
|
|
62
|
+
/** Tap phase: the full video with sound, browser controls and captions.
|
|
63
|
+
* Omit it when there is no fitting speech yet: the card stays a silent,
|
|
64
|
+
* looping portrait (with its pause button) and shows no play circle. */
|
|
65
|
+
video?: InviteVideo;
|
|
66
|
+
/** `plain` (default): ink text straight over the video, for footage with a light
|
|
67
|
+
* wall behind the title. `pill`: the title on a white pill, legible over any
|
|
68
|
+
* frame (e.g. when the face reaches the top of the crop). */
|
|
69
|
+
captionStyle?: "plain" | "pill";
|
|
70
|
+
/** Where the title sits on the idle loop: `top` (default) or `bottom` — when the
|
|
71
|
+
* face fills the top of the frame. The play circle stays bottom-right. */
|
|
72
|
+
captionAt?: "top" | "bottom";
|
|
62
73
|
/** Called when the person taps play (e.g. to pause other media on the page). */
|
|
63
74
|
onPlay?: () => void;
|
|
64
75
|
/** Called when the full video ends and the card goes back to the loop. */
|
|
@@ -88,7 +99,7 @@ function PlayGlyph({ size = 22 }: { size?: number }) {
|
|
|
88
99
|
* (`rounded-2xl` in the DS scale); the consumer sets aspect/min-height via
|
|
89
100
|
* `className`.
|
|
90
101
|
*/
|
|
91
|
-
export function VideoInviteCard({ title, description, loop, video, onPlay, onEnded, labels, className = "" }: VideoInviteCardProps) {
|
|
102
|
+
export function VideoInviteCard({ title, description, loop, video, captionStyle = "plain", captionAt = "top", onPlay, onEnded, labels, className = "" }: VideoInviteCardProps) {
|
|
92
103
|
const [inviting, setInviting] = useState(false);
|
|
93
104
|
const t = { ...EN, ...labels };
|
|
94
105
|
|
|
@@ -97,7 +108,7 @@ export function VideoInviteCard({ title, description, loop, video, onPlay, onEnd
|
|
|
97
108
|
median on the loop, 5.86:1 on the invitation). The light text-shadow
|
|
98
109
|
holds it on the darker frames. */
|
|
99
110
|
const caption = (
|
|
100
|
-
<p className=
|
|
111
|
+
<p className={`inline-block text-sm font-medium leading-snug text-ink ${captionStyle === "pill" ? "rounded-2xl bg-card/90 px-3 py-2 backdrop-blur-sm" : "[text-shadow:0_1px_2px_rgba(255,255,255,0.5)]"}`}>
|
|
101
112
|
<span className="block">{title}</span>
|
|
102
113
|
{description && <span className="block font-normal">{description}</span>}
|
|
103
114
|
</p>
|
|
@@ -105,7 +116,7 @@ export function VideoInviteCard({ title, description, loop, video, onPlay, onEnd
|
|
|
105
116
|
|
|
106
117
|
const frame = `relative overflow-hidden rounded-2xl bg-ink ${className}`;
|
|
107
118
|
|
|
108
|
-
if (inviting) {
|
|
119
|
+
if (inviting && video) {
|
|
109
120
|
return (
|
|
110
121
|
<div className={frame}>
|
|
111
122
|
<video
|
|
@@ -145,13 +156,15 @@ export function VideoInviteCard({ title, description, loop, video, onPlay, onEnd
|
|
|
145
156
|
from={loop.from}
|
|
146
157
|
poster={loop.poster}
|
|
147
158
|
posterLarge={loop.posterLarge}
|
|
159
|
+
objectPosition={loop.position}
|
|
148
160
|
labels={{ pause: t.pause, play: t.play }}
|
|
149
161
|
className="absolute inset-0"
|
|
150
162
|
/>
|
|
151
163
|
{/* right-16: the loop's pause button sits in the top-right corner. */}
|
|
152
|
-
<div className=
|
|
164
|
+
<div className={`pointer-events-none absolute left-4 ${captionAt === "bottom" ? "bottom-4 right-24" : "top-4 right-16"}`}>{caption}</div>
|
|
153
165
|
{/* Only the circle is a button, so the loop's pause button (top corner)
|
|
154
166
|
stays reachable. Bottom-right, outside the face. */}
|
|
167
|
+
{video && (
|
|
155
168
|
<button
|
|
156
169
|
type="button"
|
|
157
170
|
onClick={() => {
|
|
@@ -163,6 +176,7 @@ export function VideoInviteCard({ title, description, loop, video, onPlay, onEnd
|
|
|
163
176
|
<PlayGlyph size={22} />
|
|
164
177
|
<span className="sr-only">{t.listen}</span>
|
|
165
178
|
</button>
|
|
179
|
+
)}
|
|
166
180
|
</div>
|
|
167
181
|
);
|
|
168
182
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@breathe-ds/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Breathe DS — the design system of the Cuidado Global family of sites (creeta, ttcab, metodorta). Themeable tokens + React components.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -60,6 +60,15 @@
|
|
|
60
60
|
"./components/LanguageSwitcher": "./components/LanguageSwitcher.tsx",
|
|
61
61
|
"./components/List": "./components/List.tsx",
|
|
62
62
|
"./components/Rows": "./components/Rows.tsx",
|
|
63
|
+
"./components/SearchField": "./components/SearchField.tsx",
|
|
64
|
+
"./components/SelectField": "./components/SelectField.tsx",
|
|
65
|
+
"./components/EmptyState": "./components/EmptyState.tsx",
|
|
66
|
+
"./components/FilterChips": "./components/FilterChips.tsx",
|
|
67
|
+
"./components/StickyGroupHeading": "./components/StickyGroupHeading.tsx",
|
|
68
|
+
"./components/PublicationCard": "./components/PublicationCard.tsx",
|
|
69
|
+
"./components/PageHero": "./components/PageHero.tsx",
|
|
70
|
+
"./components/ContactRow": "./components/ContactRow.tsx",
|
|
71
|
+
"./components/FilterPill": "./components/FilterPill.tsx",
|
|
63
72
|
"./components/Rail": "./components/Rail.tsx",
|
|
64
73
|
"./components/Avatar": "./components/Avatar.tsx",
|
|
65
74
|
"./components/Byline": "./components/Byline.tsx",
|
|
@@ -87,7 +96,10 @@
|
|
|
87
96
|
"./components/EventList": "./components/EventList.tsx",
|
|
88
97
|
"./components/EventFacts": "./components/EventFacts.tsx",
|
|
89
98
|
"./components/RegistrationBox": "./components/RegistrationBox.tsx",
|
|
90
|
-
"./components/SidePanel": "./components/SidePanel.tsx"
|
|
99
|
+
"./components/SidePanel": "./components/SidePanel.tsx",
|
|
100
|
+
"./components/PullQuote": "./components/PullQuote.tsx",
|
|
101
|
+
"./components/ProfileSpotlight": "./components/ProfileSpotlight.tsx",
|
|
102
|
+
"./components/FilterBar": "./components/FilterBar.tsx"
|
|
91
103
|
},
|
|
92
104
|
"publishConfig": {
|
|
93
105
|
"access": "public"
|
package/theme.css
CHANGED
|
@@ -28,6 +28,9 @@
|
|
|
28
28
|
--color-action-tint: var(--cg-action-tint, var(--cg-surface)); /* highlighted panel/card: a 6% wash of the action color */
|
|
29
29
|
--color-action-line: var(--cg-action-line, var(--cg-action)); /* border of a highlighted card (3:1 on surface) */
|
|
30
30
|
--color-line: var(--cg-line); /* hairlines/borders */
|
|
31
|
+
--color-surface-alt: var(--cg-surface-alt, var(--cg-surface)); /* alternate section band (rhythm), a step deeper than surface */
|
|
32
|
+
--color-tint: var(--cg-tint, color-mix(in srgb, var(--cg-ink) 8%, transparent)); /* neutral fill: icon bubbles, pills, image placeholders */
|
|
33
|
+
--color-shadow: var(--cg-shadow, rgb(20 71 83)); /* hue of soft shadows (used at low alpha via color-mix) */
|
|
31
34
|
|
|
32
35
|
/* ── Typography (roles; actual family comes from --cg-font-*) ── */
|
|
33
36
|
--font-heading: var(--cg-font-heading, system-ui, sans-serif);
|
|
@@ -59,6 +62,20 @@
|
|
|
59
62
|
scroll-behavior: auto;
|
|
60
63
|
}
|
|
61
64
|
}
|
|
65
|
+
/* No orphans or widows anywhere in the family (Felipe, 31/10/2026).
|
|
66
|
+
Headings balance their lines; running text avoids a lone word on the last
|
|
67
|
+
line (text-wrap: pretty, Chrome/Edge 117+, Safari 26+; elsewhere it falls
|
|
68
|
+
back to normal wrapping). In multi-column layouts (the testimonials
|
|
69
|
+
masonry) a paragraph never leaves a single line at the foot or the head
|
|
70
|
+
of a column. A component's own text-balance/text-pretty still wins. */
|
|
71
|
+
h1, h2, h3, h4, h5, h6, legend, caption {
|
|
72
|
+
text-wrap: balance;
|
|
73
|
+
}
|
|
74
|
+
p, li, dd, dt, blockquote, figcaption, summary, label, td, th {
|
|
75
|
+
text-wrap: pretty;
|
|
76
|
+
orphans: 2;
|
|
77
|
+
widows: 2;
|
|
78
|
+
}
|
|
62
79
|
/* Tailwind v4's reset leaves buttons with the default cursor; in the family
|
|
63
80
|
everything that can be tapped shows the hand: buttons, links, the summary
|
|
64
81
|
of a collapse, labels of controls, selects. Disabled ones keep the arrow. */
|
|
@@ -244,3 +261,36 @@
|
|
|
244
261
|
display: none;
|
|
245
262
|
}
|
|
246
263
|
}
|
|
264
|
+
|
|
265
|
+
/* Paper grain: a barely-there noise in the ink color over a background (body,
|
|
266
|
+
section bands). Opt-in per site. Cards (bg-card) stay clean on top of it.
|
|
267
|
+
It does not change any text contrast (the noise averages ~3% of ink). */
|
|
268
|
+
.cg-grain {
|
|
269
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.08 0 0 0 0 0.28 0 0 0 0 0.33 0 0 0 .11 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
|
270
|
+
background-repeat: repeat;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/* Dot grid (Cloudflare-like texture): tiny ink dots on a 16px grid. `.cg-dots`
|
|
274
|
+
covers the whole background; `.cg-dots-edges` keeps the dots only on the side
|
|
275
|
+
gutters, fading out before the content column, so reading areas stay clean.
|
|
276
|
+
Layered over whatever background the element already has (needs `relative`
|
|
277
|
+
on the element; the dots sit under its children). */
|
|
278
|
+
.cg-dots,
|
|
279
|
+
.cg-dots-edges {
|
|
280
|
+
position: relative;
|
|
281
|
+
isolation: isolate;
|
|
282
|
+
}
|
|
283
|
+
.cg-dots::before,
|
|
284
|
+
.cg-dots-edges::before {
|
|
285
|
+
content: "";
|
|
286
|
+
position: absolute;
|
|
287
|
+
inset: 0;
|
|
288
|
+
z-index: -1;
|
|
289
|
+
pointer-events: none;
|
|
290
|
+
background-image: radial-gradient(color-mix(in srgb, var(--color-ink) 22%, transparent) 1px, transparent 1.2px);
|
|
291
|
+
background-size: 16px 16px;
|
|
292
|
+
}
|
|
293
|
+
.cg-dots-edges::before {
|
|
294
|
+
-webkit-mask-image: linear-gradient(to right, #000 0, #000 7%, transparent 17%, transparent 83%, #000 93%, #000 100%);
|
|
295
|
+
mask-image: linear-gradient(to right, #000 0, #000 7%, transparent 17%, transparent 83%, #000 93%, #000 100%);
|
|
296
|
+
}
|
package/tokens/creeta.css
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
--cg-action-tint: #eae7ed; /* 6% magenta over mist: highlighted card */
|
|
20
20
|
--cg-action-line: #ab2781; /* magenta itself: 5.62:1 on mist */
|
|
21
21
|
--cg-on-action: #ffffff; /* white on magenta */
|
|
22
|
+
--cg-surface-alt: #e4ecee; /* section band: ink 8.53:1 · ink-soft 5.41:1 · action-text 6.79:1 */
|
|
22
23
|
--cg-line: rgb(20 71 83 / 0.16);
|
|
23
24
|
--cg-font-heading: var(--font-quicksand), system-ui, sans-serif; /* Quicksand */
|
|
24
25
|
--cg-font-body: var(--font-figtree), system-ui, sans-serif; /* Figtree */
|
package/tokens/ttcab.css
CHANGED
|
@@ -25,7 +25,12 @@
|
|
|
25
25
|
--cg-action-text: #bd3f0c; /* 4.98:1 on cream, 4.64:1 on the tint */
|
|
26
26
|
--cg-action-tint: #faebdd; /* 6% action over cream: highlighted card (ink 8.75:1) */
|
|
27
27
|
--cg-action-line: #e8541e; /* the action color itself: 3.38:1 on cream */
|
|
28
|
-
--cg-
|
|
28
|
+
--cg-surface-alt: #f6eedd; /* section band: ink 8.85:1 · ink-soft 4.58:1 · action-text 4.69:1 */
|
|
29
|
+
/* Warm neutrals (30/10/2026): lines, fills and shadows come from the logo's
|
|
30
|
+
orange family (hue ~36°), never grey — the cream and the brown stay one family. */
|
|
31
|
+
--cg-line: rgb(150 108 62 / 0.22); /* warm hairline */
|
|
32
|
+
--cg-tint: #f7ecd8; /* icon bubbles, pills, placeholders: ink 8.73:1 · ink-soft 4.51:1 */
|
|
33
|
+
--cg-shadow: rgb(107 74 55); /* mocha: soft shadows, used at low alpha */
|
|
29
34
|
--cg-font-heading: var(--font-quicksand), system-ui, sans-serif; /* Quicksand */
|
|
30
35
|
--cg-font-body: var(--font-figtree), system-ui, sans-serif; /* Figtree */
|
|
31
36
|
}
|