@breathe-ds/ui 0.3.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.
@@ -3,7 +3,7 @@
3
3
  import { useEffect, useRef, useState, type ElementType, type ReactNode } from "react";
4
4
  import { initials } from "./Avatar";
5
5
  import { ProfessionalModal, type ProfessionalModalLabels, type ProfessionalPresentation } from "./ProfessionalModal";
6
- import { CollapseRow, LinkRow, RowList } from "./Rows";
6
+ import { CollapseRow, LinkRow, RowList, type RowLink } from "./Rows";
7
7
 
8
8
  export type ProfessionalPhoto = {
9
9
  src: string;
@@ -22,28 +22,50 @@ export type ProfessionalLiving = {
22
22
  poster: string;
23
23
  };
24
24
 
25
- export type ProfessionalGuidance = { label: ReactNode; href: string };
25
+ /**
26
+ * One row at the foot of the full card (RowList). Generic: the site decides
27
+ * what the rows are. On novo.creeta they are "Trajetória" (collapse with the
28
+ * bio), "Orientações" (collapse with the links the person signs, with
29
+ * `openOnArrival`) and "Instagram" (link with icon, new tab).
30
+ */
31
+ export type ProfessionalCardRow =
32
+ | {
33
+ kind: "collapse";
34
+ label: ReactNode;
35
+ /** sr-only right after the label, e.g. " of Person name". */
36
+ srLabel?: ReactNode;
37
+ /** Text content (a string becomes one paragraph). */
38
+ content?: ReactNode;
39
+ /** Or a list of links, with the count beside the label. */
40
+ links?: RowLink[];
41
+ count?: number;
42
+ /** Marks the row with `data-cg-open-on-arrival`: the site opens it when
43
+ * the page is reached by the card's anchor (creeta's `abre-ao-chegar`). */
44
+ openOnArrival?: boolean;
45
+ }
46
+ | {
47
+ kind: "link";
48
+ label: ReactNode;
49
+ href: string;
50
+ srLabel?: ReactNode;
51
+ /** ↗ another page (default) or ↓ an anchor on this page. */
52
+ arrow?: "go" | "down";
53
+ /** Opens in a new tab. */
54
+ external?: boolean;
55
+ /** Replaces the arrow (an 18px glyph): the Instagram icon, for instance. */
56
+ icon?: ReactNode;
57
+ };
26
58
 
27
59
  export type ProfessionalCardLabels = ProfessionalModalLabels & {
28
60
  /** alt of the portrait. */
29
61
  portrait: (name: string) => string;
30
- trajectory: string;
31
- /** The row of the guidance the person signs; the count goes beside it. */
32
- guidance: string;
33
- /** sr-only, right after "Trajectory" and "Guidance": " of {name}". */
34
- of: (name: string) => string;
35
- instagram: string;
36
- /** sr-only, right after "Instagram". */
37
- instagramOf: (name: string) => string;
62
+ /** The small badge on the medium card's photo, on hover ("Conhecer"). */
63
+ meet: string;
38
64
  };
39
65
 
40
66
  const EN: Omit<ProfessionalCardLabels, keyof ProfessionalModalLabels> = {
41
67
  portrait: (name) => `Portrait of ${name}`,
42
- trajectory: "Trajectory",
43
- guidance: "Guidance",
44
- of: (name) => ` of ${name}`,
45
- instagram: "Instagram",
46
- instagramOf: (name) => ` of ${name} (opens in a new tab)`,
68
+ meet: "Meet",
47
69
  };
48
70
 
49
71
  const SIZES = "(min-width: 1024px) 30vw, (min-width: 640px) 45vw, 92vw";
@@ -52,6 +74,9 @@ const SIZES = "(min-width: 1024px) 30vw, (min-width: 640px) 45vw, 92vw";
52
74
  const ZOOM =
53
75
  "aspect-[4/5] h-auto w-full object-cover transition-transform duration-500 ease-[var(--ease-cg)] motion-reduce:transition-none";
54
76
  const ZOOM_CARD = `${ZOOM} [@media(hover:hover)_and_(pointer:fine)]:group-hover/card:scale-[1.03] motion-reduce:[@media(hover:hover)_and_(pointer:fine)]:group-hover/card:scale-100`;
77
+ /* medium card: the 3:4 portrait, zoom with the mouse over the link */
78
+ const ZOOM_MEDIUM =
79
+ "aspect-[3/4] h-auto w-full object-cover transition-transform duration-500 ease-[var(--ease-cg)] motion-reduce:transition-none [@media(hover:hover)_and_(pointer:fine)]:group-hover:scale-[1.04] motion-reduce:[@media(hover:hover)_and_(pointer:fine)]:group-hover:scale-100";
55
80
  /* inside the modal trigger only the photo button is the hover target */
56
81
  const ZOOM_PHOTO = `${ZOOM} [@media(hover:hover)_and_(pointer:fine)]:group-hover/photo:scale-[1.03] motion-reduce:[@media(hover:hover)_and_(pointer:fine)]:group-hover/photo:scale-100`;
57
82
 
@@ -224,15 +249,25 @@ type Props = {
224
249
  living?: ProfessionalLiving;
225
250
  /** With a presentation video the portrait opens the modal (ProfessionalModal). */
226
251
  presentation?: ProfessionalPresentation;
227
- /** The bio: a string (one paragraph) or your own paragraphs. Opens in place
228
- * under "Trajectory". */
252
+ /** The full bio, shown in the presentation modal: a string (one paragraph)
253
+ * or your own paragraphs. To show it on the card too, put it in a
254
+ * `rows` collapse. */
229
255
  bio?: ReactNode;
230
- /** The guidance pages the person signs: the row lists them, with the count. */
231
- guidance?: ProfessionalGuidance[];
232
- /** Instagram profile URL (outbound row). */
233
- instagram?: string;
234
- /** Anchor of the card (the authorship link of a guidance page lands here). */
256
+ /** The rows at the foot of the card (full size): collapses with text or
257
+ * links, links with an arrow or an icon. See ProfessionalCardRow. */
258
+ rows?: ProfessionalCardRow[];
259
+ /** Anchor of the card (a link elsewhere on the site lands here). */
235
260
  id?: string;
261
+ /** `full` (default): the team-page card. `medium`: the home's tile — a
262
+ * white card with the 3:4 portrait and the name, a link to the full card
263
+ * (living portrait and "Meet" badge on hover). `micro`: the "who treats"
264
+ * row — 72px photo, name and role, also a link. The small sizes render the
265
+ * link as the root; wrap them in your <li> (RailItem). */
266
+ size?: "full" | "medium" | "micro";
267
+ /** Where the small sizes lead: the full card on the team page ("/team/#slug"). */
268
+ href?: string;
269
+ /** Name shown on the medium card (the site uses the first two words). */
270
+ shortName?: string;
236
271
  /** h2 on the team page (default), h3 inside a section that already has an h2. */
237
272
  headingLevel?: "h2" | "h3";
238
273
  /** Root element. Default <article>. */
@@ -248,13 +283,14 @@ type Props = {
248
283
  * (components/professional-card.tsx) with the Breathe tokens. A `card`
249
284
  * surface with the portrait on top (plain, living, or opening the video
250
285
  * presentation), the name, role and registration, then one list of equal
251
- * rows separated by hairlines: Trajectory (opens the bio in place), Guidance
252
- * (opens the links in place) and Instagram (outbound). Six open bios used to
253
- * make the page 6,700px tall on a phone, hence the rows.
286
+ * rows separated by hairlines (`rows`): collapses that open in place (a text,
287
+ * or a list of links with the count) and links (arrow or icon). On creeta:
288
+ * Trajectory, Guidance and Instagram. Six open bios used to make the page
289
+ * 6,700px tall on a phone, hence the collapses.
254
290
  *
255
- * The Guidance row carries `data-cg-open-on-arrival`: the site opens it when
256
- * the page is reached by the card's anchor (creeta's `abre-ao-chegar.tsx`,
257
- * not part of the DS).
291
+ * A collapse with `openOnArrival` carries `data-cg-open-on-arrival`: the site
292
+ * opens it when the page is reached by the card's anchor (creeta's
293
+ * `abre-ao-chegar.tsx`, not part of the DS).
258
294
  */
259
295
  export function ProfessionalCard({
260
296
  name,
@@ -265,9 +301,11 @@ export function ProfessionalCard({
265
301
  living,
266
302
  presentation,
267
303
  bio,
268
- guidance,
269
- instagram,
304
+ rows,
270
305
  id,
306
+ size = "full",
307
+ href = "#",
308
+ shortName,
271
309
  headingLevel: Heading = "h2",
272
310
  as: As = "article",
273
311
  linkComponent,
@@ -279,6 +317,69 @@ export function ProfessionalCard({
279
317
  const alt = photo?.alt ?? t.portrait(name);
280
318
  const hasPortrait = Boolean(photo || media || living);
281
319
 
320
+ if (size === "micro") {
321
+ // the "who treats" row of a treatment page (creeta telas/tratamento.tsx):
322
+ // the text beside says who it is, so the photo is decoration
323
+ return (
324
+ <L
325
+ href={href}
326
+ id={id}
327
+ className={`flex items-center gap-4 rounded-[22px] bg-card p-3 pr-5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink ${className}`}
328
+ >
329
+ {photo ? (
330
+ <img src={photo.src} srcSet={photo.srcSet} sizes="72px" alt="" loading="lazy" decoding="async" className="h-[72px] w-[72px] shrink-0 rounded-2xl object-cover" />
331
+ ) : media ? (
332
+ <span className="block h-[72px] w-[72px] shrink-0 overflow-hidden rounded-2xl">{media}</span>
333
+ ) : (
334
+ <span aria-hidden="true" className="flex h-[72px] w-[72px] shrink-0 items-center justify-center rounded-2xl bg-ink/[0.12] font-heading text-xl font-medium text-ink">
335
+ {initials(name)}
336
+ </span>
337
+ )}
338
+ <span>
339
+ <span className="block font-heading font-medium leading-snug text-ink">{name}</span>
340
+ {role && <span className="mt-0.5 block text-sm text-ink-soft">{role}</span>}
341
+ </span>
342
+ </L>
343
+ );
344
+ }
345
+
346
+ if (size === "medium") {
347
+ // the home's team tile (creeta telas/home.tsx): a white card with the
348
+ // photo and the name inside — without the card the name sat loose under
349
+ // the photo, with no "close". Mouse over: the clip plays, the photo zooms
350
+ // a little and a discreet "Meet" shows in the corner; on tap the card shrinks.
351
+ return (
352
+ <L
353
+ href={href}
354
+ id={id}
355
+ data-cg-professional-card=""
356
+ className={`group block h-full rounded-[26px] bg-card p-2 pb-4 transition-transform duration-200 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}`}
357
+ >
358
+ <span className="relative block overflow-hidden rounded-[20px]">
359
+ {hasPortrait ? (
360
+ <Portrait name={name} photo={photo ? { ...photo, sizes: photo.sizes ?? "(min-width: 768px) 16vw, 42vw" } : undefined} media={media} living={living} alt={alt} decorative className={ZOOM_MEDIUM} />
361
+ ) : (
362
+ <span aria-hidden="true" className="flex aspect-[3/4] w-full items-center justify-center bg-ink/[0.12] font-heading text-3xl font-medium text-ink">
363
+ {initials(name)}
364
+ </span>
365
+ )}
366
+ {/* only where a mouse exists, and with keyboard focus; the link
367
+ already has its name, so it is decoration */}
368
+ <span
369
+ aria-hidden="true"
370
+ className="pointer-events-none absolute bottom-2 left-2 inline-flex translate-y-1 items-center gap-1.5 rounded-full border border-white/15 bg-ink/80 px-3 py-2 text-xs font-medium text-white opacity-0 backdrop-blur-md transition duration-200 ease-out group-focus-visible:translate-y-0 group-focus-visible:opacity-100 motion-reduce:translate-y-0 motion-reduce:transition-none [@media(hover:hover)_and_(pointer:fine)]:group-hover:translate-y-0 [@media(hover:hover)_and_(pointer:fine)]:group-hover:opacity-100"
371
+ >
372
+ {t.meet}
373
+ <svg aria-hidden="true" viewBox="0 0 24 24" width={14} height={14} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
374
+ </span>
375
+ </span>
376
+ <span className="mt-3 block px-1.5 font-heading text-sm font-medium leading-snug text-ink transition-colors duration-150 group-hover:text-ink-soft motion-reduce:transition-none">
377
+ {shortName ?? name}
378
+ </span>
379
+ </L>
380
+ );
381
+ }
382
+
282
383
  return (
283
384
  <As id={id} data-cg-professional-card="" className={`group/card scroll-mt-24 rounded-2xl bg-card p-3 pb-6 ${className}`}>
284
385
  {hasPortrait && presentation ? (
@@ -315,26 +416,30 @@ export function ProfessionalCard({
315
416
  <p className="mt-1 text-xs text-ink/80">{registration}</p>
316
417
  </>
317
418
  )}
318
- {/* Trajectory, guidance and Instagram: one list, of equal rows. They
319
- used to be three things with three designs (an open button, a loose
320
- list of links and a magenta link at the end). The first two open in
321
- place: six open bios made 6,700px of page on a phone. */}
322
- <RowList className="mt-4">
323
- {bio && (
324
- <CollapseRow label={t.trajectory} srLabel={t.of(name)}>
325
- {bio}
326
- </CollapseRow>
327
- )}
328
-
329
- {/* What the person signs: it is what makes this card the destination
330
- of the authorship link of the guidance pages. Whoever arrives by
331
- that link finds this block open (site-side, data-cg-open-on-arrival). */}
332
- {guidance && guidance.length > 0 && (
333
- <CollapseRow label={t.guidance} srLabel={t.of(name)} links={guidance} linkComponent={L} data-cg-open-on-arrival="" />
334
- )}
335
-
336
- {instagram && <LinkRow label={t.instagram} srLabel={t.instagramOf(name)} href={instagram} external />}
337
- </RowList>
419
+ {/* One list of equal rows. On creeta they used to be three things
420
+ with three designs (an open button, a loose list of links and a
421
+ magenta link at the end); the collapses open in place. */}
422
+ {rows && rows.length > 0 && (
423
+ <RowList className="mt-4">
424
+ {rows.map((row, i) =>
425
+ row.kind === "collapse" ? (
426
+ <CollapseRow
427
+ key={i}
428
+ label={row.label}
429
+ srLabel={row.srLabel}
430
+ count={row.count}
431
+ links={row.links}
432
+ linkComponent={L}
433
+ {...(row.openOnArrival ? { "data-cg-open-on-arrival": "" } : {})}
434
+ >
435
+ {row.content}
436
+ </CollapseRow>
437
+ ) : (
438
+ <LinkRow key={i} label={row.label} srLabel={row.srLabel} href={row.href} arrow={row.arrow} external={row.external} icon={row.icon} linkComponent={L} />
439
+ ),
440
+ )}
441
+ </RowList>
442
+ )}
338
443
  </div>
339
444
  </As>
340
445
  );
@@ -0,0 +1,94 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from "react";
4
+
5
+ type RailProps = {
6
+ /** Name of the list for screen readers: "Treatments", "Team". */
7
+ label: string;
8
+ /** Grid classes from md up, written in full (Tailwind only generates whole
9
+ * classes). Default: three columns. */
10
+ grid?: string;
11
+ /** <li> children, usually RailItem. */
12
+ children: ReactNode;
13
+ className?: string;
14
+ };
15
+
16
+ /**
17
+ * Rail — novo.creeta's `Trilho` (components/trilho.tsx) with the Breathe
18
+ * tokens. On a phone it is a horizontal rail with snap, and the next card
19
+ * shows by half to say there is more. From md it becomes a grid.
20
+ *
21
+ * Under the rail, a small bar follows the finger: it starts the size of what
22
+ * is already in view and fills to the end — it says how much is left. Shown
23
+ * only when the rail really scrolls, decoration for screen readers. The fill
24
+ * is written straight to the style, without re-rendering on every scroll frame.
25
+ */
26
+ export function Rail({ label, grid = "md:grid-cols-3", children, className = "" }: RailProps) {
27
+ const list = useRef<HTMLUListElement>(null);
28
+ const bar = useRef<HTMLSpanElement>(null);
29
+ const [scrolls, setScrolls] = useState(false);
30
+
31
+ useEffect(() => {
32
+ const ul = list.current;
33
+ if (!ul) return;
34
+ let frame = 0;
35
+ const measure = () => {
36
+ frame = 0;
37
+ const overflow = ul.scrollWidth - ul.clientWidth;
38
+ setScrolls(overflow > 8);
39
+ if (bar.current && ul.scrollWidth > 0) {
40
+ const seen = Math.min(1, (ul.scrollLeft + ul.clientWidth) / ul.scrollWidth);
41
+ bar.current.style.transform = `scaleX(${seen})`;
42
+ }
43
+ };
44
+ const ask = () => {
45
+ if (!frame) frame = requestAnimationFrame(measure);
46
+ };
47
+ measure();
48
+ ul.addEventListener("scroll", ask, { passive: true });
49
+ window.addEventListener("resize", ask);
50
+ return () => {
51
+ ul.removeEventListener("scroll", ask);
52
+ window.removeEventListener("resize", ask);
53
+ if (frame) cancelAnimationFrame(frame);
54
+ };
55
+ }, []);
56
+
57
+ return (
58
+ <>
59
+ <ul
60
+ ref={list}
61
+ aria-label={label}
62
+ className={`cg-no-scrollbar -mx-4 flex snap-x snap-mandatory scroll-px-4 gap-3 overflow-x-auto px-4 pb-1 md:mx-0 md:grid md:snap-none md:gap-5 md:overflow-visible md:px-0 ${grid} ${className}`}
63
+ >
64
+ {children}
65
+ </ul>
66
+ <div
67
+ aria-hidden="true"
68
+ data-cg-rail-progress=""
69
+ className={`mx-auto mt-4 h-1 w-14 overflow-hidden rounded-full bg-ink/15 md:hidden ${scrolls ? "" : "hidden"}`}
70
+ >
71
+ <span ref={bar} className="block h-full w-full origin-left rounded-full bg-ink-soft" />
72
+ </div>
73
+ </>
74
+ );
75
+ }
76
+
77
+ /** Widths of one item on the rail (phone / sm); from md the grid decides.
78
+ * The next card shows by half: that is what says "there is more". */
79
+ export const RAIL_ITEM = {
80
+ /** Treatments, videos: one card almost full width. */
81
+ wide: "w-[78%] shrink-0 snap-start sm:w-[46%] md:w-auto",
82
+ /** Team (medium ProfessionalCard): two cards and a bit. */
83
+ narrow: "w-[46%] shrink-0 snap-start sm:w-[30%] md:w-auto",
84
+ } as const;
85
+
86
+ type RailItemProps = ComponentPropsWithoutRef<"li"> & {
87
+ /** `wide` (default) or `narrow`. */
88
+ width?: keyof typeof RAIL_ITEM;
89
+ };
90
+
91
+ /** One <li> of the Rail, with the phone width preset. */
92
+ export function RailItem({ width = "wide", className = "", ...rest }: RailItemProps) {
93
+ return <li className={`${RAIL_ITEM[width]} ${className}`} {...rest} />;
94
+ }
@@ -18,8 +18,14 @@ import { ArrowDown, ArrowUpRight, ChevronDown } from "lucide-react";
18
18
  export const ROW =
19
19
  "flex min-h-12 cursor-pointer list-none items-center justify-between gap-3 text-sm font-semibold text-ink focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink [&::-webkit-details-marker]:hidden";
20
20
 
21
+ /** Hover tells the two rows apart (2026-10-29): a link row underlines its label
22
+ * (the family's sign for "goes somewhere", as the Footer and the Byline) and
23
+ * its arrow takes the step; a collapse row never underlines — it goes nowhere —
24
+ * its chevron dips 2px, "opens down here", and turns up when open. */
25
+ const ROW_LINK = `${ROW} underline decoration-transparent underline-offset-4 transition-[text-decoration-color] duration-150 hover:decoration-ink motion-reduce:transition-none`;
26
+
21
27
  const OPEN_CHEVRON =
22
- "shrink-0 text-ink-soft transition-transform duration-200 group-open:rotate-180 motion-reduce:transition-none";
28
+ "shrink-0 text-ink-soft transition-[transform,translate] duration-200 group-hover/row:translate-y-0.5 group-open/row:rotate-180 group-open/row:group-hover/row:translate-y-0 motion-reduce:transition-none";
23
29
 
24
30
  /** What appears out of nowhere (the content of a <details>) fades in briefly
25
31
  * instead of jumping on screen: `@starting-style` via Tailwind's `starting:`
@@ -72,7 +78,7 @@ export function CollapseRow({ label, srLabel, count, links, linkComponent, child
72
78
  const L: ElementType = linkComponent ?? "a";
73
79
  const n = count ?? links?.length;
74
80
  return (
75
- <details className={`group ${className}`} {...rest}>
81
+ <details className={`group/row ${className}`} {...rest}>
76
82
  <summary className={ROW}>
77
83
  <span>
78
84
  {label}
@@ -120,7 +126,7 @@ export type LinkRowProps = Omit<ComponentPropsWithoutRef<"a">, "children"> & {
120
126
  export function LinkRow({ label, href, srLabel, arrow, external = false, icon, linkComponent, className = "", ...rest }: LinkRowProps) {
121
127
  const L: ElementType = linkComponent ?? "a";
122
128
  return (
123
- <L href={href} className={`${ROW} ${className}`} {...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})} {...rest}>
129
+ <L href={href} className={`${ROW_LINK} ${className}`} {...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})} {...rest}>
124
130
  <span>
125
131
  {label}
126
132
  {srLabel && <span className="sr-only">{srLabel}</span>}
@@ -0,0 +1,107 @@
1
+ import type { ElementType, ReactNode } from "react";
2
+
3
+ export type StoryPillar = { title: ReactNode; text: ReactNode };
4
+ export type StoryLink = { label: ReactNode; href: string; external?: boolean };
5
+
6
+ type Labels = {
7
+ /** sr-only after an external link. */
8
+ newTab: string;
9
+ };
10
+ const EN: Labels = { newTab: "(opens in a new tab)" };
11
+
12
+ type Props = {
13
+ /** What fills the media box at the top: a BackgroundVideo, an <img>
14
+ * (absolute inset-0, object-cover). The panel's film fades it into the panel. */
15
+ media: ReactNode;
16
+ /** Box of the media: 4:3 on the phone, 21:8 from md (creeta). */
17
+ mediaAspect?: string;
18
+ /** Logo for the dark background, at the base of the media where the film is already full. */
19
+ logo?: ReactNode;
20
+ title: ReactNode;
21
+ lead?: ReactNode;
22
+ /** Three columns with a hairline on top: a short title and a paragraph. */
23
+ pillars: StoryPillar[];
24
+ /** Outbound links at the end ("Learn about the method ↗"). */
25
+ links?: StoryLink[];
26
+ headingLevel?: "h2" | "h3";
27
+ /** Colour of the pillar titles. Default `text-surface`; creeta passes its light teal. */
28
+ pillarTitleClassName?: string;
29
+ linkComponent?: ElementType;
30
+ labels?: Partial<Labels>;
31
+ className?: string;
32
+ };
33
+
34
+ const ArrowUpRight = () => (
35
+ <svg aria-hidden="true" viewBox="0 0 24 24" width={18} height={18} className="cg-arrow-go" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
36
+ );
37
+
38
+ const link =
39
+ "inline-flex min-h-11 items-center gap-1.5 font-semibold underline underline-offset-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-surface";
40
+
41
+ /**
42
+ * Story panel of the Cuidado Global family — creeta's `OrigemRta`, by tokens:
43
+ * a dark panel (surface-panel) that groups a story in one piece. Media at the
44
+ * top with a film that fades into the panel, the logo at its base, a title and
45
+ * a lead, three pillars in columns with a hairline above each, and the outbound
46
+ * links. Good for "who we are" blocks: one rounded card, everything inside.
47
+ */
48
+ export function StoryPanel({
49
+ media,
50
+ mediaAspect = "aspect-[4/3] md:aspect-[21/8]",
51
+ logo,
52
+ title,
53
+ lead,
54
+ pillars,
55
+ links,
56
+ headingLevel: Title = "h2",
57
+ pillarTitleClassName = "text-surface",
58
+ linkComponent,
59
+ labels,
60
+ className = "",
61
+ }: Props) {
62
+ const L: ElementType = linkComponent ?? "a";
63
+ const t = { ...EN, ...labels };
64
+ return (
65
+ <section className={`overflow-hidden rounded-[28px] bg-surface-panel text-surface ${className}`}>
66
+ <div className={`relative ${mediaAspect}`}>
67
+ {media}
68
+ {/* -bottom-px: the box's height is fractional and a light half pixel showed under the film */}
69
+ <span aria-hidden="true" className="pointer-events-none absolute inset-0 -bottom-px bg-gradient-to-t from-surface-panel from-10% via-surface-panel/70 via-40% to-surface-panel/10" />
70
+ {logo && <div className="absolute bottom-2 left-6 md:bottom-4 md:left-12">{logo}</div>}
71
+ </div>
72
+
73
+ <div className="px-6 pb-9 pt-7 md:px-12 md:pb-14 md:pt-9">
74
+ <Title className="max-w-3xl font-heading text-[1.75rem] font-medium leading-[1.12] tracking-[-0.01em] md:text-5xl">{title}</Title>
75
+ {lead && <p className="mt-4 max-w-2xl text-lg leading-relaxed text-surface/85">{lead}</p>}
76
+
77
+ <ul className="mt-8 grid list-none gap-6 md:mt-12 md:grid-cols-3 md:gap-10">
78
+ {pillars.map((p, i) => (
79
+ <li key={i} className="border-t border-surface/25 pt-5">
80
+ <p className={`font-heading text-2xl font-medium ${pillarTitleClassName}`}>{p.title}</p>
81
+ <p className="mt-2 leading-relaxed text-surface/85">{p.text}</p>
82
+ </li>
83
+ ))}
84
+ </ul>
85
+
86
+ {links && links.length > 0 && (
87
+ <div className="mt-8 flex flex-wrap gap-x-8 gap-y-1 md:mt-10">
88
+ {links.map((l, i) =>
89
+ l.external ? (
90
+ <a key={i} href={l.href} target="_blank" rel="noopener noreferrer" className={link}>
91
+ {l.label}
92
+ <ArrowUpRight />
93
+ <span className="sr-only"> {t.newTab}</span>
94
+ </a>
95
+ ) : (
96
+ <L key={i} href={l.href} className={link}>
97
+ {l.label}
98
+ <ArrowUpRight />
99
+ </L>
100
+ ),
101
+ )}
102
+ </div>
103
+ )}
104
+ </div>
105
+ </section>
106
+ );
107
+ }
@@ -0,0 +1,39 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+
5
+ /** whole: the drawing finished, not animating (no JavaScript, no
6
+ * IntersectionObserver, or reduced motion).
7
+ * waiting: hidden, until it shows up on screen.
8
+ * playing: it showed up; the animation runs. */
9
+ export type AppearState = "whole" | "waiting" | "playing";
10
+
11
+ export const reducedMotion = () =>
12
+ typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
13
+
14
+ /** For drawings that animate once half of them is on screen (creeta's
15
+ * `useAoAparecer`). The default is the finished drawing: it only hides to
16
+ * animate when it can animate. `onScreen` follows the drawing the whole time:
17
+ * a looping animation only runs while someone can see it. */
18
+ export function useOnAppear<T extends HTMLElement>() {
19
+ const ref = useRef<T>(null);
20
+ const [state, setState] = useState<AppearState>("whole");
21
+ const [onScreen, setOnScreen] = useState(false);
22
+
23
+ useEffect(() => {
24
+ const el = ref.current;
25
+ if (!el || typeof IntersectionObserver === "undefined" || reducedMotion()) return;
26
+ setState("waiting");
27
+ const eye = new IntersectionObserver(
28
+ ([e]) => {
29
+ setOnScreen(!!e.isIntersecting);
30
+ if (e.isIntersecting) setState("playing");
31
+ },
32
+ { threshold: 0.5 },
33
+ );
34
+ eye.observe(el);
35
+ return () => eye.disconnect();
36
+ }, []);
37
+
38
+ return { ref, state, onScreen };
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@breathe-ds/ui",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",
@@ -48,6 +48,7 @@
48
48
  "./components/LanguageSwitcher": "./components/LanguageSwitcher.tsx",
49
49
  "./components/List": "./components/List.tsx",
50
50
  "./components/Rows": "./components/Rows.tsx",
51
+ "./components/Rail": "./components/Rail.tsx",
51
52
  "./components/Avatar": "./components/Avatar.tsx",
52
53
  "./components/Byline": "./components/Byline.tsx",
53
54
  "./components/ProfessionalCard": "./components/ProfessionalCard.tsx",
@@ -57,7 +58,17 @@
57
58
  "./components/Breadcrumb": "./components/Breadcrumb.tsx",
58
59
  "./components/Timeline": "./components/Timeline.tsx",
59
60
  "./components/marks/ttcab": "./components/marks/ttcab.tsx",
60
- "./components/marks/creeta": "./components/marks/creeta.tsx"
61
+ "./components/marks/creeta": "./components/marks/creeta.tsx",
62
+ "./components/CareSteps": "./components/CareSteps.tsx",
63
+ "./components/ChatPreview": "./components/ChatPreview.tsx",
64
+ "./components/PlanTimeline": "./components/PlanTimeline.tsx",
65
+ "./components/PhotoSequence": "./components/PhotoSequence.tsx",
66
+ "./components/PauseButton": "./components/PauseButton.tsx",
67
+ "./components/useOnAppear": "./components/useOnAppear.ts",
68
+ "./components/StoryPanel": "./components/StoryPanel.tsx",
69
+ "./components/PostCard": "./components/PostCard.tsx",
70
+ "./components/FeaturedPosts": "./components/FeaturedPosts.tsx",
71
+ "./components/PostArticle": "./components/PostArticle.tsx"
61
72
  },
62
73
  "publishConfig": {
63
74
  "access": "public"