@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.
@@ -0,0 +1,115 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export type FooterColumn = { title: ReactNode; items: ReactNode[] };
4
+
5
+ type Props = {
6
+ /** Marca (passe o seu <img>/logo). */
7
+ logo: ReactNode;
8
+ /** Frase/assinatura sob a marca. */
9
+ tagline?: ReactNode;
10
+ /** Colunas de navegação (o consumidor passa seus próprios <Link>). */
11
+ columns: FooterColumn[];
12
+ /** Bloco de contato/endereço (título + linhas + ações como "Ver no mapa"). */
13
+ contact: { title?: ReactNode; lines: ReactNode[]; actions?: ReactNode };
14
+ /** Bloco opcional em destaque (ex.: "Plano de saúde: atendemos por reembolso"). */
15
+ notice?: ReactNode;
16
+ /** Linha legal (CNPJ, copyright…). */
17
+ legal?: ReactNode;
18
+ /** Chips de contato (telefone/WhatsApp/e-mail/Instagram) — o consumidor passa os links. */
19
+ chips?: ReactNode[];
20
+ /** Seletor de idioma (slot). */
21
+ localeSwitcher?: ReactNode;
22
+ };
23
+
24
+ /**
25
+ * Footer da família Cuidado Global. Cartão grande arredondado em `surface`,
26
+ * com marca + navegação + contato, um bloco opcional em destaque, e a barra
27
+ * inferior com legal, chips de contato e idiomas. 100% data-driven e sem
28
+ * dependência de app (Link/ícones/logo vêm por props). Tokens: ink/ink-soft
29
+ * sobre surface, hairlines `line`.
30
+ */
31
+ export function Footer({
32
+ logo,
33
+ tagline,
34
+ columns,
35
+ contact,
36
+ notice,
37
+ legal,
38
+ chips,
39
+ localeSwitcher,
40
+ }: Props) {
41
+ return (
42
+ <footer className="px-3 pb-3 md:px-4 md:pb-4">
43
+ <div className="mx-auto overflow-hidden rounded-[clamp(24px,3vw,36px)] bg-surface px-[clamp(24px,5vw,72px)] py-[clamp(40px,5vw,64px)] text-ink">
44
+ {/* Topo: marca · colunas · contato */}
45
+ <div className="grid gap-[clamp(32px,5vw,64px)] lg:grid-cols-[1.2fr_1.4fr_1fr]">
46
+ <div>
47
+ <div className="mb-5">{logo}</div>
48
+ {tagline && (
49
+ <p className="max-w-[28ch] text-[1.02rem] leading-[1.5] text-ink-soft">
50
+ {tagline}
51
+ </p>
52
+ )}
53
+ </div>
54
+
55
+ <div className="grid grid-cols-2 gap-x-6 gap-y-3">
56
+ {columns.map((col, i) => (
57
+ <div key={i} className={columns.length === 1 ? "col-span-2" : ""}>
58
+ <p className="mb-4 font-body text-[0.78rem] font-semibold tracking-[0.18em] text-ink-soft uppercase">
59
+ {col.title}
60
+ </p>
61
+ {/* links herdam hover-underline: sublinhado some→aparece suave,
62
+ agnóstico ao que o site passa (<a> ou <Link>) */}
63
+ <ul className="flex flex-col gap-3 [&_a]:underline [&_a]:decoration-transparent [&_a]:underline-offset-[5px] [&_a]:transition-[text-decoration-color] [&_a]:duration-200 [&_a:hover]:decoration-current">
64
+ {col.items.map((item, j) => (
65
+ <li key={j} className="text-[1.02rem] leading-snug text-ink">
66
+ {item}
67
+ </li>
68
+ ))}
69
+ </ul>
70
+ </div>
71
+ ))}
72
+ </div>
73
+
74
+ <div>
75
+ {contact.title && (
76
+ <p className="mb-4 font-body text-[0.78rem] font-semibold tracking-[0.18em] text-ink-soft uppercase">
77
+ {contact.title}
78
+ </p>
79
+ )}
80
+ <div className="space-y-2 text-[1.02rem] leading-[1.5] text-ink-soft">
81
+ {contact.lines.map((line, i) => (
82
+ <p key={i}>{line}</p>
83
+ ))}
84
+ </div>
85
+ {contact.actions && <div className="mt-4">{contact.actions}</div>}
86
+ </div>
87
+ </div>
88
+
89
+ {/* Bloco opcional em destaque */}
90
+ {notice && (
91
+ <div className="mt-[clamp(32px,4vw,48px)] rounded-2xl bg-ink/[0.04] p-[clamp(24px,3.5vw,44px)]">
92
+ {notice}
93
+ </div>
94
+ )}
95
+
96
+ {/* Barra inferior: legal + chips */}
97
+ <div className="mt-[clamp(32px,4vw,48px)] flex flex-col gap-5 border-t border-line pt-[clamp(20px,2.5vw,28px)] md:flex-row md:items-center md:justify-between">
98
+ {legal && <p className="text-sm text-ink-soft">{legal}</p>}
99
+ {chips && chips.length > 0 && (
100
+ <div className="flex flex-wrap items-center gap-2.5">
101
+ {chips.map((chip, i) => (
102
+ <span key={i}>{chip}</span>
103
+ ))}
104
+ </div>
105
+ )}
106
+ </div>
107
+
108
+ {/* Idiomas */}
109
+ {localeSwitcher && (
110
+ <div className="mt-[clamp(20px,2.5vw,28px)]">{localeSwitcher}</div>
111
+ )}
112
+ </div>
113
+ </footer>
114
+ );
115
+ }
@@ -0,0 +1,172 @@
1
+ "use client";
2
+
3
+ import { useState, type ReactNode, type ElementType } from "react";
4
+
5
+ export type NavChild = {
6
+ label: ReactNode;
7
+ href: string;
8
+ icon?: ReactNode;
9
+ description?: ReactNode;
10
+ };
11
+ export type NavItem = {
12
+ label: ReactNode;
13
+ href?: string;
14
+ active?: boolean;
15
+ /** Ícone antes do rótulo (ex.: casa no "Início"). */
16
+ icon?: ReactNode;
17
+ /** Só o ícone aparece; o rótulo fica para leitor de tela (padrão dos sites: "Início" é a casa). */
18
+ iconOnly?: boolean;
19
+ children?: NavChild[];
20
+ /** Link final do dropdown. Seta → (vai para outra página do site); com
21
+ * `external` vira ↗ (sai para outro site), como nos demais componentes. */
22
+ seeAll?: { label: ReactNode; href: string; external?: boolean };
23
+ };
24
+
25
+ type Props = {
26
+ /** Marca (passe o seu logo, já com link para a home se quiser). */
27
+ logo: ReactNode;
28
+ items: NavItem[];
29
+ /** Componente de link do app (ex.: next-intl Link). Default: <a>. */
30
+ linkComponent?: ElementType;
31
+ /** CTA à direita (ex.: Agendar/WhatsApp). */
32
+ cta?: ReactNode;
33
+ /** Seletor de idioma (slot). */
34
+ localeSwitcher?: ReactNode;
35
+ };
36
+
37
+ function Chevron({ open }: { open: boolean }) {
38
+ return (
39
+ <svg
40
+ aria-hidden="true"
41
+ viewBox="0 0 24 24"
42
+ className={`h-4 w-4 transition-transform duration-200 motion-reduce:transition-none ${open ? "rotate-180" : ""}`}
43
+ fill="none"
44
+ stroke="currentColor"
45
+ strokeWidth="2.25"
46
+ strokeLinecap="round"
47
+ strokeLinejoin="round"
48
+ >
49
+ <path d="M6 9l6 6 6-6" />
50
+ </svg>
51
+ );
52
+ }
53
+
54
+ const linkCls = (active?: boolean) =>
55
+ `relative flex min-h-11 items-center gap-1.5 font-heading text-[0.95rem] font-semibold whitespace-nowrap text-ink underline decoration-2 underline-offset-[6px] ${
56
+ active ? "decoration-ink" : "decoration-transparent hover:decoration-ink/40"
57
+ } transition-[text-decoration-color] duration-150 motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ink`;
58
+
59
+ function Dropdown({ item, L }: { item: NavItem; L: ElementType }) {
60
+ const [open, setOpen] = useState(false);
61
+ return (
62
+ <div
63
+ className="relative"
64
+ onMouseEnter={() => setOpen(true)}
65
+ onMouseLeave={() => setOpen(false)}
66
+ onBlur={(e) => {
67
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) setOpen(false);
68
+ }}
69
+ onKeyDown={(e) => e.key === "Escape" && setOpen(false)}
70
+ >
71
+ <span className="flex items-center gap-0.5">
72
+ {item.href ? (
73
+ <L href={item.href} className={linkCls(item.active)}>
74
+ {item.label}
75
+ </L>
76
+ ) : (
77
+ <span className={linkCls(item.active)}>{item.label}</span>
78
+ )}
79
+ <button
80
+ type="button"
81
+ aria-expanded={open}
82
+ aria-label="Abrir submenu"
83
+ onClick={() => setOpen((o) => !o)}
84
+ className="flex min-h-11 items-center rounded-md px-0.5 text-ink/70 transition-colors hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink"
85
+ >
86
+ <Chevron open={open} />
87
+ </button>
88
+ </span>
89
+
90
+ <div className={`absolute top-full left-0 z-50 pt-2 ${open ? "" : "pointer-events-none"}`}>
91
+ <ul
92
+ className={`w-[19rem] list-none rounded-2xl border border-line bg-surface p-2 shadow-[0_18px_40px_-16px_rgba(20,71,83,0.4)] transition duration-150 ease-out motion-reduce:transition-none ${
93
+ open ? "visible scale-100 opacity-100" : "invisible -translate-y-1 scale-[0.98] opacity-0"
94
+ }`}
95
+ >
96
+ {item.children!.map((c, i) => (
97
+ <li key={i}>
98
+ <L
99
+ href={c.href}
100
+ onClick={() => setOpen(false)}
101
+ className="flex items-start gap-3 rounded-xl px-2.5 py-2 transition-colors hover:bg-ink/[0.05] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink"
102
+ >
103
+ {c.icon && (
104
+ <span className="flex h-9 w-9 flex-none items-center justify-center rounded-full bg-ink/[0.08] text-ink">
105
+ {c.icon}
106
+ </span>
107
+ )}
108
+ <span>
109
+ <span className="block font-heading text-[0.95rem] font-semibold text-ink">
110
+ {c.label}
111
+ </span>
112
+ {c.description && (
113
+ <span className="mt-0.5 block text-sm text-ink-soft">{c.description}</span>
114
+ )}
115
+ </span>
116
+ </L>
117
+ </li>
118
+ ))}
119
+ {item.seeAll && (
120
+ <li className="mt-1 border-t border-line pt-1">
121
+ <L
122
+ href={item.seeAll.href}
123
+ onClick={() => setOpen(false)}
124
+ className="flex items-center justify-between gap-2 rounded-xl px-3.5 py-2.5 font-heading text-[0.92rem] font-semibold text-ink transition-colors hover:bg-ink/[0.05]"
125
+ >
126
+ {item.seeAll.label}
127
+ <svg aria-hidden="true" viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
128
+ {item.seeAll.external ? <path d="M7 17 17 7M9 7h8v8" /> : <path d="M5 12h14M13 6l6 6-6 6" />}
129
+ </svg>
130
+ </L>
131
+ </li>
132
+ )}
133
+ </ul>
134
+ </div>
135
+ </div>
136
+ );
137
+ }
138
+
139
+ /**
140
+ * Header da família Cuidado Global. No desktop (lg+): marca + nav com dropdowns
141
+ * (hover/teclado, ícones nos subitens) + idioma + CTA. No mobile: marca +
142
+ * idioma + CTA (a navegação vai para o BottomNav). Deps injetáveis: `logo`,
143
+ * `cta`, `localeSwitcher` por slot; o `Link` do app por `linkComponent`.
144
+ */
145
+ export function Header({ logo, items, linkComponent, cta, localeSwitcher }: Props) {
146
+ const L: ElementType = linkComponent ?? "a";
147
+ return (
148
+ <header className="sticky top-0 z-50 bg-surface/90 backdrop-blur">
149
+ <div className="mx-auto flex h-20 w-full max-w-[1600px] items-center justify-between gap-3 px-4 md:px-6">
150
+ <div className="shrink-0">{logo}</div>
151
+
152
+ <nav aria-label="Principal" className="hidden items-center gap-6 lg:flex xl:gap-7">
153
+ {items.map((item, i) =>
154
+ item.children?.length ? (
155
+ <Dropdown key={i} item={item} L={L} />
156
+ ) : (
157
+ <L key={i} href={item.href} aria-current={item.active ? "page" : undefined} className={linkCls(item.active)}>
158
+ {item.icon && <span aria-hidden="true" className="flex">{item.icon}</span>}
159
+ {item.iconOnly ? <span className="sr-only">{item.label}</span> : item.label}
160
+ </L>
161
+ )
162
+ )}
163
+ </nav>
164
+
165
+ <div className="flex items-center gap-2">
166
+ {localeSwitcher}
167
+ {cta}
168
+ </div>
169
+ </div>
170
+ </header>
171
+ );
172
+ }
@@ -0,0 +1,184 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ import { BreatheMark } from "./BreatheMark";
3
+
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.
11
+ */
12
+ export type LockupVariant = "stacked" | "horizontal" | "wordmark" | "symbol" | "merged";
13
+ type Size = "sm" | "md" | "lg";
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). */
19
+ const NAME: Record<Size, string> = { sm: "text-2xl", md: "text-[2.125rem]", lg: "text-[2.7rem]" };
20
+ const TAG: Record<Size, string> = { sm: "text-[0.78rem]", md: "text-sm", lg: "text-base" };
21
+ const MARK: Record<Size, number> = { sm: 32, md: 44, lg: 56 };
22
+ const GAP: Record<Size, string> = { sm: "gap-2", md: "gap-2.5", lg: "gap-3" };
23
+ const NAME_PX: Record<Size, number> = { sm: 24, md: 34, lg: 43 };
24
+ const TILE: Record<Size, string> = { sm: "size-16 text-[0.7rem]", md: "size-24 text-[0.95rem]", lg: "size-32 text-[1.3rem]" };
25
+
26
+ type Props = {
27
+ variant?: LockupVariant;
28
+ /** Nome da marca (cada site passa o seu). */
29
+ name?: string;
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. */
33
+ logo?: ReactNode;
34
+ /** Wordmark desenhado (svg) no lugar do nome em texto. `merged` ignora
35
+ * (lá o nome é texto). */
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`. */
39
+ wordmarkRatio?: number;
40
+ /** Lado do símbolo no horizontal. Padrão `left` (creeta e os demais); o TTCAB
41
+ * é `right` (`TTCAB_LOCKUP.symbolSide`). */
42
+ symbolSide?: "left" | "right";
43
+ size?: Size;
44
+ /** O símbolo "respira"? Padrão `false` — logo de cabeçalho não fica animando. */
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. */
49
+ entrance?: boolean;
50
+ /** Atraso da entrada em ms (ex.: esperar a Cortina subir). */
51
+ entranceDelay?: number;
52
+ className?: string;
53
+ };
54
+
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).
59
+ */
60
+ export function Lockup({
61
+ variant = "horizontal",
62
+ name = "Breathe",
63
+ tagline,
64
+ logo,
65
+ wordmark,
66
+ wordmarkRatio = 0.5,
67
+ symbolSide = "left",
68
+ size = "md",
69
+ animated = false,
70
+ entrance = false,
71
+ entranceDelay = 0,
72
+ className = "",
73
+ }: Props) {
74
+ const entranceCls = entrance ? " cg-lockup--entrance" : "";
75
+ 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.
78
+ const markPx = variant === "horizontal" && tagline && symbolSide !== "right" ? Math.round(NAME_PX[size] * 2.3) : MARK[size];
79
+ const mark = (
80
+ <span className="cg-lockup__mark inline-flex text-ink [&>svg]:h-[var(--mark)] [&>svg]:w-auto" style={{ "--mark": `${markPx}px` } as CSSProperties}>
81
+ {logo ?? <BreatheMark size={markPx} animated={animated} title={name} />}
82
+ </span>
83
+ );
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.
86
+ const nameEl = (
87
+ <span
88
+ className={`cg-lockup__name font-heading font-bold leading-none tracking-[-0.02em] text-ink ${NAME[size]}`}
89
+ style={wordmark ? ({ "--cg-wordmark": `${Math.round(MARK[size] * wordmarkRatio * 10) / 10}px` } as CSSProperties) : undefined}
90
+ >
91
+ {wordmark ?? name}
92
+ </span>
93
+ );
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.
96
+ const tagEl = tagline ? (
97
+ <span className={`cg-lockup__tag block font-body leading-[1.3] text-ink-soft ${TAG[size]}`}>{tagline}</span>
98
+ ) : null;
99
+
100
+ if (variant === "symbol") {
101
+ return (
102
+ <span className={`inline-flex text-ink ${className}`} role="img" aria-label={name}>
103
+ {mark}
104
+ </span>
105
+ );
106
+ }
107
+
108
+ if (variant === "wordmark") {
109
+ return (
110
+ <span className={`inline-flex flex-col${entranceCls} ${className}`} style={entranceStyle}>
111
+ {entrance && <style>{ENTRANCE_CSS}</style>}
112
+ {nameEl}
113
+ {tagEl && <span className="mt-1">{tagEl}</span>}
114
+ </span>
115
+ );
116
+ }
117
+
118
+ if (variant === "stacked") {
119
+ return (
120
+ <span className={`inline-flex flex-col items-center text-center${entranceCls} ${className}`} style={entranceStyle}>
121
+ {entrance && <style>{ENTRANCE_CSS}</style>}
122
+ {mark}
123
+ <span className="mt-4 flex flex-col items-center">
124
+ {nameEl}
125
+ {tagEl && <span className="mt-1.5">{tagEl}</span>}
126
+ </span>
127
+ </span>
128
+ );
129
+ }
130
+
131
+ 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.
134
+ return (
135
+ <span
136
+ 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}`}
137
+ role="img"
138
+ aria-label={name}
139
+ >
140
+ {name}
141
+ </span>
142
+ );
143
+ }
144
+
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).
147
+ if (symbolSide === "right") {
148
+ return (
149
+ <span className={`inline-flex flex-col items-start${entranceCls} ${className}`} style={entranceStyle}>
150
+ {entrance && <style>{ENTRANCE_CSS}</style>}
151
+ <span className={`inline-flex items-center ${GAP[size]}`}>
152
+ {nameEl}
153
+ {mark}
154
+ </span>
155
+ {tagEl && <span className="mt-1">{tagEl}</span>}
156
+ </span>
157
+ );
158
+ }
159
+ return (
160
+ <span className={`inline-flex items-center ${GAP[size]}${entranceCls} ${className}`} style={entranceStyle}>
161
+ {entrance && <style>{ENTRANCE_CSS}</style>}
162
+ {mark}
163
+ <span className="flex min-w-0 flex-col">
164
+ {nameEl}
165
+ {tagEl && <span className="mt-1">{tagEl}</span>}
166
+ </span>
167
+ </span>
168
+ );
169
+ }
170
+
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. */
173
+ const ENTRANCE_CSS = `
174
+ .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
+ .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; }
176
+ .cg-lockup--entrance .cg-lockup__tag{ animation: cg-lockup-tag 360ms ease calc(var(--cg-entrance-delay,0ms) + 700ms) both; }
177
+ @keyframes cg-lockup-mark{ from{ opacity:0; transform:scale(.6) rotate(-16deg); } }
178
+ @keyframes cg-lockup-name{ from{ opacity:0; transform:translateX(-8px); } }
179
+ @keyframes cg-lockup-tag{ from{ opacity:0; transform:translateY(4px); } }
180
+ @media (prefers-reduced-motion:reduce){
181
+ .cg-lockup--entrance .cg-lockup__mark, .cg-lockup--entrance .cg-lockup__name, .cg-lockup--entrance .cg-lockup__tag{ animation: cg-lockup-fade 240ms ease var(--cg-entrance-delay,0ms) both; }
182
+ @keyframes cg-lockup-fade{ from{ opacity:0; } }
183
+ }
184
+ `;