@breathe-ds/ui 0.5.1 → 0.6.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 (45) hide show
  1. package/README.md +33 -5
  2. package/components/Accordion.tsx +6 -3
  3. package/components/AnchorNav.tsx +10 -5
  4. package/components/Avatar.tsx +1 -1
  5. package/components/BottomNav.tsx +12 -5
  6. package/components/Button.tsx +5 -2
  7. package/components/Byline.tsx +25 -1
  8. package/components/Card.tsx +11 -3
  9. package/components/ContactRow.tsx +112 -0
  10. package/components/CourseCard.tsx +3 -3
  11. package/components/CtaCard.tsx +3 -1
  12. package/components/DateBadge.tsx +1 -1
  13. package/components/EmptyState.tsx +54 -0
  14. package/components/EventFacts.tsx +1 -1
  15. package/components/EventRow.tsx +20 -8
  16. package/components/FactStrip.tsx +1 -1
  17. package/components/FilterBar.tsx +50 -0
  18. package/components/FilterChips.tsx +63 -0
  19. package/components/FilterPill.tsx +35 -0
  20. package/components/Footer.tsx +1 -1
  21. package/components/Header.tsx +61 -33
  22. package/components/Heading.tsx +3 -2
  23. package/components/InfoCard.tsx +7 -2
  24. package/components/List.tsx +4 -2
  25. package/components/PageHero.tsx +74 -0
  26. package/components/PageVideoHero.tsx +21 -6
  27. package/components/PersonChip.tsx +1 -1
  28. package/components/Pill.tsx +1 -1
  29. package/components/ProfessionalCard.tsx +60 -5
  30. package/components/ProfileSpotlight.tsx +94 -0
  31. package/components/PublicationCard.tsx +215 -0
  32. package/components/PullQuote.tsx +87 -0
  33. package/components/RegistrationBox.tsx +1 -1
  34. package/components/SearchField.tsx +85 -0
  35. package/components/SelectField.tsx +75 -0
  36. package/components/SidePanel.tsx +6 -3
  37. package/components/StickyGroupHeading.tsx +38 -0
  38. package/components/TestimonialCard.tsx +179 -23
  39. package/components/Text.tsx +1 -1
  40. package/components/Timeline.tsx +1 -1
  41. package/components/VideoInviteCard.tsx +20 -6
  42. package/package.json +14 -2
  43. package/theme.css +50 -0
  44. package/tokens/creeta.css +1 -0
  45. package/tokens/ttcab.css +6 -1
