@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,300 @@
1
+ "use client";
2
+
3
+ import { useEffect, useId, useRef, useState, type ComponentType, type ElementType, type ReactNode } from "react";
4
+
5
+ /** Ícone no padrão lucide (o componente, não o nó): a barra decide o tamanho. */
6
+ export type IconType = ComponentType<{ size?: number; strokeWidth?: number; className?: string; "aria-hidden"?: boolean | "true" }>;
7
+
8
+ /** Uma página do site, em ordem de importância. As primeiras ficam na barra;
9
+ * as que não couberem vão para o "Mais". */
10
+ export type BottomPage = {
11
+ href: string;
12
+ label: ReactNode;
13
+ Icon: IconType;
14
+ /** A página atual é esta? (o app decide: rota/idioma são dele) */
15
+ active?: boolean;
16
+ };
17
+
18
+ /** Saída extra do painel "Mais" (ex.: cursos em outro site): endereço escrito
19
+ * embaixo e seta para fora, como no novo.creeta. */
20
+ export type BottomExtra = {
21
+ href: string;
22
+ label: ReactNode;
23
+ Icon: IconType;
24
+ /** Linha menor sob o rótulo (ex.: o domínio). Com `external` e sem isto, mostra o domínio do href. */
25
+ description?: ReactNode;
26
+ external?: boolean;
27
+ active?: boolean;
28
+ };
29
+
30
+ type Labels = {
31
+ /** aria-label da barra */
32
+ nav: string;
33
+ /** rótulo da aba "Mais" */
34
+ more: string;
35
+ /** aria-label do painel */
36
+ morePages: string;
37
+ /** sr-only depois de "Mais": "mais páginas" */
38
+ morePagesSr: string;
39
+ /** sr-only quando a página atual está no "Mais" */
40
+ currentHere: string;
41
+ /** sr-only em link externo */
42
+ newTab: string;
43
+ };
44
+
45
+ const PT: Labels = {
46
+ nav: "Navegação principal",
47
+ more: "Mais",
48
+ morePages: "Mais páginas",
49
+ morePagesSr: "mais páginas",
50
+ currentHere: "(a página atual está aqui)",
51
+ newTab: "(abre em outra aba)",
52
+ };
53
+
54
+ type Props = {
55
+ pages: BottomPage[];
56
+ extras?: BottomExtra[];
57
+ /** Componente de link do app (ex.: next-intl Link). Default: <a>. */
58
+ linkComponent?: ElementType;
59
+ /** A partir de que largura o header assume e a barra some. creeta: `md`; ttcab: `lg`. */
60
+ hideFrom?: "md" | "lg";
61
+ /** Textos (padrão pt-BR); o app passa os do idioma. */
62
+ labels?: Partial<Labels>;
63
+ /** Embutida num contêiner `relative` (demo, moldura de celular): barra, véu e
64
+ * painel ficam `absolute` dentro dele e aparecem em qualquer largura. */
65
+ embedded?: boolean;
66
+ };
67
+
68
+ /** Largura mínima de uma aba, em px: abaixo disso o rótulo não cabe. */
69
+ export const TAB_MIN_WIDTH = 64;
70
+
71
+ /** Quantas páginas aparecem direto na barra, para uma largura útil. A última
72
+ * aba é sempre o "Mais" (é nele que ficam as páginas que não couberam e as
73
+ * saídas extras), então cabe uma página a menos que o número de abas. */
74
+ export function howManyFit(width: number, total: number): number {
75
+ const tabs = Math.floor(width / TAB_MIN_WIDTH);
76
+ return Math.max(2, Math.min(total, tabs - 1));
77
+ }
78
+
79
+ const MenuIcon: IconType = ({ size = 20, strokeWidth = 1.75 }) => (
80
+ <svg aria-hidden="true" viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round"><path d="M4 6h16M4 12h16M4 18h16" /></svg>
81
+ );
82
+ const ArrowUpRight = () => (
83
+ <svg aria-hidden="true" viewBox="0 0 24 24" className="h-[18px] w-[18px] shrink-0" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
84
+ );
85
+
86
+ // Aba: coluna ícone+rótulo. Ativa ganha FORMA (pílula clara), não só cor —
87
+ // legível pra quem não distingue cor. Encolhe no toque, como botão de app.
88
+ // O nome de transição faz o destaque deslizar entre as abas na troca de página.
89
+ const tab = (active: boolean) =>
90
+ [
91
+ "flex h-14 w-full flex-col items-center justify-center gap-1 rounded-[20px] px-1 text-[10.5px] font-medium leading-none tracking-tight",
92
+ "transition duration-150 active:scale-[0.96] motion-reduce:transition-none motion-reduce:active:scale-100",
93
+ "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-surface",
94
+ active ? "bg-surface text-ink [view-transition-name:aba-ativa]" : "text-surface/85 hover:bg-white/10",
95
+ ].join(" ");
96
+
97
+ const row = (active?: boolean) =>
98
+ `flex min-h-14 items-center gap-3 rounded-[18px] px-3 font-medium transition-colors duration-150 active:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none ${
99
+ active ? "bg-surface text-ink" : "text-ink"
100
+ }`;
101
+ const bubble = "flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-ink/[0.12] text-ink";
102
+
103
+ /**
104
+ * Navegação principal no celular: barra flutuante ao alcance do polegar, como
105
+ * em app. É o código da BarraNavegacao do novo.creeta (e do BottomNav do
106
+ * ttcab), neutro por tokens: `ink` na barra, `surface` na aba ativa.
107
+ *
108
+ * - Dinâmica: mostra quantas páginas couberem na largura (ResizeObserver,
109
+ * 64px por aba). A última aba é sempre o "Mais", com o resto e as saídas
110
+ * extras. Num celular comum são quatro páginas e o "Mais".
111
+ * - Véu atrás da barra: o conteúdo some aos poucos antes de chegar nela.
112
+ * - O painel "Mais" é um popover nativo: fecha com Esc e com toque fora,
113
+ * devolve o foco ao botão e abre mesmo sem JavaScript. Embutido (demo),
114
+ * vira um painel absoluto dentro da moldura, com a mesma entrada.
115
+ * - Some a partir de `hideFrom` (md no creeta, lg no ttcab), onde o header assume.
116
+ */
117
+ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg", labels, embedded = false }: Props) {
118
+ const L: ElementType = linkComponent ?? "a";
119
+ const t = { ...PT, ...labels };
120
+ const list = useRef<HTMLUListElement>(null);
121
+ const panel = useRef<HTMLDivElement>(null);
122
+ const wrap = useRef<HTMLDivElement>(null);
123
+ const panelId = useId();
124
+ // antes de medir: o caso do celular comum (5 abas = 4 páginas + Mais)
125
+ const [visible, setVisible] = useState(() => howManyFit(5 * TAB_MIN_WIDTH, pages.length));
126
+ const [open, setOpen] = useState(false); // só no modo embutido
127
+
128
+ useEffect(() => {
129
+ const ul = list.current;
130
+ if (!ul || typeof ResizeObserver !== "function") return;
131
+ const measure = () => {
132
+ const cs = getComputedStyle(ul);
133
+ const useful = ul.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
134
+ if (useful > 0) setVisible(howManyFit(useful, pages.length));
135
+ };
136
+ measure();
137
+ const ro = new ResizeObserver(measure);
138
+ ro.observe(ul);
139
+ return () => ro.disconnect();
140
+ }, [pages.length]);
141
+
142
+ const close = () => {
143
+ if (embedded) return setOpen(false);
144
+ const el = panel.current as (HTMLDivElement & { hidePopover?: () => void }) | null;
145
+ try {
146
+ if (el?.matches(":popover-open")) el.hidePopover?.();
147
+ } catch {
148
+ /* navegador sem popover: não há o que fechar */
149
+ }
150
+ };
151
+
152
+ // Trocou de página: o painel fecha.
153
+ const activeHref = pages.find((p) => p.active)?.href;
154
+ useEffect(close, [activeHref]); // eslint-disable-line react-hooks/exhaustive-deps
155
+
156
+ // Embutido: fecha com Esc e com toque fora (o popover nativo faz isso sozinho).
157
+ useEffect(() => {
158
+ if (!embedded || !open) return;
159
+ const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
160
+ const onDown = (e: PointerEvent) => {
161
+ if (wrap.current && !wrap.current.contains(e.target as Node)) setOpen(false);
162
+ };
163
+ document.addEventListener("keydown", onKey);
164
+ const id = window.setTimeout(() => document.addEventListener("pointerdown", onDown), 0);
165
+ return () => {
166
+ window.clearTimeout(id);
167
+ document.removeEventListener("keydown", onKey);
168
+ document.removeEventListener("pointerdown", onDown);
169
+ };
170
+ }, [embedded, open]);
171
+
172
+ const onBar = pages.slice(0, visible);
173
+ const inMore = pages.slice(visible);
174
+ const moreActive = inMore.some((p) => p.active) || extras.some((x) => x.active);
175
+ const pos = embedded ? "absolute" : "fixed";
176
+ const hide = embedded ? "" : ` ${hideFrom}:hidden`;
177
+
178
+ const panelInner = (
179
+ <nav aria-label={t.morePages}>
180
+ {/* Um item por linha: em duas colunas o painel ficava confuso. */}
181
+ {inMore.length > 0 && (
182
+ <ul className="grid list-none gap-1">
183
+ {inMore.map(({ href, label, Icon, active }, i) => (
184
+ <li key={`${i}-${href}`}>
185
+ <L href={href} aria-current={active ? "page" : undefined} onClick={close} className={row(active)}>
186
+ <span className={bubble}><Icon aria-hidden="true" size={19} strokeWidth={1.75} /></span>
187
+ {label}
188
+ </L>
189
+ </li>
190
+ ))}
191
+ </ul>
192
+ )}
193
+ {extras.length > 0 && (
194
+ <ul className={`grid list-none gap-1${inMore.length > 0 ? " mt-1 border-t border-ink/10 pt-1" : ""}`}>
195
+ {extras.map(({ href, label, Icon, description, external, active }, i) => {
196
+ const Tag: ElementType = external ? "a" : L;
197
+ const desc = description ?? (external ? href.replace(/^https?:\/\//, "").replace(/\/$/, "") : null);
198
+ return (
199
+ <li key={`${i}-${href}`}>
200
+ <Tag
201
+ href={href}
202
+ {...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
203
+ aria-current={active ? "page" : undefined}
204
+ onClick={close}
205
+ className={`${row(active)} py-2`}
206
+ >
207
+ <span className={bubble}><Icon aria-hidden="true" size={19} strokeWidth={1.75} /></span>
208
+ <span className="flex-1">
209
+ {label}
210
+ {desc && <span className="block text-xs font-normal text-ink/80">{desc}</span>}
211
+ {external && <span className="sr-only"> {t.newTab}</span>}
212
+ </span>
213
+ {external && <span className="text-ink"><ArrowUpRight /></span>}
214
+ </Tag>
215
+ </li>
216
+ );
217
+ })}
218
+ </ul>
219
+ )}
220
+ </nav>
221
+ );
222
+
223
+ return (
224
+ <div ref={wrap} className={embedded ? "contents" : undefined}>
225
+ <style>{CSS}</style>
226
+ {/* Véu atrás da barra: o conteúdo que rola some aos poucos antes de
227
+ chegar nela, em vez de aparecer cortado pelas bordas. */}
228
+ <div
229
+ aria-hidden="true"
230
+ className={`pointer-events-none ${pos} inset-x-0 bottom-0 z-40 h-28 bg-gradient-to-t from-surface from-35% via-surface/85 via-65% to-transparent [view-transition-name:veu]${hide}`}
231
+ />
232
+ <nav
233
+ aria-label={t.nav}
234
+ className={`${pos} inset-x-3 bottom-3 z-50 pb-[env(safe-area-inset-bottom)] [view-transition-name:barra]${hide}`}
235
+ >
236
+ <ul
237
+ ref={list}
238
+ className="flex list-none items-stretch justify-between gap-0.5 rounded-[26px] bg-ink/95 p-1.5 shadow-[0_12px_32px_-12px_rgba(0,0,0,0.5)] backdrop-blur-md"
239
+ >
240
+ {onBar.map(({ href, label, Icon, active }, i) => (
241
+ <li key={`${i}-${href}`} className="min-w-0 flex-1">
242
+ <L href={href} aria-current={active ? "page" : undefined} className={tab(!!active)}>
243
+ <Icon aria-hidden="true" size={20} strokeWidth={1.75} />
244
+ <span className="w-full truncate text-center">{label}</span>
245
+ </L>
246
+ </li>
247
+ ))}
248
+ <li className="min-w-0 flex-1">
249
+ {/* popovertarget em minúsculas: o React 18 não conhece o atributo e o repassa como está. */}
250
+ <button
251
+ type="button"
252
+ {...(embedded ? { "aria-expanded": open, "aria-controls": panelId, onClick: () => setOpen((o) => !o) } : { popovertarget: panelId })}
253
+ className={tab(embedded ? open || moreActive : moreActive)}
254
+ >
255
+ <MenuIcon />
256
+ <span className="w-full truncate text-center">{t.more}</span>
257
+ <span className="sr-only"> {t.morePagesSr}{moreActive ? ` ${t.currentHere}` : ""}</span>
258
+ </button>
259
+ </li>
260
+ </ul>
261
+ </nav>
262
+
263
+ {embedded ? (
264
+ open && (
265
+ <div id={panelId} className="cg-mais-painel cg-mais-painel--embutido">
266
+ {panelInner}
267
+ </div>
268
+ )
269
+ ) : (
270
+ <div ref={panel} id={panelId} {...{ popover: "auto" }} className={`cg-mais-painel cg-mais-painel--${hideFrom}`}>
271
+ {panelInner}
272
+ </div>
273
+ )}
274
+ </div>
275
+ );
276
+ }
277
+
278
+ /* Painel "Mais": logo acima da barra; entra crescendo a partir do canto do
279
+ botão "Mais", em 0,18s (igual ao .mais-painel do novo.creeta). */
280
+ const CSS = `
281
+ .cg-mais-painel{
282
+ position:fixed; inset:auto .75rem calc(5.75rem + env(safe-area-inset-bottom)) .75rem;
283
+ width:auto; margin:0; border:0; border-radius:26px; padding:.5rem;
284
+ background:#fff; color:var(--color-ink);
285
+ box-shadow:0 20px 48px -16px rgb(0 0 0 / .35), 0 1px 2px rgb(0 0 0 / .1);
286
+ opacity:0; transform:translateY(8px) scale(.97); transform-origin:88% 100%;
287
+ transition:opacity 180ms cubic-bezier(.23,1,.32,1), transform 180ms cubic-bezier(.23,1,.32,1), overlay 180ms allow-discrete, display 180ms allow-discrete;
288
+ }
289
+ .cg-mais-painel:popover-open, .cg-mais-painel--embutido{ opacity:1; transform:none; }
290
+ @starting-style{ .cg-mais-painel:popover-open, .cg-mais-painel--embutido{ opacity:0; transform:translateY(8px) scale(.97); } }
291
+ .cg-mais-painel::backdrop{ background:transparent; }
292
+ .cg-mais-painel--embutido{ position:absolute; z-index:50; }
293
+ @media (prefers-reduced-motion:reduce){
294
+ .cg-mais-painel{ transform:none; transition:opacity 120ms ease; }
295
+ @starting-style{ .cg-mais-painel:popover-open, .cg-mais-painel--embutido{ transform:none; } }
296
+ }
297
+ /* A partir do breakpoint a barra não existe: o painel também não. */
298
+ @media (min-width:768px){ .cg-mais-painel--md{ display:none !important; } }
299
+ @media (min-width:1024px){ .cg-mais-painel--lg{ display:none !important; } }
300
+ `;
@@ -0,0 +1,67 @@
1
+ type Props = {
2
+ /** Lado em px (quadrado). É o tamanho do glifo visível: a caixa do SVG é
3
+ * justa ao anel + ponto, sem ar interno (o espaço entre símbolo e nome
4
+ * fica a cargo do layout). */
5
+ size?: number;
6
+ /** Segundos de um ciclo respiratório completo (inspira+expira). */
7
+ duration?: number;
8
+ /** Anima? (ainda respeita prefers-reduced-motion). */
9
+ animated?: boolean;
10
+ className?: string;
11
+ title?: string;
12
+ };
13
+
14
+ /**
15
+ * Símbolo vivo do Breathe — um anel aberto (gesto fluido, contínuo) com um
16
+ * ponto no respiro, abstração do logotipo do creeta (forma redonda + ponto).
17
+ * NEUTRO: usa currentColor, herda a cor do contexto (a paleta vem de cada app).
18
+ * "Respira" num pulso suave de escala; respeita prefers-reduced-motion.
19
+ */
20
+ export function BreatheMark({
21
+ size = 96,
22
+ duration = 6,
23
+ animated = true,
24
+ className,
25
+ title = "Breathe",
26
+ }: Props) {
27
+ return (
28
+ <span className={className} style={{ display: "inline-flex", lineHeight: 0 }}>
29
+ <style>{`
30
+ @keyframes breathe-mark-inhale {
31
+ 0%, 100% { transform: scale(0.93); }
32
+ 50% { transform: scale(1.05); }
33
+ }
34
+ .breathe-mark__g {
35
+ transform-box: fill-box;
36
+ transform-origin: center;
37
+ animation: breathe-mark-inhale var(--breathe-dur, 6s) cubic-bezier(0.37, 0, 0.63, 1) infinite;
38
+ }
39
+ .breathe-mark--static .breathe-mark__g { animation: none; }
40
+ @media (prefers-reduced-motion: reduce) { .breathe-mark__g { animation: none; } }
41
+ `}</style>
42
+ <svg
43
+ width={size}
44
+ height={size}
45
+ viewBox="10.5 10 43 43"
46
+ overflow="visible"
47
+ role="img"
48
+ aria-label={title}
49
+ className={animated ? "" : "breathe-mark--static"}
50
+ style={{ ["--breathe-dur" as string]: `${duration}s` }}
51
+ >
52
+ <g className="breathe-mark__g">
53
+ {/* anel aberto — gesto contínuo com um respiro no topo */}
54
+ <path
55
+ d="M 42 17.5 A 17 17 0 1 1 22 17.5"
56
+ fill="none"
57
+ stroke="currentColor"
58
+ strokeWidth="8.5"
59
+ strokeLinecap="round"
60
+ />
61
+ {/* ponto do respiro */}
62
+ <circle cx="32" cy="14.5" r="4.4" fill="currentColor" />
63
+ </g>
64
+ </svg>
65
+ </span>
66
+ );
67
+ }
@@ -0,0 +1,68 @@
1
+ import type { ReactNode, ButtonHTMLAttributes, AnchorHTMLAttributes } from "react";
2
+
3
+ type Variant = "solid" | "accent" | "outline" | "ghost" | "link";
4
+ type Size = "sm" | "md" | "lg";
5
+
6
+ const pillBase =
7
+ "inline-flex items-center justify-center gap-2 rounded-pill font-body font-semibold whitespace-nowrap transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink active:scale-[0.97] motion-reduce:transition-none motion-reduce:active:scale-100 disabled:pointer-events-none disabled:opacity-50";
8
+
9
+ /* Variante `link`: ação de texto sublinhada (não é pílula) — o irmão leve do
10
+ botão, mesma tipografia (font-body semibold). Herda a cor do contexto
11
+ (currentColor), então funciona no claro e sobre fundo escuro. O sublinhado
12
+ fica a 50% e fecha no hover; uma seta (ícone passado nos filhos) dá um passo
13
+ pra cima-e-direita. Ignora `size` (é inline, sem pílula). */
14
+ const linkBase =
15
+ "group inline-flex items-center gap-1.5 font-body font-semibold underline decoration-[1.5px] decoration-current/50 underline-offset-4 transition-[text-decoration-color,text-underline-offset] duration-200 hover:decoration-current hover:underline-offset-[5px] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink rounded-[2px] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none [&>svg]:transition-transform [&>svg]:duration-200 hover:[&>svg]:-translate-y-0.5 hover:[&>svg]:translate-x-0.5 motion-reduce:[&>svg]:transform-none";
16
+
17
+ const variants: Record<Exclude<Variant, "link">, string> = {
18
+ // petróleo sólido, texto creme (ex.: "Falar com a equipe")
19
+ solid: "bg-ink text-surface hover:-translate-y-0.5 shadow-[0_6px_18px_rgba(20,71,83,0.28)]",
20
+ // cor de ação (laranja/magenta) + texto on-action (do token, varia por site)
21
+ accent: "bg-action text-on-action hover:-translate-y-0.5 shadow-[0_6px_18px_rgba(20,71,83,0.22)]",
22
+ outline: "border border-line text-ink hover:bg-ink/[0.05]",
23
+ ghost: "text-ink hover:bg-ink/[0.06]",
24
+ };
25
+
26
+ const sizes: Record<Size, string> = {
27
+ sm: "min-h-9 px-4 text-sm",
28
+ md: "min-h-11 px-5 text-[0.95rem]",
29
+ lg: "min-h-[52px] px-7 text-base",
30
+ };
31
+
32
+ type CommonProps = {
33
+ variant?: Variant;
34
+ size?: Size;
35
+ children: ReactNode;
36
+ className?: string;
37
+ };
38
+
39
+ type ButtonProps = CommonProps & ButtonHTMLAttributes<HTMLButtonElement> & { href?: undefined };
40
+ type LinkProps = CommonProps & AnchorHTMLAttributes<HTMLAnchorElement> & { href: string };
41
+
42
+ /**
43
+ * Button da família Cuidado Global. Polimórfico: com `href` vira `<a>`
44
+ * (injete seu Link via wrapper se precisar de roteador), senão `<button>`.
45
+ * Variantes: solid (petróleo), accent (cor de ação), outline, ghost e
46
+ * `link` (ação de texto sublinhada — ex.: "Ver todos", "Ação secundária",
47
+ * "Ver no Google Maps"; herda a cor, serve no claro e no escuro).
48
+ */
49
+ export function Button(props: ButtonProps | LinkProps) {
50
+ const { variant = "solid", size = "md", className = "", children, ...rest } = props;
51
+ const cls =
52
+ variant === "link"
53
+ ? `${linkBase} ${className}`
54
+ : `${pillBase} ${variants[variant]} ${sizes[size]} ${className}`;
55
+ if ("href" in props && props.href !== undefined) {
56
+ const { href, ...a } = rest as AnchorHTMLAttributes<HTMLAnchorElement> & { href: string };
57
+ return (
58
+ <a href={href} className={cls} {...a}>
59
+ {children}
60
+ </a>
61
+ );
62
+ }
63
+ return (
64
+ <button className={cls} {...(rest as ButtonHTMLAttributes<HTMLButtonElement>)}>
65
+ {children}
66
+ </button>
67
+ );
68
+ }
@@ -0,0 +1,31 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ type Props = {
4
+ children: ReactNode;
5
+ /** Vira link se href for passado. */
6
+ href?: string;
7
+ /** Eleva no hover (para cards clicáveis). */
8
+ interactive?: boolean;
9
+ className?: string;
10
+ };
11
+
12
+ /**
13
+ * Card base da família — estilo decidido no DS: **borda discreta + sombra
14
+ * discreta**, raio 2xl (28px), fundo surface. Com `href` vira link; com
15
+ * `interactive` eleva no hover. Nada de borda forte ou sombra pesada.
16
+ */
17
+ export function Card({ children, href, interactive, className = "" }: Props) {
18
+ const cls = `block rounded-2xl border border-line bg-surface p-[clamp(20px,3vw,32px)] shadow-[0_2px_12px_rgba(20,71,83,0.06)] ${
19
+ interactive || href
20
+ ? "transition duration-200 hover:-translate-y-0.5 hover:shadow-[0_14px_34px_rgba(20,71,83,0.12)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none motion-reduce:hover:translate-y-0"
21
+ : ""
22
+ } ${className}`;
23
+ if (href) {
24
+ return (
25
+ <a href={href} className={cls}>
26
+ {children}
27
+ </a>
28
+ );
29
+ }
30
+ return <div className={cls}>{children}</div>;
31
+ }
@@ -0,0 +1,22 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ type Props = {
4
+ children: ReactNode;
5
+ /** Largura máxima. "content" (padrão) p/ leitura; "wide" p/ heros/rodapé. */
6
+ width?: "content" | "wide";
7
+ className?: string;
8
+ };
9
+
10
+ /**
11
+ * Container da família Cuidado Global — largura máxima + gutter lateral
12
+ * consistente (16px no celular). "content" para texto/seções, "wide" para
13
+ * blocos full-bleed (heros, rodapé).
14
+ */
15
+ export function Container({ children, width = "content", className = "" }: Props) {
16
+ const max = width === "wide" ? "max-w-[1600px]" : "max-w-[1120px]";
17
+ return (
18
+ <div className={`mx-auto w-full ${max} px-4 md:px-6 ${className}`}>
19
+ {children}
20
+ </div>
21
+ );
22
+ }
@@ -0,0 +1,152 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
4
+ import { BreatheMark } from "./BreatheMark";
5
+
6
+ type Props = {
7
+ /** Nome da marca (ex.: "creeta", "TTCAB", "Breathe"). */
8
+ name: ReactNode;
9
+ /** Linha abaixo do nome (ex.: "Centro de Reequilíbrio Toracoabdominal"). */
10
+ tagline?: ReactNode;
11
+ /** Símbolo à esquerda do nome. Padrão: o BreatheMark (anel + ponto). Cada
12
+ * site injeta o seu logotipo aqui. */
13
+ logo?: ReactNode;
14
+ /** Quando `name` é um wordmark svg: altura dele como fração da altura do
15
+ * símbolo, do manual da marca (ex.: `CREETA_LOCKUP.wordmarkPerSymbol`). */
16
+ wordmarkRatio?: number;
17
+ /** Lado do símbolo. Padrão `left`; o TTCAB é `right` (`TTCAB_LOCKUP.symbolSide`). */
18
+ symbolSide?: "left" | "right";
19
+ /** Chave do sessionStorage (quando há o script de cabeça). */
20
+ storageKey?: string;
21
+ /** Chamado quando a cortina termina de subir. */
22
+ onDone?: () => void;
23
+ className?: string;
24
+ };
25
+
26
+ /**
27
+ * Cortina de entrada da família Cuidado Global — tela cheia, na cor do site
28
+ * (tokens surface/ink), com o logotipo + nome se formando no centro e a
29
+ * tagline embaixo; depois ela sobe e mostra a página. Espelha a `Cortina` do
30
+ * creeta, mas neutra: a paleta e o logotipo vêm de cada app.
31
+ *
32
+ * - É toda em CSS (estilo embutido): sem JavaScript ela sobe do mesmo jeito,
33
+ * então nunca fica presa na frente do site.
34
+ * - Não recebe clique nem foco e não existe para leitor de tela (aria-hidden).
35
+ * - Com movimento reduzido (ou impressão) não aparece.
36
+ * - Para aparecer só na 1ª visita da sessão, ponha `cortinaHeadScript()` no
37
+ * <head> (antes da pintura): em visita repetida o <html> já nasce com
38
+ * data-cg-cortina="vista" e a cortina não é desenhada — sem piscar.
39
+ *
40
+ * Ao terminar de subir, o <html> ganha data-cg-cortina-fim (para o topo das
41
+ * páginas "pousar" só depois dela, nunca numa troca de tela) e chama onDone.
42
+ */
43
+ export function Cortina({ name, tagline, logo, wordmarkRatio, symbolSide = "left", storageKey, onDone, className }: Props) {
44
+ const el = useRef<HTMLDivElement>(null);
45
+ const [gone, setGone] = useState(false);
46
+
47
+ useEffect(() => {
48
+ const node = el.current;
49
+ if (!node) return;
50
+ const aoTerminar = (e: AnimationEvent) => {
51
+ if (e.animationName !== "cg-cortina-sobe") return;
52
+ document.documentElement.setAttribute("data-cg-cortina-fim", "");
53
+ try {
54
+ if (storageKey) sessionStorage.setItem(storageKey, "1");
55
+ } catch {
56
+ /* sessionStorage bloqueado: a cortina só aparece de novo, nada quebra */
57
+ }
58
+ onDone?.();
59
+ setGone(true);
60
+ };
61
+ node.addEventListener("animationend", aoTerminar);
62
+ return () => node.removeEventListener("animationend", aoTerminar);
63
+ }, [onDone, storageKey]);
64
+
65
+ if (gone) return null;
66
+
67
+ return (
68
+ <div ref={el} className={`cg-cortina${className ? ` ${className}` : ""}`} aria-hidden="true">
69
+ <style>{CORTINA_CSS}</style>
70
+ {/* Símbolo à esquerda: ele abraça o bloco nome+tagline. À direita (TTCAB):
71
+ ele encosta no nome, na mesma linha, e a tagline fica embaixo — senão
72
+ o símbolo iria parar depois da tagline, longe do wordmark. */}
73
+ {(() => {
74
+ const mark = <span className="cg-cortina__mark">{logo ?? <BreatheMark size={60} duration={5} />}</span>;
75
+ const nameEl = (
76
+ <span
77
+ className="cg-cortina__name"
78
+ style={wordmarkRatio ? ({ "--cg-wordmark": `calc(var(--cg-cortina-mark) * ${wordmarkRatio})` } as CSSProperties) : undefined}
79
+ >
80
+ {name}
81
+ </span>
82
+ );
83
+ const tag = tagline ? <span className="cg-cortina__tag">{tagline}</span> : null;
84
+ return symbolSide === "right" ? (
85
+ <div className="cg-cortina__lockup cg-cortina__lockup--right">
86
+ <span className="cg-cortina__row">{nameEl}{mark}</span>
87
+ {tag}
88
+ </div>
89
+ ) : (
90
+ <div className="cg-cortina__lockup">
91
+ {mark}
92
+ <span className="cg-cortina__text">{nameEl}{tag}</span>
93
+ </div>
94
+ );
95
+ })()}
96
+ </div>
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Script para o <head> (antes da pintura): marca a cortina como vista nesta
102
+ * sessão, para não repetir ao navegar nem ao recarregar. Sem ele, a cortina
103
+ * aparece a cada carregamento.
104
+ * // Next (app/layout):
105
+ * <script dangerouslySetInnerHTML={{ __html: cortinaHeadScript("cg-cortina") }} />
106
+ */
107
+ export function cortinaHeadScript(storageKey = "cg-cortina") {
108
+ const k = JSON.stringify(storageKey);
109
+ return `try{var d=document.documentElement;if(sessionStorage.getItem(${k})){d.setAttribute('data-cg-cortina','vista')}else{sessionStorage.setItem(${k},'1')}}catch(e){}`;
110
+ }
111
+
112
+ const CORTINA_CSS = `
113
+ .cg-cortina{
114
+ --cg-cortina-mark: clamp(40px,7.4vw,60px); /* altura do símbolo */
115
+ position:fixed; inset:0 0 -40px; padding-bottom:40px;
116
+ z-index:120; display:grid; place-items:center;
117
+ border-radius:0 0 32px 32px;
118
+ background:var(--color-surface,#fff); color:var(--color-ink,#14323a);
119
+ box-shadow:0 24px 60px -20px rgba(0,0,0,.28);
120
+ pointer-events:none;
121
+ animation:cg-cortina-sobe 700ms cubic-bezier(.77,0,.175,1) 2200ms both;
122
+ }
123
+ /* gap ≈ 1/3 do corpo do nome: o anel é curvo, então o vão óptico lê um pouco maior que o geométrico */
124
+ .cg-cortina__lockup{ display:flex; align-items:center; gap:clamp(10px,1.6vw,16px); padding:0 24px; }
125
+ .cg-cortina__lockup--right{ flex-direction:column; align-items:flex-start; }
126
+ .cg-cortina__row{ display:flex; align-items:center; gap:clamp(10px,1.6vw,16px); }
127
+ .cg-cortina__lockup--right .cg-cortina__name{ animation-name:cg-cortina-nome-dir; }
128
+ .cg-cortina__mark{ display:inline-flex; color:var(--color-ink,#14323a);
129
+ animation:cg-cortina-mark 700ms var(--ease-cg,cubic-bezier(.2,.8,.2,1)) both; }
130
+ /* símbolo ~1.25× o corpo do nome (a caixa do SVG é justa ao glifo), fluido.
131
+ Por altura: um símbolo injetado não-quadrado (TTCAB) mantém a proporção. */
132
+ .cg-cortina__mark svg{ height:var(--cg-cortina-mark); width:auto; }
133
+ .cg-cortina__text{ display:flex; flex-direction:column; min-width:0; }
134
+ .cg-cortina__name{
135
+ font-family:var(--font-heading,system-ui,sans-serif);
136
+ font-weight:700; font-size:clamp(2rem,6vw,3rem); line-height:1; letter-spacing:-.02em;
137
+ animation:cg-cortina-nome 600ms var(--ease-cg,cubic-bezier(.2,.8,.2,1)) 480ms both;
138
+ }
139
+ .cg-cortina__tag{
140
+ margin-top:.55rem; font-family:var(--font-body,system-ui,sans-serif);
141
+ font-size:clamp(.8rem,2.4vw,1rem); line-height:1.3; color:var(--color-ink-soft,#54707a);
142
+ animation:cg-cortina-tag 500ms ease 820ms both;
143
+ }
144
+ @keyframes cg-cortina-sobe{ to{ transform:translateY(-100%); visibility:hidden; } }
145
+ @keyframes cg-cortina-mark{ from{ opacity:0; transform:scale(.6) rotate(-16deg); } }
146
+ @keyframes cg-cortina-nome{ from{ opacity:0; transform:translateX(-8px); } }
147
+ @keyframes cg-cortina-nome-dir{ from{ opacity:0; transform:translateX(8px); } }
148
+ @keyframes cg-cortina-tag{ from{ opacity:0; transform:translateY(4px); } }
149
+ html[data-cg-cortina="vista"] .cg-cortina{ display:none; }
150
+ @media (prefers-reduced-motion:reduce){ .cg-cortina{ display:none; } }
151
+ @media print{ .cg-cortina{ display:none; } }
152
+ `;