@breathe-ds/ui 0.0.0-stage → 0.1.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 +196 -2
- package/components/Accordion.tsx +103 -0
- package/components/AnchorNav.tsx +137 -0
- package/components/BackgroundVideo.tsx +107 -0
- package/components/BottomNav.tsx +300 -0
- package/components/BreatheMark.tsx +67 -0
- package/components/Button.tsx +68 -0
- package/components/Card.tsx +31 -0
- package/components/Container.tsx +22 -0
- package/components/Cortina.tsx +152 -0
- package/components/Footer.tsx +115 -0
- package/components/Header.tsx +172 -0
- package/components/Lockup.tsx +184 -0
- package/components/PageVideoHero.tsx +281 -0
- package/components/PillList.tsx +79 -0
- package/components/SectionKicker.tsx +25 -0
- package/components/icons.tsx +51 -0
- package/components/marks/creeta.tsx +68 -0
- package/components/marks/ttcab.tsx +64 -0
- package/package.json +47 -4
- package/theme.css +40 -0
- package/tokens/creeta.css +21 -0
- package/tokens/neutral.css +21 -0
- package/tokens/ttcab.css +22 -0
package/README.md
CHANGED
|
@@ -1,3 +1,197 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Breathe
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|