@breathe-ds/ui 0.1.0 → 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.
@@ -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
+ }
@@ -2,20 +2,20 @@ import type { CSSProperties, ReactNode } from "react";
2
2
  import { BreatheMark } from "./BreatheMark";
3
3
 
4
4
  /**
5
- * Arranjos do logo (sistema de branding flexível da família Cuidado Global).
6
- * - `stacked` empilhado: símbolo em cima, nome embaixo — perfis, splash, pôster.
7
- * - `horizontal` símbolo + nome lado a lado (+ tagline) — cabeçalho, documento.
8
- * - `wordmark` só texto (nome + tagline) — anúncio, quando a leitura é essencial.
9
- * - `symbol` só o símbolo — favicon, ícone de app, marca d'água.
10
- * - `merged` fundido: símbolo preenchido com o nome dentro — avatar, embalagem.
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
- /* Nome em `font-heading`; símbolo ~1.3× o corpo do nome (o anel é aberto e
16
- "lê" mais leve, então pede um tamanho a mais pra fechar em harmonia).
17
- A caixa do BreatheMark é justa ao glifo: o vão entre símbolo e nome é só o gap.
18
- `md` = header dos sites (símbolo de 44px, medido no creeta e no ttcab). */
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
- /** Nome da marca (cada site passa o seu). */
28
+ /** Brand name (each site passes its own). */
29
29
  name?: string;
30
30
  tagline?: ReactNode;
31
- /** Símbolo customizado; por padrão o BreatheMark (anel + ponto). Um <svg>
32
- * injetado recebe a altura do tamanho (`size`); a largura segue a proporção. */
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
- /** Wordmark desenhado (svg) no lugar do nome em texto. `merged` ignora
35
- * (lá o nome é texto). */
34
+ /** Drawn wordmark (svg) in place of the text name. Ignored by `merged`
35
+ * (there the name is text). */
36
36
  wordmark?: ReactNode;
37
- /** Altura do wordmark como fração da altura do símbolo, do manual da marca
38
- * (ex.: `TTCAB_LOCKUP.wordmarkPerSymbol`). Chega ao svg por `--cg-wordmark`. */
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
- /** Lado do símbolo no horizontal. Padrão `left` (creeta e os demais); o TTCAB
41
- * é `right` (`TTCAB_LOCKUP.symbolSide`). */
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
- /** O símbolo "respira"? Padrão `false` — logo de cabeçalho não fica animando. */
44
+ /** Does the symbol "breathe"? Default `false`: a header logo should not keep animating. */
45
45
  animated?: boolean;