@@ -0,0 +1,75 @@
1
+ import type { ReactNode, SelectHTMLAttributes } from "react";
2
+ import { ChevronDown } from "lucide-react";
3
+ import { FIELD, FIELD_LABEL } from "./SearchField";
4
+
5
+ export type SelectOption = {
6
+ value: string;
7
+ label: string;
8
+ /** Count shown after the label ("São Paulo (51)"). Native options only take text. */
9
+ count?: number;
10
+ };
11
+
12
+ export type SelectGroup = { label: string; options: SelectOption[] };
13
+
14
+ type Props = Omit<SelectHTMLAttributes<HTMLSelectElement>, "value" | "onChange" | "children"> & {
15
+ id: string;
16
+ label: ReactNode;
17
+ value: string;
18
+ onValueChange: (value: string) => void;
19
+ /** The first option, value "": "Todos os estados". */
20
+ placeholder: string;
21
+ /** Flat options, or groups (`<optgroup>`): pass one or the other. */
22
+ options?: SelectOption[];
23
+ groups?: SelectGroup[];
24
+ /** Keep the label for screen readers only, when a group label above already says
25
+ * what the fields are (and the placeholder names the field: "Todos os estados"). */
26
+ hideLabel?: boolean;
27
+ /** `md` (default): the 56px field. `sm`: a 40px pill for a filter bar (Apple-like).
28
+ * (Not `size`: that is the native <select> attribute.) */
29
+ density?: "md" | "sm";
30
+ className?: string;
31
+ };
32
+
33
+ const text = (o: SelectOption) => (o.count === undefined ? o.label : `${o.label} (${o.count})`);
34
+
35
+ /**
36
+ * SelectField — a native `<select>` dressed as the family's field (same pill,
37
+ * hairline and shadow as SearchField), with a lucide chevron. Native on
38
+ * purpose: on phones it opens the system picker, which is faster and more
39
+ * accessible than any custom listbox for a list of states or cities.
40
+ */
41
+ export function SelectField({ id, label, value, onValueChange, placeholder, options, groups, hideLabel = false, density = "md", className = "", ...rest }: Props) {
42
+ return (
43
+ <div className={className}>
44
+ <label htmlFor={id} className={hideLabel ? "sr-only" : FIELD_LABEL}>
45
+ {label}
46
+ </label>
47
+ <div className="relative">
48
+ <select
49
+ id={id}
50
+ value={value}
51
+ onChange={(e) => onValueChange(e.target.value)}
52
+ className={`${FIELD} cursor-pointer appearance-none truncate ${density === "sm" ? "!min-h-10 py-2 pr-9 pl-4 text-sm font-medium" : "pr-12 pl-5"}`}
53
+ {...rest}
54
+ >
55
+ <option value="">{placeholder}</option>
56
+ {options?.map((o) => (
57
+ <option key={o.value} value={o.value}>
58
+ {text(o)}
59
+ </option>
60
+ ))}
61
+ {groups?.map((g) => (
62
+ <optgroup key={g.label} label={g.label}>
63
+ {g.options.map((o) => (
64
+ <option key={o.value} value={o.value}>
65
+ {text(o)}
66
+ </option>
67
+ ))}
68
+ </optgroup>
69
+ ))}
70
+ </select>
71
+ <ChevronDown aria-hidden="true" size={density === "sm" ? 16 : 18} className={`pointer-events-none absolute top-1/2 -translate-y-1/2 text-ink-soft ${density === "sm" ? "right-3.5" : "right-5"}`} />
72
+ </div>
73
+ </div>
74
+ );
75
+ }
@@ -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-ink/[0.06] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none"
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: string;
28
- /** Still frame shown until the person taps. */
29
- poster: string;
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
- video: TestimonialVideo;
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
- const frame = "relative min-h-[16rem] overflow-hidden bg-ink";
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
- export function TestimonialCard({ quote, author, authorPhoto, authorAvatar, authors, link, video, as: Root = "div", labels, className = "" }: Props) {
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={`grid grid-cols-[minmax(0,2fr)_minmax(0,3fr)] overflow-hidden rounded-[28px] bg-card ${className}`}>
124
- <TapVideo {...video} label={t.watch(video.description)} />
125
- <figure className="flex flex-col justify-between gap-5 p-5 md:p-7">
126
- <blockquote className="font-heading text-[0.9375rem] font-medium leading-relaxed text-ink md:text-base">{quote}</blockquote>
127
- <figcaption>
128
- <Byline
129
- cite
130
- people={people}
131
- action={link ? { label: link.label, href: link.href, external: true } : undefined}
132
- newTabLabel={t.newTab.trim()}
133
- />
134
- </figcaption>
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
  }
@@ -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 space-y-4 leading-relaxed text-ink-body [&>p]:m-0 ${className}`}>{children}</div>;
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
  }
@@ -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-ink/[0.12] text-ink">
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
- video: InviteVideo;
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="inline-block text-sm font-medium leading-snug text-ink [text-shadow:0_1px_2px_rgba(255,255,255,0.5)]">
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="pointer-events-none absolute top-4 left-4 right-16">{caption}</div>
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.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Breathe DS — the design system of the Cuidado Global family of sites (creeta, ttcab, metodorta). Themeable tokens + React components.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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-line: rgb(20 71 83 / 0.16);
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
  }