@breathe-ds/ui 0.1.1 → 0.2.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.
- package/LICENSE +21 -0
- package/README.md +157 -142
- package/components/Accordion.tsx +11 -10
- package/components/AnchorNav.tsx +33 -21
- package/components/BackgroundVideo.tsx +14 -14
- package/components/BottomNav.tsx +69 -68
- package/components/BreatheMark.tsx +12 -11
- package/components/Button.tsx +12 -12
- package/components/Card.tsx +5 -5
- package/components/Container.tsx +4 -4
- package/components/Curtain.tsx +152 -0
- package/components/Footer.tsx +186 -77
- package/components/Header.tsx +32 -17
- package/components/LanguageSwitcher.tsx +139 -0
- package/components/Lockup.tsx +40 -39
- package/components/PageVideoHero.tsx +53 -53
- package/components/PillList.tsx +4 -4
- package/components/SectionKicker.tsx +3 -3
- package/components/icons.tsx +6 -6
- package/components/marks/creeta.tsx +22 -22
- package/components/marks/ttcab.tsx +13 -13
- package/package.json +7 -5
- package/theme.css +21 -21
- package/tokens/creeta.css +10 -11
- package/tokens/neutral.css +7 -7
- package/tokens/ttcab.css +8 -8
- package/components/Cortina.tsx +0 -152
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useId, useRef, useState, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
export type Language = {
|
|
6
|
+
/** BCP 47 code for `lang`/`hrefLang` (pt-BR, en, es). */
|
|
7
|
+
lang: string;
|
|
8
|
+
/** The language written in itself ("Português", "English", "Español"). */
|
|
9
|
+
label: ReactNode;
|
|
10
|
+
/** The same page in that language. */
|
|
11
|
+
href: string;
|
|
12
|
+
current?: boolean;
|
|
13
|
+
/** Short code shown on the menu button; default: the first part of `lang`, uppercase (PT). */
|
|
14
|
+
code?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type Labels = {
|
|
18
|
+
/** aria-label of the nav */
|
|
19
|
+
nav: string;
|
|
20
|
+
/** Accessible name of the menu button. Starts with the code written on it
|
|
21
|
+
* ("PT"): voice-control users say what they see (WCAG 2.5.3). */
|
|
22
|
+
change: (code: string, current: string) => string;
|
|
23
|
+
};
|
|
24
|
+
const EN: Labels = { nav: "Language", change: (code, current) => `${code}. Change language. Current: ${current}` };
|
|
25
|
+
|
|
26
|
+
type Props = {
|
|
27
|
+
languages: Language[];
|
|
28
|
+
/** `menu`: button with the current code that opens the list (header).
|
|
29
|
+
* `list`: the languages side by side, no JavaScript (footer). */
|
|
30
|
+
mode?: "menu" | "list";
|
|
31
|
+
labels?: Partial<Labels>;
|
|
32
|
+
className?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const Globe = () => (
|
|
36
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={16} height={16} className="max-[359px]:hidden md:max-lg:hidden" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20M2 12h20" /></svg>
|
|
37
|
+
);
|
|
38
|
+
const Check = ({ size = 16 }: { size?: number }) => (
|
|
39
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const codeOf = (l: Language) => l.code ?? l.lang.split("-")[0].toUpperCase();
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Language switcher — the SeletorDeIdioma of novo.creeta, by tokens.
|
|
46
|
+
* - Leads to the SAME page in the other language, never to the home.
|
|
47
|
+
* - Each language is written in itself ("English", "Español"): that is how
|
|
48
|
+
* the reader recognizes theirs. The button shows the code (PT, EN, ES).
|
|
49
|
+
* - The current one is marked by a ✓ and `aria-current`, never by color alone.
|
|
50
|
+
* - Plain links (<a>): switching reloads the page, each language has its own
|
|
51
|
+
* document. Closed, the menu stays in the HTML: the links exist for crawlers
|
|
52
|
+
* and agents even if nobody opens it.
|
|
53
|
+
* - The site never redirects by browser language: the choice is the reader's.
|
|
54
|
+
*/
|
|
55
|
+
export function LanguageSwitcher({ languages, mode = "menu", labels, className = "" }: Props) {
|
|
56
|
+
const t = { ...EN, ...labels };
|
|
57
|
+
const [open, setOpen] = useState(false);
|
|
58
|
+
const box = useRef<HTMLDivElement>(null);
|
|
59
|
+
const button = useRef<HTMLButtonElement>(null);
|
|
60
|
+
const id = useId();
|
|
61
|
+
const current = languages.find((l) => l.current) ?? languages[0];
|
|
62
|
+
|
|
63
|
+
if (mode === "list") {
|
|
64
|
+
return (
|
|
65
|
+
<nav aria-label={t.nav} className={className}>
|
|
66
|
+
<ul className="flex list-none flex-wrap items-center gap-x-1 gap-y-0">
|
|
67
|
+
{languages.map((l) => (
|
|
68
|
+
<li key={l.lang}>
|
|
69
|
+
<a
|
|
70
|
+
href={l.href}
|
|
71
|
+
lang={l.lang}
|
|
72
|
+
hrefLang={l.lang}
|
|
73
|
+
aria-current={l.current ? "true" : undefined}
|
|
74
|
+
className={`inline-flex min-h-11 items-center gap-1.5 rounded-full px-3 text-sm ${
|
|
75
|
+
l.current ? "font-semibold text-ink" : "text-ink/80 underline underline-offset-4"
|
|
76
|
+
}`}
|
|
77
|
+
>
|
|
78
|
+
{l.current && <Check size={15} />}
|
|
79
|
+
{l.label}
|
|
80
|
+
</a>
|
|
81
|
+
</li>
|
|
82
|
+
))}
|
|
83
|
+
</ul>
|
|
84
|
+
</nav>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div
|
|
90
|
+
ref={box}
|
|
91
|
+
className={`relative ${className}`}
|
|
92
|
+
onBlur={(e) => {
|
|
93
|
+
if (!box.current?.contains(e.relatedTarget as Node | null)) setOpen(false);
|
|
94
|
+
}}
|
|
95
|
+
onKeyDown={(e) => {
|
|
96
|
+
if (e.key === "Escape" && open) {
|
|
97
|
+
setOpen(false);
|
|
98
|
+
button.current?.focus();
|
|
99
|
+
}
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
<button
|
|
103
|
+
ref={button}
|
|
104
|
+
type="button"
|
|
105
|
+
aria-expanded={open}
|
|
106
|
+
aria-controls={id}
|
|
107
|
+
aria-label={t.change(codeOf(current), typeof current.label === "string" ? current.label : codeOf(current))}
|
|
108
|
+
onClick={() => setOpen((o) => !o)}
|
|
109
|
+
className="flex min-h-11 items-center gap-1.5 rounded-full border border-ink/25 px-3 text-[0.8125rem] font-semibold text-ink transition-colors duration-150 hover:bg-ink/5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none"
|
|
110
|
+
>
|
|
111
|
+
{/* between 768 and 1023px the whole menu shares the row with the CTA: the globe goes, only the code stays */}
|
|
112
|
+
<Globe />
|
|
113
|
+
<span aria-hidden="true">{codeOf(current)}</span>
|
|
114
|
+
</button>
|
|
115
|
+
<ul
|
|
116
|
+
id={id}
|
|
117
|
+
className={`absolute right-0 top-full z-50 mt-2 min-w-[11rem] list-none origin-top-right rounded-[20px] bg-white p-1.5 shadow-[0_16px_40px_-16px_rgba(0,0,0,0.35)] transition duration-150 ease-out motion-reduce:transition-none ${
|
|
118
|
+
open ? "visible scale-100 opacity-100" : "invisible scale-95 opacity-0"
|
|
119
|
+
}`}
|
|
120
|
+
>
|
|
121
|
+
{languages.map((l) => (
|
|
122
|
+
<li key={l.lang}>
|
|
123
|
+
<a
|
|
124
|
+
href={l.href}
|
|
125
|
+
lang={l.lang}
|
|
126
|
+
hrefLang={l.lang}
|
|
127
|
+
aria-current={l.current ? "true" : undefined}
|
|
128
|
+
onClick={() => setOpen(false)}
|
|
129
|
+
className="flex min-h-11 items-center justify-between gap-3 rounded-[14px] px-3.5 text-sm font-medium text-ink hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ink"
|
|
130
|
+
>
|
|
131
|
+
{l.label}
|
|
132
|
+
{l.current && <Check />}
|
|
133
|
+
</a>
|
|
134
|
+
</li>
|
|
135
|
+
))}
|
|
136
|
+
</ul>
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
package/components/Lockup.tsx
CHANGED
|
@@ -2,20 +2,20 @@ import type { CSSProperties, ReactNode } from "react";
|
|
|
2
2
|
import { BreatheMark } from "./BreatheMark";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
* - `stacked`
|
|
7
|
-
* - `horizontal`
|
|
8
|
-
* - `wordmark`
|
|
9
|
-
* - `symbol`
|
|
10
|
-
* - `merged`
|
|
5
|
+
* Logo arrangements (flexible branding system of the Cuidado Global family).
|
|
6
|
+
* - `stacked` symbol on top, name below: profiles, splash, poster.
|
|
7
|
+
* - `horizontal` symbol + name side by side (+ tagline): header, documents.
|
|
8
|
+
* - `wordmark` text only (name + tagline): ads, when legibility is essential.
|
|
9
|
+
* - `symbol` symbol only: favicon, app icon, watermark.
|
|
10
|
+
* - `merged` filled symbol with the name inside: avatar, packaging.
|
|
11
11
|
*/
|
|
12
12
|
export type LockupVariant = "stacked" | "horizontal" | "wordmark" | "symbol" | "merged";
|
|
13
13
|
type Size = "sm" | "md" | "lg";
|
|
14
14
|
|
|
15
|
-
/*
|
|
16
|
-
"
|
|
17
|
-
|
|
18
|
-
`md` =
|
|
15
|
+
/* Name in `font-heading`; symbol ~1.3× the name's body size (the ring is open
|
|
16
|
+
and "reads" lighter, so it needs one size up to sit in harmony).
|
|
17
|
+
The BreatheMark box is tight to the glyph: the space between symbol and name is just the gap.
|
|
18
|
+
`md` = the sites' header (44px symbol, measured on creeta and ttcab). */
|
|
19
19
|
const NAME: Record<Size, string> = { sm: "text-2xl", md: "text-[2.125rem]", lg: "text-[2.7rem]" };
|
|
20
20
|
const TAG: Record<Size, string> = { sm: "text-[0.78rem]", md: "text-sm", lg: "text-base" };
|
|
21
21
|
const MARK: Record<Size, number> = { sm: 32, md: 44, lg: 56 };
|
|
@@ -25,37 +25,38 @@ const TILE: Record<Size, string> = { sm: "size-16 text-[0.7rem]", md: "size-24 t
|
|
|
25
25
|
|
|
26
26
|
type Props = {
|
|
27
27
|
variant?: LockupVariant;
|
|
28
|
-
/**
|
|
28
|
+
/** Brand name (each site passes its own). */
|
|
29
29
|
name?: string;
|
|
30
30
|
tagline?: ReactNode;
|
|
31
|
-
/**
|
|
32
|
-
*
|
|
31
|
+
/** Custom symbol; defaults to the BreatheMark (ring + dot). An injected <svg>
|
|
32
|
+
* gets the height of the size (`size`); width follows the aspect ratio. */
|
|
33
33
|
logo?: ReactNode;
|
|
34
|
-
/**
|
|
35
|
-
* (
|
|
34
|
+
/** Drawn wordmark (svg) in place of the text name. Ignored by `merged`
|
|
35
|
+
* (there the name is text). */
|
|
36
36
|
wordmark?: ReactNode;
|
|
37
|
-
/**
|
|
38
|
-
* (
|
|
37
|
+
/** Wordmark height as a fraction of the symbol height, from the brand manual
|
|
38
|
+
* (e.g. `TTCAB_LOCKUP.wordmarkPerSymbol`). Reaches the svg via `--cg-wordmark`. */
|
|
39
39
|
wordmarkRatio?: number;
|
|
40
|
-
/**
|
|
41
|
-
*
|
|
40
|
+
/** Which side the symbol sits on in `horizontal`. Default `left` (creeta and
|
|
41
|
+
* the others); TTCAB is `right` (`TTCAB_LOCKUP.symbolSide`). */
|
|
42
42
|
symbolSide?: "left" | "right";
|
|
43
43
|
size?: Size;
|
|
44
|
-
/**
|
|
44
|
+
/** Does the symbol "breathe"? Default `false`: a header logo should not keep animating. */
|
|
45
45
|
animated?: boolean;
|
|
46
|
-
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
46
|
+
/** Entrance on mount, as in the novo.creeta header: the symbol arrives first,
|
|
47
|
+
* then the name, then the tagline. Respects prefers-reduced-motion (becomes
|
|
48
|
+
* just a short fade). Remount (key) to replay. */
|
|
49
49
|
entrance?: boolean;
|
|
50
|
-
/**
|
|
50
|
+
/** Entrance delay in ms (e.g. to wait for the Curtain to rise). */
|
|
51
51
|
entranceDelay?: number;
|
|
52
52
|
className?: string;
|
|
53
53
|
};
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
* Lockup
|
|
57
|
-
*
|
|
58
|
-
*
|
|
56
|
+
* Lockup: assembles the family logo in one of the arrangements above. Neutral
|
|
57
|
+
* through tokens: name/symbol in `ink`, tagline in `ink-soft`; the actual color
|
|
58
|
+
* comes from each app. Typography is fixed by the family (Quicksand for the
|
|
59
|
+
* name, Figtree for the tagline).
|
|
59
60
|
*/
|
|
60
61
|
export function Lockup({
|
|
61
62
|
variant = "horizontal",
|
|
@@ -73,16 +74,16 @@ export function Lockup({
|
|
|
73
74
|
}: Props) {
|
|
74
75
|
const entranceCls = entrance ? " cg-lockup--entrance" : "";
|
|
75
76
|
const entranceStyle = entrance ? ({ "--cg-entrance-delay": `${entranceDelay}ms` } as CSSProperties) : undefined;
|
|
76
|
-
//
|
|
77
|
-
//
|
|
77
|
+
// In horizontal with a tagline, the symbol spans the whole block (name + tagline),
|
|
78
|
+
// becoming a proportional "bookend", not just the name height.
|
|
78
79
|
const markPx = variant === "horizontal" && tagline && symbolSide !== "right" ? Math.round(NAME_PX[size] * 2.3) : MARK[size];
|
|
79
80
|
const mark = (
|
|
80
81
|
<span className="cg-lockup__mark inline-flex text-ink [&>svg]:h-[var(--mark)] [&>svg]:w-auto" style={{ "--mark": `${markPx}px` } as CSSProperties}>
|
|
81
82
|
{logo ?? <BreatheMark size={markPx} animated={animated} title={name} />}
|
|
82
83
|
</span>
|
|
83
84
|
);
|
|
84
|
-
//
|
|
85
|
-
//
|
|
85
|
+
// The wordmark follows the size's base symbol (not the "bookend" of horizontal
|
|
86
|
+
// with tagline), as in creeta's signed logo: name ≈ 0.26 of the large symbol.
|
|
86
87
|
const nameEl = (
|
|
87
88
|
<span
|
|
88
89
|
className={`cg-lockup__name font-heading font-bold leading-none tracking-[-0.02em] text-ink ${NAME[size]}`}
|
|
@@ -91,8 +92,8 @@ export function Lockup({
|
|
|
91
92
|
{wordmark ?? name}
|
|
92
93
|
</span>
|
|
93
94
|
);
|
|
94
|
-
// `block`:
|
|
95
|
-
//
|
|
95
|
+
// `block`: as an inline item, the parent's line-height (1.5 from body) ruled
|
|
96
|
+
// the line and a two-line tagline came out too loose.
|
|
96
97
|
const tagEl = tagline ? (
|
|
97
98
|
<span className={`cg-lockup__tag block font-body leading-[1.3] text-ink-soft ${TAG[size]}`}>{tagline}</span>
|
|
98
99
|
) : null;
|
|
@@ -129,8 +130,8 @@ export function Lockup({
|
|
|
129
130
|
}
|
|
130
131
|
|
|
131
132
|
if (variant === "merged") {
|
|
132
|
-
//
|
|
133
|
-
//
|
|
133
|
+
// Merged: name inside a filled disc (ink). Avatar / app icon.
|
|
134
|
+
// Short names (creeta, ttcab) fit best; a long name needs a smaller body size.
|
|
134
135
|
return (
|
|
135
136
|
<span
|
|
136
137
|
className={`inline-flex items-center justify-center whitespace-nowrap rounded-full bg-ink px-2 text-center font-heading font-bold leading-none tracking-[-0.01em] text-surface ${TILE[size]} ${className}`}
|
|
@@ -142,8 +143,8 @@ export function Lockup({
|
|
|
142
143
|
);
|
|
143
144
|
}
|
|
144
145
|
|
|
145
|
-
// horizontal (
|
|
146
|
-
//
|
|
146
|
+
// horizontal (default). Symbol on the right (TTCAB): sits next to the name, on
|
|
147
|
+
// the same line; the tagline goes below (otherwise the symbol would end up after the tagline).
|
|
147
148
|
if (symbolSide === "right") {
|
|
148
149
|
return (
|
|
149
150
|
<span className={`inline-flex flex-col items-start${entranceCls} ${className}`} style={entranceStyle}>
|
|
@@ -168,8 +169,8 @@ export function Lockup({
|
|
|
168
169
|
);
|
|
169
170
|
}
|
|
170
171
|
|
|
171
|
-
/*
|
|
172
|
-
|
|
172
|
+
/* Entrance: symbol (scale + slight rotation) → name (slides in from the left) → tagline.
|
|
173
|
+
Same vocabulary as the Curtain; with reduced motion it becomes just a short fade. */
|
|
173
174
|
const ENTRANCE_CSS = `
|
|
174
175
|
.cg-lockup--entrance .cg-lockup__mark{ animation: cg-lockup-mark 500ms var(--ease-cg,cubic-bezier(.2,.8,.2,1)) var(--cg-entrance-delay,0ms) both; }
|
|
175
176
|
.cg-lockup--entrance .cg-lockup__name{ animation: cg-lockup-name 420ms var(--ease-cg,cubic-bezier(.2,.8,.2,1)) calc(var(--cg-entrance-delay,0ms) + 420ms) both; }
|
|
@@ -5,7 +5,7 @@ import { BackgroundVideo } from "./BackgroundVideo";
|
|
|
5
5
|
|
|
6
6
|
type Panel = { kicker?: ReactNode; title: ReactNode; lead?: ReactNode };
|
|
7
7
|
|
|
8
|
-
/**
|
|
8
|
+
/** Local background clip (loop mode): muted, looping. */
|
|
9
9
|
type LoopPreview = {
|
|
10
10
|
src: string;
|
|
11
11
|
srcLarge?: string;
|
|
@@ -15,40 +15,40 @@ type LoopPreview = {
|
|
|
15
15
|
objectPosition?: string;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
* - loop:
|
|
20
|
-
* - player:
|
|
21
|
-
* (16:9,
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
18
|
+
/** Two kinds of video hero:
|
|
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. */
|
|
25
25
|
type VideoLoop = { mode: "loop"; preview: LoopPreview };
|
|
26
26
|
type VideoPlayer = {
|
|
27
27
|
mode: "player";
|
|
28
28
|
youtubeId: string;
|
|
29
|
-
/**
|
|
30
|
-
*
|
|
29
|
+
/** Still frame (reduced motion / data saver). Without it, uses YouTube's own
|
|
30
|
+
* thumbnail (maxresdefault). */
|
|
31
31
|
poster?: string;
|
|
32
|
-
|
|
32
|
+
playerCaption?: ReactNode;
|
|
33
33
|
};
|
|
34
34
|
|
|
35
35
|
type Props = {
|
|
36
|
-
/**
|
|
36
|
+
/** Video background — defines the hero kind (loop or player). */
|
|
37
37
|
video: VideoLoop | VideoPlayer;
|
|
38
|
-
/**
|
|
38
|
+
/** Top-left slot (location badge, "← back" + icon). */
|
|
39
39
|
topLeft?: ReactNode;
|
|
40
|
-
/**
|
|
40
|
+
/** Short caption in the bottom-left corner (pill) — simple version. */
|
|
41
41
|
caption?: ReactNode;
|
|
42
|
-
/**
|
|
42
|
+
/** Bottom-left content panel (kicker + title + lead). */
|
|
43
43
|
panel?: Panel;
|
|
44
|
-
/** CTAs
|
|
45
|
-
*
|
|
44
|
+
/** CTAs in the bottom-right corner. In player mode, omitting this shows
|
|
45
|
+
* "Watch the video"; null hides it. In loop mode, only what is passed here appears. */
|
|
46
46
|
actions?: ReactNode | null;
|
|
47
|
-
/**
|
|
47
|
+
/** Label + meta of the default "Watch the video" button (player mode). */
|
|
48
48
|
watch?: { label?: string; meta?: ReactNode };
|
|
49
|
-
/**
|
|
49
|
+
/** Label of the close button in the bottom bar (player mode, playing). */
|
|
50
50
|
closeLabel?: string;
|
|
51
|
-
/**
|
|
51
|
+
/** Labels of the background pause button (player mode, muted preview). */
|
|
52
52
|
previewLabels?: { pause?: string; play?: string };
|
|
53
53
|
videoTitle?: string;
|
|
54
54
|
};
|
|
@@ -61,13 +61,13 @@ function XGlyph({ size = 18 }: { size?: number }) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
|
-
*
|
|
65
|
-
* (
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* (cqw/cqh),
|
|
69
|
-
*
|
|
70
|
-
*
|
|
64
|
+
* YouTube video background playing MUTED, on repeat, covering the card
|
|
65
|
+
* (player mode, before the sound starts). It is decorative: hidden from screen
|
|
66
|
+
* readers. Respects reduced motion and data saver (then it stays on the still
|
|
67
|
+
* frame without requesting the video). The "cover" is done with container units
|
|
68
|
+
* (cqw/cqh), so the 16:9 ratio is never distorted or falsely cropped.
|
|
69
|
+
* Has a pause button (WCAG 2.2.2). Clicks pass through (pointer-events-none):
|
|
70
|
+
* the watch button on top is what receives the tap.
|
|
71
71
|
*/
|
|
72
72
|
function YouTubeBackground({
|
|
73
73
|
id,
|
|
@@ -88,14 +88,14 @@ function YouTubeBackground({
|
|
|
88
88
|
const lessMotion =
|
|
89
89
|
typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
90
90
|
const saveData = (navigator as Navigator & { connection?: { saveData?: boolean } }).connection?.saveData;
|
|
91
|
-
if (lessMotion || saveData) return; //
|
|
91
|
+
if (lessMotion || saveData) return; // stay on the still frame
|
|
92
92
|
setShow(true);
|
|
93
93
|
}, []);
|
|
94
94
|
|
|
95
95
|
function toggle() {
|
|
96
96
|
const w = frame.current?.contentWindow;
|
|
97
97
|
if (!w) return;
|
|
98
|
-
// API
|
|
98
|
+
// YouTube API via postMessage (enablejsapi=1): pause/resume without loading the script.
|
|
99
99
|
w.postMessage(JSON.stringify({ event: "command", func: paused ? "playVideo" : "pauseVideo", args: [] }), "*");
|
|
100
100
|
setPaused((p) => !p);
|
|
101
101
|
}
|
|
@@ -107,7 +107,7 @@ function YouTubeBackground({
|
|
|
107
107
|
{show ? (
|
|
108
108
|
<iframe
|
|
109
109
|
ref={frame}
|
|
110
|
-
title={title ?? "
|
|
110
|
+
title={title ?? "Video"}
|
|
111
111
|
tabIndex={-1}
|
|
112
112
|
aria-hidden="true"
|
|
113
113
|
src={`https://www.youtube-nocookie.com/embed/${id}?autoplay=1&mute=1&loop=1&playlist=${id}&controls=0&disablekb=1&fs=0&modestbranding=1&rel=0&playsinline=1&iv_load_policy=3&enablejsapi=1`}
|
|
@@ -116,7 +116,7 @@ function YouTubeBackground({
|
|
|
116
116
|
style={{ width: "max(100cqw, 177.78cqh)", height: "max(100cqh, 56.25cqw)" }}
|
|
117
117
|
/>
|
|
118
118
|
) : (
|
|
119
|
-
//
|
|
119
|
+
// YouTube's own thumbnail (16:9) when the video is not playing
|
|
120
120
|
// eslint-disable-next-line @next/next/no-img-element
|
|
121
121
|
<img src={posterSrc} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />
|
|
122
122
|
)}
|
|
@@ -124,7 +124,7 @@ function YouTubeBackground({
|
|
|
124
124
|
<button
|
|
125
125
|
type="button"
|
|
126
126
|
onClick={toggle}
|
|
127
|
-
aria-label={paused ? labels?.play ?? "
|
|
127
|
+
aria-label={paused ? labels?.play ?? "Play video" : labels?.pause ?? "Pause video"}
|
|
128
128
|
className="absolute top-4 right-4 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-ink/85 text-surface backdrop-blur-md transition-colors duration-200 hover:bg-ink focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-surface motion-reduce:transition-none"
|
|
129
129
|
>
|
|
130
130
|
{paused ? (
|
|
@@ -139,15 +139,15 @@ function YouTubeBackground({
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
/**
|
|
142
|
-
*
|
|
143
|
-
* novo.creeta (
|
|
144
|
-
*
|
|
142
|
+
* Full-bleed hero of the Cuidado Global family — mirrors the "TopoVideo" of
|
|
143
|
+
* novo.creeta (exact sizes/spacing: max-w-1600, rounded-32, min-h 560/820),
|
|
144
|
+
* in the Breathe language (tokens: ink/surface).
|
|
145
145
|
*
|
|
146
|
-
* - loop:
|
|
147
|
-
* - player:
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
* "
|
|
146
|
+
* - 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).
|
|
151
151
|
*/
|
|
152
152
|
export function PageVideoHero({
|
|
153
153
|
video,
|
|
@@ -156,7 +156,7 @@ export function PageVideoHero({
|
|
|
156
156
|
panel,
|
|
157
157
|
actions,
|
|
158
158
|
watch,
|
|
159
|
-
closeLabel = "
|
|
159
|
+
closeLabel = "Close video",
|
|
160
160
|
previewLabels,
|
|
161
161
|
videoTitle,
|
|
162
162
|
}: Props) {
|
|
@@ -165,23 +165,23 @@ export function PageVideoHero({
|
|
|
165
165
|
const watchRef = useRef<HTMLButtonElement>(null);
|
|
166
166
|
const playedOnce = useRef(false);
|
|
167
167
|
|
|
168
|
-
//
|
|
168
|
+
// On close, focus returns to the watch button.
|
|
169
169
|
useEffect(() => {
|
|
170
170
|
if (playing) playedOnce.current = true;
|
|
171
171
|
else if (playedOnce.current) watchRef.current?.focus();
|
|
172
172
|
}, [playing]);
|
|
173
173
|
|
|
174
|
-
const
|
|
174
|
+
const playerCaption = isPlayer ? (video as VideoPlayer).playerCaption : undefined;
|
|
175
175
|
|
|
176
176
|
return (
|
|
177
177
|
<div className="px-4 pt-2 md:px-6 md:pt-6">
|
|
178
178
|
<section className="relative isolate mx-auto flex min-h-[min(calc(100svh-7.5rem),560px)] max-w-[1600px] flex-col justify-end overflow-hidden rounded-[32px] bg-ink text-surface lg:min-h-[min(calc(100svh-7.5rem),820px)]">
|
|
179
|
-
{/*
|
|
179
|
+
{/* media — background (preview) or, when playing, the full video with sound */}
|
|
180
180
|
<div className={`absolute inset-x-0 top-0 ${playing ? "bottom-[4.5rem]" : "bottom-0 -z-10"}`}>
|
|
181
181
|
{playing ? (
|
|
182
182
|
<iframe
|
|
183
|
-
key="cg-hero-
|
|
184
|
-
title={videoTitle ?? "
|
|
183
|
+
key="cg-hero-sound"
|
|
184
|
+
title={videoTitle ?? "Video"}
|
|
185
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
186
|
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture; fullscreen"
|
|
187
187
|
allowFullScreen
|
|
@@ -208,7 +208,7 @@ export function PageVideoHero({
|
|
|
208
208
|
className="absolute inset-0"
|
|
209
209
|
/>
|
|
210
210
|
)}
|
|
211
|
-
{/*
|
|
211
|
+
{/* scrims — legibility of the text (bottom) and of the top elements */}
|
|
212
212
|
<div aria-hidden="true" className="pointer-events-none absolute inset-0 bg-gradient-to-t from-ink/70 via-ink/10 to-transparent" />
|
|
213
213
|
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 h-28 bg-gradient-to-b from-ink/35 to-transparent" />
|
|
214
214
|
</>
|
|
@@ -217,10 +217,10 @@ export function PageVideoHero({
|
|
|
217
217
|
|
|
218
218
|
{!playing && (
|
|
219
219
|
<>
|
|
220
|
-
{/*
|
|
220
|
+
{/* top-left */}
|
|
221
221
|
{topLeft && <div className="absolute top-4 left-4 z-10">{topLeft}</div>}
|
|
222
222
|
|
|
223
|
-
{/*
|
|
223
|
+
{/* panel / caption — in flow (justify-end), m-6 */}
|
|
224
224
|
<div className="relative z-10 p-6 md:m-6 md:p-0">
|
|
225
225
|
{panel ? (
|
|
226
226
|
<div className="inline-flex max-w-full items-center rounded-[24px] bg-ink/75 px-6 py-4 backdrop-blur-md md:max-w-[calc(100%-16rem)] lg:px-7 lg:py-5">
|
|
@@ -239,7 +239,7 @@ export function PageVideoHero({
|
|
|
239
239
|
) : null}
|
|
240
240
|
</div>
|
|
241
241
|
|
|
242
|
-
{/*
|
|
242
|
+
{/* bottom-right action */}
|
|
243
243
|
{actions === null ? null : actions ? (
|
|
244
244
|
<div className="absolute right-4 bottom-4 z-10 flex flex-wrap items-center justify-end gap-2 md:right-6 md:bottom-6">{actions}</div>
|
|
245
245
|
) : isPlayer ? (
|
|
@@ -253,7 +253,7 @@ export function PageVideoHero({
|
|
|
253
253
|
<PlayGlyph size={20} />
|
|
254
254
|
</span>
|
|
255
255
|
<span className="flex flex-col items-start leading-tight">
|
|
256
|
-
{watch?.label ?? "
|
|
256
|
+
{watch?.label ?? "Watch the video"}
|
|
257
257
|
{watch?.meta && <span className="text-sm font-normal text-surface/80">{watch.meta}</span>}
|
|
258
258
|
</span>
|
|
259
259
|
</button>
|
|
@@ -261,10 +261,10 @@ export function PageVideoHero({
|
|
|
261
261
|
</>
|
|
262
262
|
)}
|
|
263
263
|
|
|
264
|
-
{/*
|
|
264
|
+
{/* close bar — bottom of the playing video (caption on the left, close on the right) */}
|
|
265
265
|
{playing && (
|
|
266
266
|
<div className="relative z-10 flex h-[4.5rem] items-center justify-between gap-6 bg-ink px-6">
|
|
267
|
-
<p className="min-w-0 truncate text-sm text-surface/80">{
|
|
267
|
+
<p className="min-w-0 truncate text-sm text-surface/80">{playerCaption}</p>
|
|
268
268
|
<button
|
|
269
269
|
type="button"
|
|
270
270
|
onClick={() => setPlaying(false)}
|
package/components/PillList.tsx
CHANGED
|
@@ -6,7 +6,7 @@ export type Pill = { label: ReactNode; href?: string };
|
|
|
6
6
|
type Props = {
|
|
7
7
|
title?: ReactNode;
|
|
8
8
|
items: Pill[];
|
|
9
|
-
/**
|
|
9
|
+
/** Optional "See all" link. */
|
|
10
10
|
seeAll?: { label: ReactNode; href?: string };
|
|
11
11
|
};
|
|
12
12
|
|
|
@@ -28,9 +28,9 @@ function ArrowUpRight() {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
31
|
+
* Pill list (e.g. "Conditions we treat"): white chips with label + arrow, in
|
|
32
|
+
* a wrapping grid, plus a "See all" link. A clickable chip renders as `<a>`.
|
|
33
|
+
* The DS's subtle card style (soft border + shadow), pill radius.
|
|
34
34
|
*/
|
|
35
35
|
export function PillList({ title, items, seeAll }: Props) {
|
|
36
36
|
return (
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
|
|
3
3
|
type Props = {
|
|
4
|
-
/**
|
|
4
|
+
/** Section numeral (e.g. "01"). */
|
|
5
5
|
num?: string;
|
|
6
6
|
children: ReactNode;
|
|
7
7
|
};
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
* Numeral
|
|
10
|
+
* Numbered section overline (e.g. "01 — TRATAMENTO"), the family's standard.
|
|
11
|
+
* Numeral in `action-text`, uppercase label in `ink-soft`. No dependencies.
|
|
12
12
|
*/
|
|
13
13
|
export function SectionKicker({ num, children }: Props) {
|
|
14
14
|
return (
|
package/components/icons.tsx
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Breathe icon library. Re-exports the ENTIRE lucide-react lib (hundreds of
|
|
3
|
+
* icons), so you can pick any of them when building interfaces:
|
|
4
4
|
* import { Heart, Wind, Baby } from "@breathe-ds/ui/components/icons";
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Icons use `currentColor` and accept `size`/`strokeWidth`. The DS components
|
|
6
|
+
* take icons through slots, so inject whichever you want.
|
|
7
7
|
*/
|
|
8
8
|
export * from "lucide-react";
|
|
9
9
|
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
Quote, Menu, X, ChevronDown, ArrowUpRight, Globe,
|
|
14
14
|
} from "lucide-react";
|
|
15
15
|
|
|
16
|
-
/*
|
|
16
|
+
/* Semantic aliases for the icons the family uses (the "core" ones). */
|
|
17
17
|
export const IconHome = House;
|
|
18
18
|
export const IconWind = Wind;
|
|
19
19
|
export const IconActivity = Activity;
|
|
@@ -39,7 +39,7 @@ export const IconChevronDown = ChevronDown;
|
|
|
39
39
|
export const IconArrowUpRight = ArrowUpRight;
|
|
40
40
|
export const IconGlobe = Globe;
|
|
41
41
|
|
|
42
|
-
/*
|
|
42
|
+
/* Map of the icons in use (core) — name → component. */
|
|
43
43
|
export const used = {
|
|
44
44
|
Home: IconHome, Wind: IconWind, Activity: IconActivity, Baby: IconBaby,
|
|
45
45
|
Search: IconSearch, Users: IconUsers, GraduationCap: IconGraduationCap,
|