@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.
- package/README.md +88 -7
- package/components/AnchorNav.tsx +12 -4
- package/components/Button.tsx +11 -4
- package/components/Byline.tsx +3 -1
- package/components/CareSteps.tsx +86 -0
- package/components/ChatPreview.tsx +138 -0
- package/components/CourseCard.tsx +139 -0
- package/components/DateBadge.tsx +24 -0
- package/components/EventFacts.tsx +62 -0
- package/components/EventList.tsx +49 -0
- package/components/EventRow.tsx +102 -0
- package/components/FeaturedPosts.tsx +80 -0
- package/components/Footer.tsx +47 -10
- package/components/Header.tsx +4 -2
- package/components/PageVideoHero.tsx +65 -23
- package/components/PauseButton.tsx +41 -0
- package/components/PhotoSequence.tsx +99 -0
- package/components/PlanTimeline.tsx +66 -0
- package/components/PostArticle.tsx +102 -0
- package/components/PostCard.tsx +69 -0
- package/components/ProfessionalCard.tsx +155 -50
- package/components/Rail.tsx +94 -0
- package/components/RegistrationBox.tsx +82 -0
- package/components/Rows.tsx +9 -3
- package/components/SidePanel.tsx +96 -0
- package/components/StoryPanel.tsx +107 -0
- package/components/useOnAppear.ts +39 -0
- package/package.json +36 -6
- package/theme.css +140 -0
- package/tokens/creeta.css +2 -0
- package/tokens/neutral.css +2 -0
- package/tokens/ttcab.css +13 -5
|
@@ -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
|
+
}
|
package/components/Footer.tsx
CHANGED
|
@@ -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
|
-
|
|
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 ${
|
|
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
|
-
|
|
121
|
-
{
|
|
122
|
-
|
|
123
|
-
{
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
package/components/Header.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
27
|
-
|
|
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
|
|
148
|
-
* Turning the sound on remounts it from zero
|
|
149
|
-
* inside the card and opens the close bar at
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
195
|
-
poster={(video as
|
|
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
|
+
}
|