@breathe-ds/ui 0.0.0-stage → 0.1.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/README.md CHANGED
@@ -1,3 +1,197 @@
1
- # Temporary Holding Version
1
+ # Breathe
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Breathe** — o design system da **família Cuidado Global** (ecossistema RTA / Mariangela:
4
+ creeta, ttcab, metodorta…). Os sites **herdam** este pacote e aplicam a
5
+ própria paleta sobre o mesmo contrato.
6
+
7
+ > Pacote npm: `@breathe-ds/ui` (endereço técnico). **Breathe** é o nome do sistema.
8
+
9
+ > **v0.1** — tokens + componentes (ver `components/`) + marcas dos sites.
10
+ > Confira tudo renderizado no playground (abaixo).
11
+
12
+ ## Ideia
13
+
14
+ Os sites da família **não compartilham paleta** (ttcab é petróleo/creme,
15
+ creeta é petróleo/névoa/magenta). O que se compartilha são os **papéis de
16
+ token** — `surface`, `ink`, `action`, `line`… — com nomes e estrutura iguais.
17
+ Cada site define os valores (`--cg-*`) da sua paleta.
18
+
19
+ Token nunca é "a cor X": é **papel + contraste medido**. (O Felipe é daltônico;
20
+ decisão de cor sempre por papel e contraste, nunca a olho.)
21
+
22
+ ## Uso (Tailwind v4)
23
+
24
+ No `globals.css` do site:
25
+
26
+ ```css
27
+ @import "tailwindcss";
28
+ @import "@breathe-ds/ui/theme.css";
29
+ @import "@breathe-ds/ui/tokens/ttcab.css"; /* ou defina os --cg-* inline */
30
+ ```
31
+
32
+ Depois, as utilitárias do Tailwind usam os papéis: `bg-surface`, `text-ink`,
33
+ `border-line`, `bg-action`, `text-action-text`, `rounded-2xl`, etc.
34
+
35
+ Para um site sem arquivo de tokens pronto, defina os papéis no próprio `:root`:
36
+
37
+ ```css
38
+ :root {
39
+ --cg-surface: #fbf5e9;
40
+ --cg-ink: #144753;
41
+ /* … ver tokens/ttcab.css para a lista completa */
42
+ }
43
+ ```
44
+
45
+ ## Papéis de token
46
+
47
+ | Token (Tailwind) | Papel | `--cg-*` |
48
+ |---|---|---|
49
+ | `surface` | fundo base | `--cg-surface` |
50
+ | `surface-panel` | painel escuro (footer, CTA final) | `--cg-surface-panel` |
51
+ | `ink` | texto principal / títulos | `--cg-ink` |
52
+ | `ink-soft` | texto secundário | `--cg-ink-soft` |
53
+ | `ink-body` | corpo de leitura | `--cg-ink-body` |
54
+ | `action` | CTA / destaque — **nunca texto puro** | `--cg-action` |
55
+ | `action-text` | texto na cor de ação (AA) | `--cg-action-text` |
56
+ | `line` | hairlines / bordas | `--cg-line` |
57
+ | `font-heading` / `font-body` | tipografia | `--cg-font-*` |
58
+ | `radius-xl/2xl/pill` · `ease-cg` | raio e curva de motion | fixos |
59
+
60
+ ## Contrastes medidos — TTCAB (`tokens/ttcab.css`)
61
+
62
+ Valores pela fórmula WCAG padrão (os mesmos que o `preview/` calcula ao vivo):
63
+
64
+ | Par | Contraste | Nível |
65
+ |---|---|---|
66
+ | surface `#fbf5e9` / ink `#144753` | 9,41:1 | AAA |
67
+ | surface `#fbf5e9` / ink-soft `#54707a` | 4,86:1 | AA |
68
+ | surface `#fbf5e9` / ink-body `#1e4f5b` | 8,32:1 | AAA |
69
+ | surface `#fbf5e9` / action-text `#9a5d07` | 4,90:1 | AA |
70
+ | surface-panel `#144753` / texto creme | 9,41:1 | AAA |
71
+
72
+ `action` `#f6a22a` é decorativo/CTA — não usar como texto sobre creme.
73
+
74
+ ## Tipografia da família
75
+
76
+ Decisão (28/10/2026): **só fontes open-source** — nada de licenciadas (Gotham saiu).
77
+
78
+ | Papel | Fonte | Observação |
79
+ |---|---|---|
80
+ | `font-heading` | **Quicksand** | títulos, toda a família (Google Fonts) |
81
+ | `font-body` | **Figtree** | corpo, toda a família (Google Fonts) |
82
+
83
+ **Hierarquia/escala tipográfica:** usar a do **novo.creeta** como guia (bem trabalhada
84
+ e refinada). _A extrair para tokens de tamanho/peso/entrelinha._
85
+
86
+ ## Primeiros consumidores
87
+
88
+ **ttcab** e **creeta**. A paleta do creeta em `tokens/creeta.css` está
89
+ ilustrativa (marcada `[conferir]`) — fecha ao migrar o creeta.
90
+
91
+ ## O que muda por tema (e o que é fixo)
92
+
93
+ **Muda por tema:** apenas **cores** e **grafismos** (ThreadRail, OrganicFrame,
94
+ marca d'água…). Cada site/marca da família tem a sua cor e os seus grafismos.
95
+
96
+ **Fixo em toda a família:** **tipografia** (Quicksand nos títulos + Figtree no
97
+ corpo, em qualquer tema — inclusive o Neutro) e os **ícones** (lucide). Botões
98
+ usam a fonte de corpo semibold (o 600 da Quicksand rende leve demais).
99
+
100
+ ## Decisão — estilo de card
101
+
102
+ Linguagem de card do DS: **bordas discretas + sombra discreta** (referência: "Famílias
103
+ contam" do novo.creeta). Nada de borda forte ou sombra pesada. _A virar tokens `--shadow-card` + `--border`._
104
+
105
+ ## Decisão — estilo de card
106
+
107
+ Linguagem de card do DS: **bordas discretas + sombra discreta** (referência: "Famílias contam" do novo.creeta). Nada de borda forte ou sombra pesada. _A virar tokens `--shadow-card` + `--border`._
108
+
109
+ ## Decisão — largura de hero
110
+
111
+ Heros **mais largos / full-bleed**, na proporção do **novo.creeta** (mais imersivos, menos
112
+ margem lateral). Vale para `PageVideoHero` e `PageHero`. O hero atual do ttcab é mais contido
113
+ — alargar para o padrão creeta ao migrar. _Vira token/escala de largura de hero._
114
+
115
+ ## Marcas dos sites (`components/marks/`)
116
+
117
+ Cada site da família tem o símbolo e o wordmark reais, copiados dos arquivos de
118
+ marca (`public/brand`) dos repositórios, separados em dois componentes para a
119
+ `Cortina` e o `Lockup` montarem os arranjos:
120
+
121
+ | Site | Símbolo | Wordmark | Import |
122
+ |---|---|---|---|
123
+ | TTCAB | `TtcabSymbol` (mãe + bebê, laranja `#F6A22A`) | `TtcabWordmark` (caixa-alta, `currentColor`) | `@breathe-ds/ui/components/marks/ttcab` |
124
+ | Creeta | `CreetaSymbol` (três ondas + ponto, cores da marca) | `CreetaWordmark` (minúsculas, `currentColor`) | `@breathe-ds/ui/components/marks/creeta` |
125
+
126
+ ```tsx
127
+ <Cortina logo={<TtcabSymbol />} name={<TtcabWordmark />} wordmarkRatio={TTCAB_LOCKUP.wordmarkPerSymbol} tagline="…" />
128
+ <Lockup variant="horizontal" size="md" logo={<CreetaSymbol />} wordmark={<CreetaWordmark />} wordmarkRatio={CREETA_LOCKUP.wordmarkPerSymbol} name="creeta" />
129
+ ```
130
+
131
+ **Proporção vem do manual, não do olho.** Cada arquivo de marca exporta
132
+ `*_LOCKUP.wordmarkPerSymbol` (altura do wordmark ÷ altura do símbolo no
133
+ logotipo horizontal: TTCAB 0,557 · creeta 0,469) e `symbolSide` (no TTCAB o
134
+ símbolo fica à **direita** do nome; no creeta e nos demais, à esquerda). O `Lockup` e a `Cortina`
135
+ passam isso ao svg pela variável `--cg-wordmark`; o símbolo injetado recebe a
136
+ altura do tamanho (`size`). Medido nos sites (29/10/2026): no header dos dois o
137
+ símbolo tem **44px** de altura — é o `size="md"` do Lockup. O símbolo aceita
138
+ `color` para versão monocromática (branco sobre painel escuro). O desenho não se
139
+ edita à mão: é o do manual de cada marca.
140
+
141
+ `Lockup` com `entrance` (e `entranceDelay` em ms) faz a entrada do header do
142
+ novo.creeta: símbolo, depois o nome, depois a tagline; com movimento reduzido vira
143
+ um fade curto.
144
+
145
+ ## Navegação
146
+
147
+ - **Header**: marca (o app passa o link para a home) · itens · item com dropdown
148
+ (ícones nos subitens; link final com seta → ou ↗ se `external`) · idioma · CTA.
149
+ Padrão dos sites: "Início" é só a casa (`icon` + `iconOnly`), rótulo para
150
+ leitor de tela.
151
+ - **BottomNav**: no celular/tablet a navegação sai do header e vira a barra
152
+ flutuante inferior. É o código da BarraNavegacao do novo.creeta (= BottomNav
153
+ do ttcab), neutro por tokens: recebe TODAS as páginas em ordem de importância
154
+ (`pages`, com o `Icon` lucide) e mostra quantas couberem (64px por aba,
155
+ ResizeObserver); o resto e as saídas externas (`extras`, com o domínio escrito
156
+ e seta ↗) vão para o painel "Mais", um popover nativo (Esc, toque fora, sem
157
+ JS). Véu de gradiente atrás da barra. `hideFrom` = `md` (creeta) ou `lg`
158
+ (ttcab). `labels` para outro idioma. `embedded` para a moldura da demo.
159
+ - **AnchorNav**: cápsula flutuante de âncoras da página (scroll-spy + indicador
160
+ deslizante), extraída do TreatmentAnchorNav do ttcab. As seções-alvo precisam
161
+ de `scroll-mt-*` compatível com o sticky.
162
+
163
+ ## Playground (ver os componentes)
164
+
165
+ `cd playground && npm install && npm run dev` → kitchen-sink em http://localhost:5177 com os componentes reais renderizados (paleta ttcab).
166
+
167
+ ## Roadmap
168
+
169
+ - [x] v0.1 — tokens (preset + paletas ttcab/creeta) + preview
170
+ - [x] Accordion (1º componente; padrão de deps injetáveis)
171
+ - [x] Tipografia da família (Quicksand + Figtree, só livres)
172
+ - [ ] **Escalas do novo.creeta** (referência total, aprovado): **tipografia** (tamanhos/pesos/entrelinha)
173
+ **+ espaçamento/ritmo** (padding de seção, gaps) **+ border-radius padrão** → viram tokens do DS
174
+ - [ ] **Ícones**: empacotar o conjunto do lucide usado pela família, para os
175
+ componentes importarem/injetarem (sem cada site depender do lucide direto)
176
+ - [ ] **MediaCard** (vídeo/imagem atrás + badge de ícone + título + lead + seta;
177
+ variante **carrossel horizontal no mobile** com barra de progresso + pause) — aprovado
178
+ - [ ] **TestimonialCard** (depoimento) — aprovado. Variantes: **citação** (card de texto),
179
+ **vídeo hero** (full-bleed com citação sobreposta), **vídeo ao lado** ("Famílias contam": vídeo + texto).
180
+ Avaliar um **mix** das duas. **Já existe no ttcab** (TestimonialVideoHero/Card) → extrair/generalizar.
181
+ - [ ] **VideoStoryCard** (vídeo curto, vertical) — aprovado. Play + badge de duração +
182
+ título + lead + **"Ler a transcrição"** (colapso, reusa o Accordion). Padrão da página de Vídeos.
183
+ _Nota: o card ainda pode melhorar num update futuro (ok deixar pra depois)._
184
+ - [ ] **ProfessionalCard** (equipe) — aprovado. Duas versões: **small** (foto + nome, fila/linha)
185
+ e **big** (foto + nome + função + CREFITO + accordions "Trajetória"/"Orientações").
186
+ Variante com **vídeo**: badge "Apresentação", hover anima o vídeo, e **modal** (vídeo + bio).
187
+ **Regra de dado:** todo profissional PRECISA ter **CREFITO** vinculado (exigência COFFITO).
188
+ - [x] **PillList** (chips tipo "Condições que atendemos": rótulo + seta, grade que quebra, link "Ver todos")
189
+ - [x] **AnchorNav** (cápsula flutuante de âncoras, scroll-spy + indicador deslizante) — extraída do ttcab (29/10)
190
+ - [x] **Footer** · **PageVideoHero** (loop e player) · **Header** (dropdown, casa) · **BottomNav** · **Cortina** · **Lockup** (5 arranjos) · **BreatheMark**
191
+ - [x] **Marcas dos sites** (`components/marks/`): símbolo + wordmark reais do TTCAB e do Creeta (29/10)
192
+ - [ ] **ThreadRail** (grafismo lateral — fio que desce pela esquerda ligando seções) — aprovado,
193
+ **já existe no ttcab** → extrair.
194
+ - [ ] **OrganicFrame** (recorte de imagem orgânico, tipo blob) — registrado como **OPÇÃO**
195
+ (não default pra todos os sites). **Já existe no ttcab** → extrair como variante opcional.
196
+ - [x] Primitivos: Button (com variant link), Container, SectionKicker, Card
197
+ - [ ] Migrar ttcab e creeta para consumir o pacote + `npm publish`
@@ -0,0 +1,103 @@
1
+ "use client";
2
+
3
+ import { useId, useState, type ReactNode } from "react";
4
+
5
+ export type AccordionItem = {
6
+ id: string;
7
+ title: ReactNode;
8
+ content: ReactNode;
9
+ };
10
+
11
+ type Props = {
12
+ items: AccordionItem[];
13
+ /** id aberto inicialmente (padrão: o primeiro). null = todos fechados. */
14
+ defaultOpenId?: string | null;
15
+ /** true (padrão): só um aberto por vez, como no creeta. */
16
+ single?: boolean;
17
+ };
18
+
19
+ /**
20
+ * Accordion / collapse da família Cuidado Global. Cartões em `surface` com
21
+ * borda `line`, título `ink` e chevron que gira ao abrir. Sem dependência de
22
+ * app: o ícone é um SVG inline. Anima a altura pelo truque grid-rows (0fr→1fr),
23
+ * sem medir nada em JS, e respeita prefers-reduced-motion.
24
+ *
25
+ * Cores: texto `ink` sobre `surface` (contraste do papel definido pelo site).
26
+ * Consome os tokens via classes do Tailwind (surface/ink/line) — ver theme.css.
27
+ */
28
+ export function Accordion({ items, defaultOpenId, single = true }: Props) {
29
+ const initial =
30
+ defaultOpenId === null
31
+ ? []
32
+ : [defaultOpenId ?? items[0]?.id].filter(Boolean as never as (x: string | undefined) => x is string);
33
+ const [open, setOpen] = useState<string[]>(initial);
34
+ const baseId = useId();
35
+
36
+ const toggle = (id: string) => {
37
+ setOpen((cur) => {
38
+ const isOpen = cur.includes(id);
39
+ if (single) return isOpen ? [] : [id];
40
+ return isOpen ? cur.filter((x) => x !== id) : [...cur, id];
41
+ });
42
+ };
43
+
44
+ return (
45
+ <div className="flex flex-col gap-3">
46
+ {items.map((it) => {
47
+ const isOpen = open.includes(it.id);
48
+ const btnId = `${baseId}-btn-${it.id}`;
49
+ const panelId = `${baseId}-panel-${it.id}`;
50
+ return (
51
+ <div
52
+ key={it.id}
53
+ className="overflow-hidden rounded-2xl border border-line bg-surface"
54
+ >
55
+ <h3 className="m-0">
56
+ <button
57
+ type="button"
58
+ id={btnId}
59
+ aria-expanded={isOpen}
60
+ aria-controls={panelId}
61
+ onClick={() => toggle(it.id)}
62
+ className="flex w-full items-center justify-between gap-4 px-[clamp(18px,3vw,28px)] py-[clamp(16px,2.4vw,22px)] text-left font-heading text-[1.05rem] font-semibold text-ink transition-colors focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ink"
63
+ >
64
+ <span>{it.title}</span>
65
+ <svg
66
+ aria-hidden="true"
67
+ viewBox="0 0 24 24"
68
+ className={`h-5 w-5 flex-none text-ink-soft transition-transform duration-200 ease-[cubic-bezier(.2,.8,.2,1)] motion-reduce:transition-none ${
69
+ isOpen ? "rotate-180" : ""
70
+ }`}
71
+ fill="none"
72
+ stroke="currentColor"
73
+ strokeWidth="2.25"
74
+ strokeLinecap="round"
75
+ strokeLinejoin="round"
76
+ >
77
+ <path d="M6 9l6 6 6-6" />
78
+ </svg>
79
+ </button>
80
+ </h3>
81
+ {/* grid-rows 0fr→1fr: anima a altura sem medir em JS */}
82
+ <div
83
+ id={panelId}
84
+ role="region"
85
+ aria-labelledby={btnId}
86
+ // React 18 não conhece `inert` como booleano: string vazia liga, undefined desliga (no 19 o booleano funciona igual).
87
+ {...({ inert: isOpen ? undefined : "" } as Record<string, unknown>)}
88
+ className={`grid transition-[grid-template-rows] duration-300 ease-[cubic-bezier(.2,.8,.2,1)] motion-reduce:transition-none ${
89
+ isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
90
+ }`}
91
+ >
92
+ <div className="min-h-0 overflow-hidden">
93
+ <div className="px-[clamp(18px,3vw,28px)] pt-0 pb-[clamp(18px,2.6vw,26px)] text-ink-body">
94
+ {it.content}
95
+ </div>
96
+ </div>
97
+ </div>
98
+ </div>
99
+ );
100
+ })}
101
+ </div>
102
+ );
103
+ }
@@ -0,0 +1,137 @@
1
+ "use client";
2
+
3
+ import { useEffect, useLayoutEffect, useRef, useState, type ElementType } from "react";
4
+ import { Container } from "./Container";
5
+
6
+ export type AnchorItem = { id: string; label: string };
7
+
8
+ type Props = {
9
+ items: AnchorItem[];
10
+ /** Classe de deslocamento do sticky (altura do header). Padrão `top-20` (header de 80px). */
11
+ stickyTop?: string;
12
+ /** Componente de link do app (ex.: next-intl Link). Default: <a>. */
13
+ linkComponent?: ElementType;
14
+ className?: string;
15
+ };
16
+
17
+ /**
18
+ * Navegação contextual de âncoras — cápsula flutuante que acompanha a rolagem
19
+ * (extraída do TreatmentAnchorNav do ttcab). Dois comportamentos:
20
+ * 1. Scroll-spy: um IntersectionObserver marca como ativa a seção que cruza a
21
+ * faixa ~35% do topo; o pill correspondente acende.
22
+ * 2. Indicador deslizante: um <span> absoluto mede o pill ativo (offsetLeft/
23
+ * Width, imune ao scroll horizontal da cápsula) e desliza até ele na curva
24
+ * da família. Em tela estreita a cápsula rola e centraliza o ativo.
25
+ * Sinal do ativo = o indicador (preenchimento ink/12 + contorno), NÃO cor do
26
+ * texto: o texto fica sempre `ink` sobre `surface`, sem frame de contraste
27
+ * fraco. prefers-reduced-motion corta o deslize. As seções-alvo precisam de
28
+ * `scroll-mt-*` compatível com o sticky (ex.: `scroll-mt-40`).
29
+ */
30
+ export function AnchorNav({ items, stickyTop = "top-20", linkComponent, className = "" }: Props) {
31
+ const L: ElementType = linkComponent ?? "a";
32
+ const [active, setActive] = useState(items[0]?.id ?? "");
33
+ const listRef = useRef<HTMLUListElement>(null);
34
+ const linkRefs = useRef<Record<string, HTMLAnchorElement | null>>({});
35
+ const [pill, setPill] = useState({ x: 0, y: 0, w: 0, h: 0, ready: false });
36
+
37
+ // Scroll-spy
38
+ useEffect(() => {
39
+ const sections = items
40
+ .map((it) => document.getElementById(it.id))
41
+ .filter((el): el is HTMLElement => Boolean(el));
42
+ if (!sections.length) return;
43
+ const io = new IntersectionObserver(
44
+ (entries) => {
45
+ const visible = entries.filter((e) => e.isIntersecting);
46
+ if (!visible.length) return;
47
+ visible.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
48
+ setActive(visible[0].target.id);
49
+ },
50
+ { rootMargin: "-35% 0px -60% 0px", threshold: 0 }
51
+ );
52
+ sections.forEach((s) => io.observe(s));
53
+ return () => io.disconnect();
54
+ }, [items]);
55
+
56
+ // Chegada com #hash por navegação client-side: rola até a âncora (no full
57
+ // load o browser já faz; aqui é idempotente). Reaplica algumas vezes para
58
+ // vencer o reset tardio de scroll do roteador.
59
+ useEffect(() => {
60
+ const hash = window.location.hash.slice(1);
61
+ if (!hash || !items.some((it) => it.id === hash)) return;
62
+ const el = document.getElementById(hash);
63
+ if (!el) return;
64
+ setActive(hash);
65
+ let tries = 0;
66
+ const id = window.setInterval(() => {
67
+ el.scrollIntoView({ behavior: "auto", block: "start" });
68
+ if (++tries >= 4) window.clearInterval(id);
69
+ }, 60);
70
+ return () => window.clearInterval(id);
71
+ }, [items]);
72
+
73
+ const measure = () => {
74
+ const el = linkRefs.current[active];
75
+ if (!el) return;
76
+ setPill({ x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight, ready: true });
77
+ };
78
+
79
+ // Mede o pill ativo + revela na cápsula em telas estreitas
80
+ useLayoutEffect(() => {
81
+ measure();
82
+ const el = linkRefs.current[active];
83
+ const list = listRef.current;
84
+ if (!el || !list) return;
85
+ const target = el.offsetLeft - list.clientWidth / 2 + el.offsetWidth / 2;
86
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
87
+ list.scrollTo({ left: Math.max(0, target), behavior: reduce ? "auto" : "smooth" });
88
+ // eslint-disable-next-line react-hooks/exhaustive-deps
89
+ }, [active]);
90
+
91
+ // Recalcula em resize (quebras/fontes mudam a largura)
92
+ useEffect(() => {
93
+ window.addEventListener("resize", measure);
94
+ return () => window.removeEventListener("resize", measure);
95
+ // eslint-disable-next-line react-hooks/exhaustive-deps
96
+ }, [active]);
97
+
98
+ return (
99
+ <nav aria-label="Nesta página" className={`sticky ${stickyTop} z-30 py-3 ${className}`}>
100
+ <Container>
101
+ <div className="flex justify-center">
102
+ <ul
103
+ ref={listRef}
104
+ className="relative flex max-w-full list-none gap-1 overflow-x-auto rounded-pill border border-line bg-surface/95 p-1.5 shadow-[0_10px_28px_-14px_rgba(0,0,0,0.35)] backdrop-blur [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
105
+ >
106
+ {/* Indicador deslizante */}
107
+ <span
108
+ aria-hidden="true"
109
+ className="pointer-events-none absolute top-0 left-0 rounded-pill bg-ink/[0.12] shadow-[inset_0_0_0_1px_var(--color-line)] transition-[transform,width,height,opacity] duration-[320ms] ease-cg motion-reduce:transition-none"
110
+ style={{ width: pill.w, height: pill.h, opacity: pill.ready ? 1 : 0, transform: `translate(${pill.x}px, ${pill.y}px)` }}
111
+ />
112
+ {items.map((n) => {
113
+ const isActive = n.id === active;
114
+ return (
115
+ <li key={n.id}>
116
+ <L
117
+ ref={(el: HTMLAnchorElement | null) => {
118
+ linkRefs.current[n.id] = el;
119
+ }}
120
+ href={`#${n.id}`}
121
+ aria-current={isActive ? "true" : undefined}
122
+ onClick={() => setActive(n.id)}
123
+ className={`relative z-10 inline-block whitespace-nowrap rounded-pill border border-transparent px-3.5 py-2 font-heading text-[0.92rem] font-semibold text-ink transition-[background-color,border-color] duration-200 ease-cg focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none ${
124
+ isActive ? "" : "hover:border-ink/15 hover:bg-ink/[0.06]"
125
+ }`}
126
+ >
127
+ {n.label}
128
+ </L>
129
+ </li>
130
+ );
131
+ })}
132
+ </ul>
133
+ </div>
134
+ </Container>
135
+ </nav>
136
+ );
137
+ }
@@ -0,0 +1,107 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+
5
+ type Props = {
6
+ /** Clipe curto, sem som, em repetição (arquivo do próprio site). */
7
+ src: string;
8
+ /** Versão maior, baixada só em telas largas (a partir de `from`). */
9
+ srcLarge?: string;
10
+ /** Largura mínima (px) para a versão maior. 1024 por padrão. */
11
+ from?: 768 | 1024;
12
+ /** Quadro parado: aparece antes de o vídeo carregar, sem JS e com movimento reduzido. */
13
+ poster: string;
14
+ /** Quadro parado da versão maior, quando ela é outro vídeo (ex.: vertical no celular, deitado no desktop). */
15
+ posterLarge?: string;
16
+ /** Aproxima o vídeo para cortar bordas gravadas no arquivo. */
17
+ trimEdges?: boolean;
18
+ /** object-position do vídeo (ex.: "center 20%" p/ manter o rosto num clipe vertical). */
19
+ objectPosition?: string;
20
+ /** Rótulos do botão de pausa (injetáveis p/ i18n). */
21
+ labels?: { pause?: string; play?: string };
22
+ className?: string;
23
+ };
24
+
25
+ /**
26
+ * Vídeo de fundo da família Cuidado Global — espelha o `VideoFundo` do creeta
27
+ * com os tokens do Breathe (ink/surface). Clipe mudo em repetição, é enfeite:
28
+ * fica fora da leitura de tela (aria-hidden). Não usa o atributo `autoplay`:
29
+ * só começa depois de conferir prefers-reduced-motion e economia de dados
30
+ * (save-data). Tem botão de pausa (WCAG 2.2.2) no canto superior-direito.
31
+ */
32
+ export function BackgroundVideo({
33
+ src,
34
+ srcLarge,
35
+ from = 1024,
36
+ poster,
37
+ posterLarge,
38
+ trimEdges = false,
39
+ objectPosition,
40
+ labels,
41
+ className,
42
+ }: Props) {
43
+ const wide = `(min-width: ${from}px)`;
44
+ const video = useRef<HTMLVideoElement>(null);
45
+ const [running, setRunning] = useState(false);
46
+ const [posterNow, setPosterNow] = useState(poster);
47
+
48
+ useEffect(() => {
49
+ if (posterLarge && typeof window.matchMedia === "function" && window.matchMedia(wide).matches) {
50
+ setPosterNow(posterLarge);
51
+ }
52
+ }, [posterLarge, wide]);
53
+
54
+ useEffect(() => {
55
+ const lessMotion =
56
+ typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
57
+ const saveData = (navigator as Navigator & { connection?: { saveData?: boolean } }).connection?.saveData;
58
+ if (lessMotion || saveData) return;
59
+ video.current
60
+ ?.play()
61
+ .then(() => setRunning(true))
62
+ .catch(() => setRunning(false));
63
+ }, []);
64
+
65
+ function toggle() {
66
+ const v = video.current;
67
+ if (!v) return;
68
+ if (v.paused) {
69
+ v.play().then(() => setRunning(true)).catch(() => setRunning(false));
70
+ } else {
71
+ v.pause();
72
+ setRunning(false);
73
+ }
74
+ }
75
+
76
+ return (
77
+ <div className={`overflow-hidden ${className ?? ""}`}>
78
+ <video
79
+ ref={video}
80
+ muted
81
+ loop
82
+ playsInline
83
+ preload="metadata"
84
+ poster={posterNow}
85
+ aria-hidden="true"
86
+ tabIndex={-1}
87
+ style={objectPosition ? { objectPosition } : undefined}
88
+ className={`h-full w-full object-cover ${trimEdges ? "scale-[1.18]" : ""}`}
89
+ >
90
+ {srcLarge && <source src={srcLarge} type="video/mp4" media={wide} />}
91
+ <source src={src} type="video/mp4" />
92
+ </video>
93
+ <button
94
+ type="button"
95
+ onClick={toggle}
96
+ aria-label={running ? labels?.pause ?? "Pausar vídeo" : labels?.play ?? "Reproduzir vídeo"}
97
+ 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"
98
+ >
99
+ {running ? (
100
+ <svg viewBox="0 0 24 24" className="h-[18px] w-[18px]" fill="currentColor" aria-hidden="true"><rect x="6" y="4" width="4" height="16" rx="1.2" /><rect x="14" y="4" width="4" height="16" rx="1.2" /></svg>
101
+ ) : (
102
+ <svg viewBox="0 0 24 24" className="ml-0.5 h-[18px] w-[18px]" fill="currentColor" aria-hidden="true"><path d="M6 3v18l15-9z" /></svg>
103
+ )}
104
+ </button>
105
+ </div>
106
+ );
107
+ }