46
- /** Entrada ao montar, como no header do novo.creeta: o símbolo chega
47
- * primeiro, depois o nome, depois a tagline. Respeita prefers-reduced-motion
48
- * (vira só um fade curto). Remonte (key) para repetir. */
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
- /** Atraso da entrada em ms (ex.: esperar a Cortina subir). */
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 — monta o logo da família num dos arranjos acima. Neutro por tokens:
57
- * nome/símbolo em `ink`, tagline em `ink-soft`; a cor real vem de cada app.
58
- * Tipografia é fixa da família (Quicksand no nome, Figtree na tagline).
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
- // No horizontal com tagline, o símbolo cobre o bloco inteiro (nome + tagline),
77
- // virando um "bookend" proporcional — não só a altura do nome.
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
- // O wordmark segue o símbolo-base do tamanho (não o "bookend" do horizontal
85
- // com tagline), como no logotipo assinado do creeta: nome ≈ 0.26 do símbolo grande.
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`: como item inline, a entrelinha do pai (1.5 do body) mandava na
95
- // linha e a tagline de duas linhas ficava folgada demais.
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
- // Fundido: nome dentro de um disco preenchido (ink). Avatar / ícone de app.
133
- // Nomes curtos (creeta, ttcab) fecham melhor; nome longo reduz o corpo.
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 (padrão). Símbolo à direita (TTCAB): encosta no nome, na mesma
146
- // linha; a tagline fica embaixo (senão o símbolo iria parar depois da tagline).
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
- /* Entrada: símbolo (escala + giro leve) → nome (desliza da esquerda) → tagline.
172
- Mesmo vocabulário da Cortina; com movimento reduzido fica só um fade curto. */
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
- /** Clipe de fundo local (modo loop), sem som, em repetição. */
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
- /** Dois tipos de hero com vídeo:
19
- * - loop: um clipe local em repetição (sem player). Fundo = BackgroundVideo.
20
- * - player: o PRÓPRIO vídeo do YouTube roda mudo, em repetição, como fundo
21
- * (16:9, cobrindo o cartão, sem corte/zoom). Ao apertar o som ele
22
- * remonta do zero — com áudio e controles — e surge a barra de
23
- * fechar no pé. Mantém a consistência: a prévia e o vídeo são o
24
- * mesmo material. */
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
- /** Quadro parado (movimento reduzido / economia de dados). Sem ele, usa a
30
- * miniatura do próprio YouTube (maxresdefault). */
29
+ /** Still frame (reduced motion / data saver). Without it, uses YouTube's own
30
+ * thumbnail (maxresdefault). */
31
31
  poster?: string;
32
- legenda?: ReactNode;
32
+ playerCaption?: ReactNode;
33
33
  };
34
34
 
35
35
  type Props = {
36
- /** Fundo em vídeo — define o tipo do hero (loop ou player). */
36
+ /** Video background — defines the hero kind (loop or player). */
37
37
  video: VideoLoop | VideoPlayer;
38
- /** Slot no topo-esquerdo (selo de local, "← voltar" + ícone). */
38
+ /** Top-left slot (location badge, "← back" + icon). */
39
39
  topLeft?: ReactNode;
40
- /** Legenda curta no canto inferior-esquerdo (pílula) — versão simples. */
40
+ /** Short caption in the bottom-left corner (pill) — simple version. */
41
41
  caption?: ReactNode;
42
- /** Painel de conteúdo inferior-esquerdo (kicker + título + lead). */
42
+ /** Bottom-left content panel (kicker + title + lead). */
43
43
  panel?: Panel;
44
- /** CTAs no canto inferior-direito. No modo player, sem isto mostra "Assistir
45
- * ao vídeo"; null esconde. No modo loop, só aparece o que vier aqui. */
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
- /** Rótulo + meta do botão padrão "Assistir ao vídeo" (modo player). */
47
+ /** Label + meta of the default "Watch the video" button (player mode). */
48
48
  watch?: { label?: string; meta?: ReactNode };
49
- /** Rótulo do botão de fechar na barra inferior (modo player tocando). */
49
+ /** Label of the close button in the bottom bar (player mode, playing). */
50
50
  closeLabel?: string;
51
- /** Rótulos do botão de pausa do fundo (modo player, prévia muda). */
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
- * Fundo em vídeo do YouTube rodando MUDO, em repetição, cobrindo o cartão
65
- * (modo player, antes de o som começar). É enfeite: fica fora da leitura de
66
- * tela. Respeita movimento reduzido e economia de dados (aí fica no quadro
67
- * parado, sem pedir o vídeo). O "cover" é feito com unidades de container
68
- * (cqw/cqh), então a proporção 16:9 nunca é distorcida nem cortada em falso.
69
- * Tem botão de pausa (WCAG 2.2.2). O clique passa reto (pointer-events-none):
70
- * quem recebe o toque é o botão de assistir, por cima.
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; // fica no quadro parado
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 do YouTube por postMessage (enablejsapi=1): pausa/retoma sem carregar o script.
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 ?? "Vídeo"}
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
- // miniatura do próprio YouTube (16:9) quando não se toca o vídeo
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 ?? "Reproduzir vídeo" : labels?.pause ?? "Pausar vídeo"}
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
- * Hero full-bleed da família Cuidado Global — espelha o "TopoVideo" do
143
- * novo.creeta (tamanhos/espaçamentos exatos: max-w-1600, rounded-32,
144
- * min-h 560/820), com a linguagem do Breathe (tokens: ink/surface).
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: o fundo é um clipe local em repetição (BackgroundVideo).
147
- * - player: o fundo é o PRÓPRIO vídeo do YouTube rodando mudo, em repetição.
148
- * Ao apertar o som, ele remonta do zero (com áudio e controles) no
149
- * próprio cartão e abre a barra de fechar no pé (legenda à esquerda,
150
- * "Fechar" à direita).
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 = "Fechar o vídeo",
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
- // Ao fechar, o foco volta para o botão de assistir.
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 legenda = isPlayer ? (video as VideoPlayer).legenda : undefined;
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
- {/* mídia — fundo (prévia) ou, tocando, o vídeo inteiro com som */}
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-som"
184
- title={videoTitle ?? "Vídeo"}
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
- {/* véus — legibilidade do texto (base) e dos elementos do topo */}
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
- {/* topo-esquerdo */}
220
+ {/* top-left */}
221
221
  {topLeft && <div className="absolute top-4 left-4 z-10">{topLeft}</div>}
222
222
 
223
- {/* painel / legenda — no fluxo (justify-end), m-6 */}
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
- {/* ação inferior-direita */}
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 ?? "Assistir ao vídeo"}
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
- {/* barra de fechar — pé do vídeo tocando (legenda à esquerda, fechar à direita) */}
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">{legenda}</p>
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)}
@@ -6,7 +6,7 @@ export type Pill = { label: ReactNode; href?: string };
6
6
  type Props = {
7
7
  title?: ReactNode;
8
8
  items: Pill[];
9
- /** Link "Ver todos" opcional. */
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
- * Lista de pills (ex.: "Condições que atendemos"): chips brancos com rótulo +
32
- * seta, em grade que quebra, e um link "Ver todos". Chip clicável vira `<a>`.
33
- * Estilo de card discreto do DS (borda + sombra suaves), raio pill.
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
- /** Numeral da seção (ex.: "01"). */
4
+ /** Section numeral (e.g. "01"). */
5
5
  num?: string;
6
6
  children: ReactNode;
7
7
  };
8
8
 
9
9
  /**
10
- * Sobrelinha de seção numerada (ex.: "01 — TRATAMENTO"), padrão da família.
11
- * Numeral em `action-text`, rótulo em `ink-soft` caixa-alta. Sem dependência.
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 (
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Biblioteca de ícones do Breathe. Re-exporta a lib INTEIRA do lucide-react
3
- * (centenas de ícones), para você escolher qualquer um ao montar interfaces:
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
- * Os ícones usam `currentColor` e aceitam `size`/`strokeWidth`. Os componentes
6
- * do DS recebem ícones por slot, então injete o que quiser.
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
- /* Aliases semânticos dos ícones usados pela família (os "principais"). */
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
- /* Mapa dos usados (principais) — nome → componente. */
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,