@breathe-ds/ui 0.3.0 → 0.5.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.
@@ -0,0 +1,62 @@
1
+ import type { ElementType, ReactNode } from "react";
2
+ import { MapPin } from "lucide-react";
3
+ import { DateBadge } from "./DateBadge";
4
+
5
+ export type EventFact = {
6
+ /** A DateBadge, or an icon; "pin" draws the map pin. */
7
+ tile: ReactNode | "pin";
8
+ title: ReactNode;
9
+ subtitle?: ReactNode;
10
+ /** The title becomes a link (the venue's map page), with the arrow. */
11
+ href?: string;
12
+ external?: boolean;
13
+ };
14
+
15
+ type Props = {
16
+ facts: EventFact[];
17
+ linkComponent?: ElementType;
18
+ newTabLabel?: string;
19
+ className?: string;
20
+ };
21
+
22
+ const ArrowUpRight = () => (
23
+ <svg aria-hidden="true" viewBox="0 0 24 24" width={16} height={16} className="cg-arrow-go" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
24
+ );
25
+
26
+ /** The facts of an event as tiles (Luma): the date on a calendar tile with
27
+ * the written date beside it, the place on a pin tile with the venue (a link)
28
+ * and the city. `DateBadge` is exported for the date tile. */
29
+ export function EventFacts({ facts, linkComponent, newTabLabel = "(opens in a new tab)", className = "" }: Props) {
30
+ const L: ElementType = linkComponent ?? "a";
31
+ return (
32
+ <ul className={`flex list-none flex-col gap-4 ${className}`}>
33
+ {facts.map((f, i) => (
34
+ <li key={i} className="flex items-center gap-4">
35
+ {f.tile === "pin" ? (
36
+ <span aria-hidden="true" className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-ink/[0.06] text-ink">
37
+ <MapPin size={20} strokeWidth={2} />
38
+ </span>
39
+ ) : (
40
+ f.tile
41
+ )}
42
+ <span className="min-w-0 leading-snug">
43
+ <span className="block font-semibold text-ink">
44
+ {f.href ? (
45
+ <L href={f.href} {...(f.external ? { target: "_blank", rel: "noopener noreferrer" } : {})} className="inline-flex items-center gap-1 underline decoration-transparent underline-offset-4 transition-[text-decoration-color] duration-150 hover:decoration-ink focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none">
46
+ {f.title}
47
+ <ArrowUpRight />
48
+ {f.external && <span className="sr-only"> {newTabLabel}</span>}
49
+ </L>
50
+ ) : (
51
+ f.title
52
+ )}
53
+ </span>
54
+ {f.subtitle && <span className="mt-0.5 block text-sm text-ink-body">{f.subtitle}</span>}
55
+ </span>
56
+ </li>
57
+ ))}
58
+ </ul>
59
+ );
60
+ }
61
+
62
+ export { DateBadge };
@@ -0,0 +1,49 @@
1
+ import type { ReactNode } from "react";
2
+ import { EventRow, type EventRowProps } from "./EventRow";
3
+
4
+ export type EventGroup = {
5
+ /** The day ("6 Nov"). */
6
+ date: ReactNode;
7
+ /** The weekday, one tone below ("Friday"). */
8
+ weekday?: ReactNode;
9
+ /** ISO date for the <time>. */
10
+ iso?: string;
11
+ events: EventRowProps[];
12
+ };
13
+
14
+ type Props = {
15
+ groups: EventGroup[];
16
+ /** aria-label of the list. */
17
+ label?: string;
18
+ className?: string;
19
+ };
20
+
21
+ /**
22
+ * Events grouped by day, down a dotted rail (Luma): each day has a dot on the
23
+ * rail, the date and the weekday, and its rows below. One <ol> per day keeps
24
+ * the order for the screen reader.
25
+ */
26
+ export function EventList({ groups, label = "Events", className = "" }: Props) {
27
+ return (
28
+ <div aria-label={label} role="list" className={`relative ${className}`}>
29
+ {/* the rail: a dotted line down the left */}
30
+ <span aria-hidden="true" className="absolute bottom-6 left-[5px] top-3 w-px border-l-2 border-dotted border-ink/15" />
31
+ {groups.map((g, i) => (
32
+ <section key={i} role="listitem" className="relative pl-9 pb-8 last:pb-0">
33
+ <span aria-hidden="true" className="absolute left-0 top-[7px] h-3 w-3 rounded-full bg-ink/20" />
34
+ <h2 className="font-heading text-lg font-semibold text-ink">
35
+ {g.iso ? <time dateTime={g.iso}>{g.date}</time> : g.date}
36
+ {g.weekday && <span className="ml-2 font-body text-base font-normal text-ink-soft">{g.weekday}</span>}
37
+ </h2>
38
+ <ol className="mt-4 flex list-none flex-col gap-3">
39
+ {g.events.map((e, j) => (
40
+ <li key={j}>
41
+ <EventRow {...e} />
42
+ </li>
43
+ ))}
44
+ </ol>
45
+ </section>
46
+ ))}
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,102 @@
1
+ import type { ElementType, ReactNode } from "react";
2
+ import { MapPin } from "lucide-react";
3
+ import { Avatar } from "./Avatar";
4
+ import type { Person } from "./Byline";
5
+
6
+ type Labels = {
7
+ /** "+34" pill after the stacked portraits. */
8
+ more: (n: number) => string;
9
+ /** Prefix of the organizer line ("By"). */
10
+ by: ReactNode;
11
+ };
12
+ const EN: Labels = { more: (n) => `+${n}`, by: "By" };
13
+
14
+ export type EventRowProps = {
15
+ /** The time line ("09:00 · 12:00 GMT+0", or "3 to 9 Nov · 08:30–17:30"). */
16
+ time: ReactNode;
17
+ /** The end of the time line, in the soft action tone (Luma shows the end
18
+ * time this way). Optional. */
19
+ timeAccent?: ReactNode;
20
+ title: ReactNode;
21
+ href: string;
22
+ /** Who organizes or teaches: small portrait(s) and the name(s). */
23
+ organizers?: Person[];
24
+ location?: ReactNode;
25
+ /** Who is going (or who teaches, on a course): stacked portraits and "+N". */
26
+ people?: Person[];
27
+ /** How many beyond the portraits shown. */
28
+ peopleMore?: number;
29
+ /** Square cover on the right (1:1). */
30
+ cover?: { src: string; alt?: string };
31
+ /** A small state on the title line ("Sold out", "Online"). */
32
+ badge?: ReactNode;
33
+ linkComponent?: ElementType;
34
+ labels?: Partial<Labels>;
35
+ className?: string;
36
+ };
37
+
38
+ /**
39
+ * Event row of the Cuidado Global family (after Luma and Meetup): the time
40
+ * line, the title (the link, stretched over the whole row), who organizes,
41
+ * where, who is going, and the square cover on the right. Lives in an
42
+ * EventList, under a date heading.
43
+ */
44
+ export function EventRow({ time, timeAccent, title, href, organizers, location, people, peopleMore, cover, badge, linkComponent, labels, className = "" }: EventRowProps) {
45
+ const L: ElementType = linkComponent ?? "a";
46
+ const t = { ...EN, ...labels };
47
+ const shown = people?.slice(0, 4) ?? [];
48
+ return (
49
+ <article className={`group relative flex gap-5 rounded-[24px] bg-card p-5 transition-transform duration-200 ease-out active:scale-[0.99] motion-reduce:transition-none motion-reduce:active:scale-100 md:p-6 ${className}`}>
50
+ <div className="flex min-w-0 flex-1 flex-col">
51
+ <p className="text-sm font-medium text-ink-soft">
52
+ {time}
53
+ {timeAccent && (
54
+ <>
55
+ <span aria-hidden="true"> · </span>
56
+ <span className="font-semibold text-action-text">{timeAccent}</span>
57
+ </>
58
+ )}
59
+ </p>
60
+ <h3 className="mt-1.5 font-heading text-xl font-medium leading-snug text-ink md:text-[1.375rem]">
61
+ <L href={href} className="after:absolute after:inset-0 after:rounded-[24px] focus-visible:outline-none focus-visible:after:outline focus-visible:after:outline-2 focus-visible:after:-outline-offset-2 focus-visible:after:outline-ink">
62
+ {title}
63
+ </L>
64
+ {badge && <span className="ml-2 inline-flex translate-y-[-2px] items-center rounded-pill bg-ink/[0.08] px-2.5 py-0.5 align-middle text-xs font-semibold text-ink">{badge}</span>}
65
+ </h3>
66
+ {organizers && organizers.length > 0 && (
67
+ <p className="mt-2 flex items-center gap-2 text-sm text-ink-body">
68
+ <span className="flex -space-x-1.5">
69
+ {organizers.slice(0, 3).map((p) => (
70
+ <Avatar key={p.name} name={p.name} photo={p.photo} size={20} ring>{p.avatar}</Avatar>
71
+ ))}
72
+ </span>
73
+ <span className="truncate">{t.by} {organizers.map((p) => p.name).join(", ")}</span>
74
+ </p>
75
+ )}
76
+ {location && (
77
+ <p className="mt-1.5 flex items-center gap-2 text-sm text-ink-body">
78
+ <MapPin aria-hidden="true" size={16} strokeWidth={2} className="shrink-0 text-ink-soft" />
79
+ <span className="truncate">{location}</span>
80
+ </p>
81
+ )}
82
+ {shown.length > 0 && (
83
+ <p className="mt-auto flex items-center gap-2 pt-4">
84
+ <span className="flex -space-x-2">
85
+ {shown.map((p) => (
86
+ <Avatar key={p.name} name={p.name} photo={p.photo} size={28} ring>{p.avatar}</Avatar>
87
+ ))}
88
+ </span>
89
+ {peopleMore ? <span className="rounded-pill bg-ink/[0.08] px-2 py-0.5 text-xs font-semibold text-ink">{t.more(peopleMore)}</span> : null}
90
+ <span className="sr-only">{shown.map((p) => p.name).join(", ")}</span>
91
+ </p>
92
+ )}
93
+ </div>
94
+ {cover && (
95
+ <div className="relative h-24 w-24 shrink-0 overflow-hidden rounded-2xl bg-ink/[0.06] md:h-36 md:w-36">
96
+ {/* eslint-disable-next-line @next/next/no-img-element */}
97
+ <img src={cover.src} alt={cover.alt ?? ""} loading="lazy" decoding="async" className="absolute inset-0 h-full w-full object-cover" />
98
+ </div>
99
+ )}
100
+ </article>
101
+ );
102
+ }
@@ -0,0 +1,80 @@
1
+ import type { ElementType, ReactNode } from "react";
2
+ import { Byline, type Person } from "./Byline";
3
+ import type { PostCover } from "./PostCard";
4
+
5
+ export type FeaturedPost = {
6
+ cover: PostCover;
7
+ title: ReactNode;
8
+ /** The post's own page. */
9
+ href: string;
10
+ text: ReactNode;
11
+ /** Who said it (name, portrait): the text is their words, only cut. */
12
+ speaker: Person;
13
+ /** Link to the video the text came from ("Watch the video ↗"). */
14
+ video?: { href: string; label?: ReactNode; external?: boolean };
15
+ };
16
+
17
+ type Props = {
18
+ posts: FeaturedPost[];
19
+ headingLevel?: "h2" | "h3";
20
+ /** Default label of the video link. */
21
+ watchLabel?: ReactNode;
22
+ newTabLabel?: string;
23
+ linkComponent?: ElementType;
24
+ className?: string;
25
+ };
26
+
27
+ /**
28
+ * Featured posts of the Cuidado Global family — creeta's `Temas` ("in the
29
+ * words of who treats you"): what the team explains in the videos, as text.
30
+ * Each card: the cover above, the title and the text, and at the foot who
31
+ * said it (with the portrait) and the link to the video, because the text is
32
+ * their speech, only with cuts. Bento: the first post takes two columns with
33
+ * the cover beside the text; if the last would sit alone in its row, it too.
34
+ */
35
+ export function FeaturedPosts({ posts, headingLevel: Title = "h3", watchLabel = "Watch the video", newTabLabel, linkComponent, className = "" }: Props) {
36
+ const L: ElementType = linkComponent ?? "a";
37
+ return (
38
+ <ul className={`grid list-none gap-3 md:grid-cols-2 md:gap-5 ${className}`}>
39
+ {posts.map((p, i) => {
40
+ const wide = i === 0 || (i === posts.length - 1 && (posts.length - 1) % 2 === 1);
41
+ return (
42
+ <li key={i} className={`flex flex-col overflow-hidden rounded-[28px] bg-card ${wide ? "md:col-span-2 md:grid md:grid-cols-5" : ""}`}>
43
+ {/* cover: decoration (4:3); the title says the subject */}
44
+ <div className={`relative overflow-hidden ${wide ? "aspect-[4/3] md:col-span-2 md:aspect-auto md:min-h-[320px]" : "aspect-[4/3]"}`}>
45
+ {/* eslint-disable-next-line @next/next/no-img-element */}
46
+ <img
47
+ src={p.cover.src}
48
+ srcSet={p.cover.srcSet}
49
+ sizes={p.cover.sizes ?? (wide ? "(min-width: 1152px) 440px, (min-width: 768px) 40vw, 92vw" : "(min-width: 1152px) 540px, (min-width: 768px) 46vw, 92vw")}
50
+ alt=""
51
+ loading="lazy"
52
+ decoding="async"
53
+ className="absolute inset-0 h-full w-full object-cover object-[50%_30%]"
54
+ />
55
+ </div>
56
+
57
+ <div className={`flex flex-1 flex-col p-6 md:p-8 ${wide ? "md:col-span-3 md:justify-center" : ""}`}>
58
+ <Title className="font-heading text-xl font-medium leading-snug text-ink md:text-[1.375rem]">
59
+ <L
60
+ href={p.href}
61
+ className="underline decoration-transparent underline-offset-4 transition-[text-decoration-color] duration-150 hover:decoration-ink focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ink motion-reduce:transition-none"
62
+ >
63
+ {p.title}
64
+ </L>
65
+ </Title>
66
+ <p className="mt-3 leading-relaxed text-ink-body">{p.text}</p>
67
+ <Byline
68
+ className="mt-auto pt-6"
69
+ people={[p.speaker]}
70
+ action={p.video ? { label: p.video.label ?? watchLabel, href: p.video.href, external: p.video.external } : undefined}
71
+ newTabLabel={newTabLabel}
72
+ linkComponent={linkComponent}
73
+ />
74
+ </div>
75
+ </li>
76
+ );
77
+ })}
78
+ </ul>
79
+ );
80
+ }
@@ -6,6 +6,8 @@ import { List } from "./List";
6
6
  type IconType = ComponentType<{ size?: number; strokeWidth?: number; className?: string; "aria-hidden"?: boolean | "true" }>;
7
7
 
8
8
  export type FooterLink = { href: string; label: ReactNode };
9
+ /** A titled column of links, for footers with many pages ("Foundations", "Cards…"). */
10
+ export type FooterLinkGroup = { title: ReactNode; links: FooterLink[] };
9
11
  export type FooterContact = { href: string; label: ReactNode; Icon: IconType; external?: boolean };
10
12
  export type FooterLanguage = Language;
11
13
 
@@ -29,8 +31,11 @@ type Props = {
29
31
  /** The brand sentence, shown in quotes under the logo. */
30
32
  quote?: ReactNode;
31
33
  /** "Pages": title on the first line, links in two columns below. Optional:
32
- * a footer with no internal links drops the column (two-column grid). */
33
- pages?: { title: ReactNode; links: FooterLink[] };
34
+ * a footer with no internal links drops the column (two-column grid).
35
+ * Many links (a dozen or more): pass `groups` instead of `links` — each
36
+ * group is a column with a small heading, up to four side by side, so the
37
+ * list gets wider instead of taller (the 44px rows stay). */
38
+ pages?: { title: ReactNode; links?: FooterLink[]; groups?: FooterLinkGroup[] };
34
39
  /** "Where we are": address lines, opening hours, and the directions links
35
40
  * ("Directions ↗ · Waze ↗", external). */
36
41
  where: { title: ReactNode; address: ReactNode[]; hours?: ReactNode; directions?: FooterLink[] };
@@ -59,6 +64,14 @@ const ArrowUpRight = () => (
59
64
  );
60
65
 
61
66
  const title = "font-heading text-lg font-medium text-ink";
67
+ const groupTitle = "text-xs font-semibold uppercase tracking-[0.12em] text-ink-soft";
68
+ // whole class names: as many columns as groups, up to four
69
+ const groupCols: Record<number, string> = {
70
+ 1: "grid-cols-1",
71
+ 2: "grid-cols-2",
72
+ 3: "grid-cols-2 lg:grid-cols-3",
73
+ 4: "grid-cols-2 lg:grid-cols-4",
74
+ };
62
75
  const link =
63
76
  "flex min-h-11 items-center text-ink/80 underline decoration-transparent underline-offset-4 transition-[color,text-decoration-color] duration-150 hover:text-ink hover:decoration-ink motion-reduce:transition-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink";
64
77
  const strongLink =
@@ -100,12 +113,19 @@ export function Footer({
100
113
  // padding leaves room for the floating bar.
101
114
  const outer = navHiddenFrom === "md" ? "mt-16 pb-28 md:mt-24 md:pb-14" : "mt-16 pb-28 lg:mt-24 lg:pb-14";
102
115
  const noticeId = notice?.id ?? "footer-notice";
116
+ const groups = pages?.groups?.length ? pages.groups : undefined;
117
+ // grouped pages need the room: the pages column grows and the brand column shrinks
118
+ const columns = !pages
119
+ ? "md:grid-cols-[minmax(0,1fr)_auto]"
120
+ : groups
121
+ ? "md:grid-cols-[minmax(0,3.5fr)_minmax(0,6.5fr)_minmax(0,3fr)]"
122
+ : "md:grid-cols-[minmax(0,4fr)_minmax(0,5fr)_minmax(0,3fr)]";
103
123
 
104
124
  return (
105
125
  <footer className={`${outer} bg-card py-10 text-ink md:py-14 ${className}`}>
106
126
  <div className="mx-auto max-w-[1600px] px-4 md:px-6">
107
127
  {/* without the pages column, "where" keeps to the right edge (auto width) */}
108
- <div className={`grid gap-x-10 gap-y-10 ${pages ? "md:grid-cols-[minmax(0,4fr)_minmax(0,5fr)_minmax(0,3fr)]" : "md:grid-cols-[minmax(0,1fr)_auto]"}`}>
128
+ <div className={`grid gap-x-10 gap-y-10 ${columns}`}>
109
129
  <div>
110
130
  {logo}
111
131
  {brandName && <p className="sr-only">{brandName}</p>}
@@ -117,13 +137,30 @@ export function Footer({
117
137
  {pages && (
118
138
  <nav aria-label={t.pagesNav}>
119
139
  <h2 className={title}>{pages.title}</h2>
120
- <div className="mt-3 grid gap-x-6 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2">
121
- {pages.links.map((p, i) => (
122
- <L key={`${i}-${p.href}`} href={p.href} className={link}>
123
- {p.label}
124
- </L>
125
- ))}
126
- </div>
140
+ {groups ? (
141
+ <div className={`mt-5 grid gap-x-6 gap-y-8 ${groupCols[Math.min(groups.length, 4)]}`}>
142
+ {groups.map((g, gi) => (
143
+ <div key={gi}>
144
+ <h3 className={groupTitle}>{g.title}</h3>
145
+ <div className="mt-2">
146
+ {g.links.map((p, i) => (
147
+ <L key={`${i}-${p.href}`} href={p.href} className={link}>
148
+ {p.label}
149
+ </L>
150
+ ))}
151
+ </div>
152
+ </div>
153
+ ))}
154
+ </div>
155
+ ) : (
156
+ <div className="mt-3 grid gap-x-6 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2">
157
+ {(pages.links ?? []).map((p, i) => (
158
+ <L key={`${i}-${p.href}`} href={p.href} className={link}>
159
+ {p.label}
160
+ </L>
161
+ ))}
162
+ </div>
163
+ )}
127
164
  </nav>
128
165
  )}
129
166
 
@@ -112,7 +112,9 @@ function Dropdown({ item, L, t }: { item: NavItem; L: ElementType; t: Labels })
112
112
  <L
113
113
  href={c.href}
114
114
  onClick={() => setOpen(false)}
115
- className="flex items-start gap-3 rounded-xl px-2.5 py-2 transition-colors hover:bg-ink/[0.05] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink"
115
+ // with a description the icon sits with the label's first line (creeta);
116
+ // label only: centred on the bubble, and a two-line label stays tight
117
+ className={`flex ${c.description ? "items-start" : "items-center"} gap-3 rounded-xl px-2.5 py-2 transition-colors hover:bg-ink/[0.05] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink`}
116
118
  >
117
119
  {c.icon && (
118
120
  <span className="flex h-9 w-9 flex-none items-center justify-center rounded-full bg-ink/[0.08] text-ink">
@@ -120,7 +122,7 @@ function Dropdown({ item, L, t }: { item: NavItem; L: ElementType; t: Labels })
120
122
  </span>
121
123
  )}
122
124
  <span>
123
- <span className="block font-heading text-[0.95rem] font-semibold text-ink">
125
+ <span className="block font-heading text-[0.95rem] leading-snug font-semibold text-ink">
124
126
  {c.label}
125
127
  </span>
126
128
  {c.description && (
@@ -17,20 +17,33 @@ type LoopPreview = {
17
17
 
18
18
  /** Two kinds of video hero:
19
19
  * - loop: a local clip on repeat (no player). Background = BackgroundVideo.
20
- * - player: the YouTube video ITSELF plays muted, on repeat, as the background
21
- * (16:9, covering the card, no false crop/zoom). Turning the sound on
22
- * remounts it from zero — with audio and controls — and the close bar
23
- * appears at the bottom. Keeps things consistent: the preview and the
24
- * video are the same footage. */
20
+ * - player: the video ITSELF plays muted, on repeat, as the background.
21
+ * Turning the sound on remounts it from zero — with audio and controls —
22
+ * and the close bar appears at the bottom. Keeps things consistent: the
23
+ * preview and the video are the same footage. The source is either a
24
+ * YouTube id (16:9 iframe covering the card, no false crop/zoom) or an
25
+ * mp4 hosted by the site (`src` + `poster`; the sound version plays in
26
+ * the browser's own player, letterboxed like the iframe). */
25
27
  type VideoLoop = { mode: "loop"; preview: LoopPreview };
26
- type VideoPlayer = {
27
- mode: "player";
28
+ type PlayerCommon = { mode: "player"; playerCaption?: ReactNode };
29
+ type VideoPlayerYouTube = PlayerCommon & {
28
30
  youtubeId: string;
29
31
  /** Still frame (reduced motion / data saver). Without it, uses YouTube's own
30
32
  * thumbnail (maxresdefault). */
31
33
  poster?: string;
32
- playerCaption?: ReactNode;
33
34
  };
35
+ type VideoPlayerFile = PlayerCommon & {
36
+ /** The mp4 itself: muted and looping as the background, with sound after the tap. */
37
+ src: string;
38
+ /** Larger file for wide screens (background only). */
39
+ srcLarge?: string;
40
+ /** Still frame (reduced motion / data saver, and the <video> poster). */
41
+ poster: string;
42
+ posterLarge?: string;
43
+ objectPosition?: string;
44
+ };
45
+ type VideoPlayer = VideoPlayerYouTube | VideoPlayerFile;
46
+ const isFile = (v: VideoPlayer): v is VideoPlayerFile => "src" in v;
34
47
 
35
48
  type Props = {
36
49
  /** Video background — defines the hero kind (loop or player). */
@@ -144,10 +157,10 @@ function YouTubeBackground({
144
157
  * in the Breathe language (tokens: ink/surface).
145
158
  *
146
159
  * - loop: the background is a local clip on repeat (BackgroundVideo).
147
- * - player: the background is the YouTube video ITSELF playing muted, on repeat.
148
- * Turning the sound on remounts it from zero (with audio and controls)
149
- * inside the card and opens the close bar at the bottom (caption on
150
- * the left, "Close" on the right).
160
+ * - player: the background is the video ITSELF (YouTube id or the site's mp4)
161
+ * playing muted, on repeat. Turning the sound on remounts it from zero
162
+ * (with audio and controls) inside the card and opens the close bar at
163
+ * the bottom (caption on the left, "Close" on the right).
151
164
  */
152
165
  export function PageVideoHero({
153
166
  video,
@@ -179,20 +192,49 @@ export function PageVideoHero({
179
192
  {/* media — background (preview) or, when playing, the full video with sound */}
180
193
  <div className={`absolute inset-x-0 top-0 ${playing ? "bottom-[4.5rem]" : "bottom-0 -z-10"}`}>
181
194
  {playing ? (
182
- <iframe
183
- key="cg-hero-sound"
184
- title={videoTitle ?? "Video"}
185
- src={`https://www.youtube-nocookie.com/embed/${(video as VideoPlayer).youtubeId}?autoplay=1&mute=0&start=0&rel=0&modestbranding=1&playsinline=1&iv_load_policy=3`}
186
- allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture; fullscreen"
187
- allowFullScreen
188
- className="absolute inset-0 h-full w-full border-0 bg-black"
189
- />
195
+ isFile(video as VideoPlayer) ? (
196
+ // the site's own mp4, with sound and the browser's controls; the
197
+ // click came from the person, so autoplay with audio is allowed
198
+ <video
199
+ key="cg-hero-sound"
200
+ src={(video as VideoPlayerFile).src}
201
+ poster={(video as VideoPlayerFile).poster}
202
+ controls
203
+ autoPlay
204
+ playsInline
205
+ aria-label={videoTitle ?? "Video"}
206
+ onEnded={() => setPlaying(false)}
207
+ className="absolute inset-0 h-full w-full bg-black object-contain"
208
+ />
209
+ ) : (
210
+ <iframe
211
+ key="cg-hero-sound"
212
+ title={videoTitle ?? "Video"}
213
+ src={`https://www.youtube-nocookie.com/embed/${(video as VideoPlayerYouTube).youtubeId}?autoplay=1&mute=0&start=0&rel=0&modestbranding=1&playsinline=1&iv_load_policy=3`}
214
+ allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture; fullscreen"
215
+ allowFullScreen
216
+ className="absolute inset-0 h-full w-full border-0 bg-black"
217
+ />
218
+ )
190
219
  ) : (
191
220
  <>
192
- {isPlayer ? (
221
+ {isPlayer && isFile(video as VideoPlayer) ? (
222
+ // the mp4 itself, muted and on repeat (BackgroundVideo: reduced
223
+ // motion, data saver and the pause button come with it)
224
+ <BackgroundVideo
225
+ key={(video as VideoPlayerFile).src}
226
+ src={(video as VideoPlayerFile).src}
227
+ srcLarge={(video as VideoPlayerFile).srcLarge}
228
+ poster={(video as VideoPlayerFile).poster}
229
+ posterLarge={(video as VideoPlayerFile).posterLarge}
230
+ objectPosition={(video as VideoPlayerFile).objectPosition}
231
+ labels={previewLabels}
232
+ className="absolute inset-0"
233
+ />
234
+ ) : isPlayer ? (
193
235
  <YouTubeBackground
194
- id={(video as VideoPlayer).youtubeId}
195
- poster={(video as VideoPlayer).poster}
236
+ id={(video as VideoPlayerYouTube).youtubeId}
237
+ poster={(video as VideoPlayerYouTube).poster}
196
238
  title={videoTitle}
197
239
  labels={previewLabels}
198
240
  />
@@ -0,0 +1,41 @@
1
+ import { Pause, Play } from "lucide-react";
2
+
3
+ type Labels = {
4
+ /** sr-only name: "Pause {what}" / "Resume {what}". */
5
+ pause: (what: string) => string;
6
+ resume: (what: string) => string;
7
+ };
8
+ const EN: Labels = { pause: (what) => `Pause ${what}`, resume: (what) => `Resume ${what}` };
9
+
10
+ type Props = {
11
+ paused: boolean;
12
+ onToggle: () => void;
13
+ /** What it pauses, in the page's language: "the sample conversation". */
14
+ what: string;
15
+ /** Colour of the circle: card (white) over the surface, surface over a card. */
16
+ bubble?: "card" | "surface";
17
+ /** Position (absolute) inside the drawing: "right-0 top-0". */
18
+ className?: string;
19
+ labels?: Partial<Labels>;
20
+ };
21
+
22
+ /** Pause/resume button of the looping drawings (creeta's `Pausa`). What moves
23
+ * by itself for more than 5s must be pausable (WCAG 2.2.2). 44px tap area,
24
+ * 32px circle. */
25
+ export function PauseButton({ paused, onToggle, what, bubble = "card", className = "", labels }: Props) {
26
+ const t = { ...EN, ...labels };
27
+ const Icon = paused ? Play : Pause;
28
+ return (
29
+ <button
30
+ type="button"
31
+ onClick={onToggle}
32
+ aria-pressed={paused}
33
+ className={`absolute flex h-11 w-11 items-center justify-center rounded-full text-ink transition-transform duration-150 ease-out active:scale-[0.94] focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-ink motion-reduce:transition-none motion-reduce:active:scale-100 ${className}`}
34
+ >
35
+ <span className={`flex h-8 w-8 items-center justify-center rounded-full ${bubble === "card" ? "bg-card" : "bg-surface"}`}>
36
+ <Icon aria-hidden="true" size={14} strokeWidth={2} className={paused ? "ml-0.5" : ""} />
37
+ </span>
38
+ <span className="sr-only">{paused ? t.resume(what) : t.pause(what)}</span>
39
+ </button>
40
+ );
41
+ }