@breathe-ds/ui 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,31 +3,31 @@
3
3
  import { useEffect, useRef, useState } from "react";
4
4
 
5
5
  type Props = {
6
- /** Clipe curto, sem som, em repetição (arquivo do próprio site). */
6
+ /** Short, muted, looping clip (file hosted by the site itself). */
7
7
  src: string;
8
- /** Versão maior, baixada só em telas largas (a partir de `from`). */
8
+ /** Larger version, downloaded only on wide screens (from `from` up). */
9
9
  srcLarge?: string;
10
- /** Largura mínima (px) para a versão maior. 1024 por padrão. */
10
+ /** Minimum width (px) for the larger version. Defaults to 1024. */
11
11
  from?: 768 | 1024;
12
- /** Quadro parado: aparece antes de o vídeo carregar, sem JS e com movimento reduzido. */
12
+ /** Still frame: shown before the video loads, without JS, and with reduced motion. */
13
13
  poster: string;
14
- /** Quadro parado da versão maior, quando ela é outro vídeo (ex.: vertical no celular, deitado no desktop). */
14
+ /** Still frame of the larger version, when it is a different video (e.g. vertical on mobile, landscape on desktop). */
15
15
  posterLarge?: string;
16
- /** Aproxima o vídeo para cortar bordas gravadas no arquivo. */
16
+ /** Zooms the video in to crop edges baked into the file. */
17
17
  trimEdges?: boolean;
18
- /** object-position do vídeo (ex.: "center 20%" p/ manter o rosto num clipe vertical). */
18
+ /** object-position of the video (e.g. "center 20%" to keep the face in a vertical clip). */
19
19
  objectPosition?: string;
20
- /** Rótulos do botão de pausa (injetáveis p/ i18n). */
20
+ /** Pause button labels (injectable for i18n). */
21
21
  labels?: { pause?: string; play?: string };
22
22
  className?: string;
23
23
  };
24
24
 
25
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.
26
+ * Background video of the Cuidado Global family — mirrors creeta's `VideoFundo`
27
+ * with the Breathe tokens (ink/surface). Muted looping clip, purely decorative:
28
+ * hidden from screen readers (aria-hidden). Does not use the `autoplay`
29
+ * attribute: it only starts after checking prefers-reduced-motion and data
30
+ * saver (save-data). Has a pause button (WCAG 2.2.2) in the top-right corner.
31
31
  */
32
32
  export function BackgroundVideo({
33
33
  src,
@@ -93,7 +93,7 @@ export function BackgroundVideo({
93
93
  <button
94
94
  type="button"
95
95
  onClick={toggle}
96
- aria-label={running ? labels?.pause ?? "Pausar vídeo" : labels?.play ?? "Reproduzir vídeo"}
96
+ aria-label={running ? labels?.pause ?? "Pause video" : labels?.play ?? "Play video"}
97
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
98
  >
99
99
  {running ? (
@@ -2,75 +2,75 @@
2
2
 
3
3
  import { useEffect, useId, useRef, useState, type ComponentType, type ElementType, type ReactNode } from "react";
4
4
 
5
- /** Ícone no padrão lucide (o componente, não o nó): a barra decide o tamanho. */
5
+ /** Icon in the lucide pattern (the component, not the node): the bar decides the size. */
6
6
  export type IconType = ComponentType<{ size?: number; strokeWidth?: number; className?: string; "aria-hidden"?: boolean | "true" }>;
7
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". */
8
+ /** One page of the site, in order of importance. The first ones stay on the bar;
9
+ * those that don't fit go into "More". */
10
10
  export type BottomPage = {
11
11
  href: string;
12
12
  label: ReactNode;
13
13
  Icon: IconType;
14
- /** A página atual é esta? (o app decide: rota/idioma são dele) */
14
+ /** Is this the current page? (the app decides: route/locale are its own) */
15
15
  active?: boolean;
16
16
  };
17
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. */
18
+ /** Extra exit in the "More" panel (e.g. courses on another site): address written
19
+ * underneath and an outward arrow, as on novo.creeta. */
20
20
  export type BottomExtra = {
21
21
  href: string;
22
22
  label: ReactNode;
23
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. */
24
+ /** Smaller line under the label (e.g. the domain). With `external` and without this, shows the href's domain. */
25
25
  description?: ReactNode;
26
26
  external?: boolean;
27
27
  active?: boolean;
28
28
  };
29
29
 
30
30
  type Labels = {
31
- /** aria-label da barra */
31
+ /** aria-label of the bar */
32
32
  nav: string;
33
- /** rótulo da aba "Mais" */
33
+ /** label of the "More" tab */
34
34
  more: string;
35
- /** aria-label do painel */
35
+ /** aria-label of the panel */
36
36
  morePages: string;
37
- /** sr-only depois de "Mais": "mais páginas" */
37
+ /** sr-only after "More": "more pages" */
38
38
  morePagesSr: string;
39
- /** sr-only quando a página atual está no "Mais" */
39
+ /** sr-only when the current page is inside "More" */
40
40
  currentHere: string;
41
- /** sr-only em link externo */
41
+ /** sr-only on an external link */
42
42
  newTab: string;
43
43
  };
44
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)",
45
+ const EN: Labels = {
46
+ nav: "Main navigation",
47
+ more: "More",
48
+ morePages: "More pages",
49
+ morePagesSr: "more pages",
50
+ currentHere: "(the current page is here)",
51
+ newTab: "(opens in a new tab)",
52
52
  };
53
53
 
54
54
  type Props = {
55
55
  pages: BottomPage[];
56
56
  extras?: BottomExtra[];
57
- /** Componente de link do app (ex.: next-intl Link). Default: <a>. */
57
+ /** The app's link component (e.g. next-intl Link). Default: <a>. */
58
58
  linkComponent?: ElementType;
59
- /** A partir de que largura o header assume e a barra some. creeta: `md`; ttcab: `lg`. */
59
+ /** From which width the header takes over and the bar disappears. creeta: `md`; ttcab: `lg`. */
60
60
  hideFrom?: "md" | "lg";
61
- /** Textos (padrão pt-BR); o app passa os do idioma. */
61
+ /** Strings (English defaults); the app passes the ones for its locale. */
62
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. */
63
+ /** Embedded in a `relative` container (demo, phone frame): bar, veil and
64
+ * panel are `absolute` inside it and show at any width. */
65
65
  embedded?: boolean;
66
66
  };
67
67
 
68
- /** Largura mínima de uma aba, em px: abaixo disso o rótulo não cabe. */
68
+ /** Minimum width of a tab, in px: below this the label doesn't fit. */
69
69
  export const TAB_MIN_WIDTH = 64;
70
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. */
71
+ /** How many pages show directly on the bar, for a given usable width. The last
72
+ * tab is always "More" (that's where the pages that didn't fit and the extra
73
+ * exits live), so one page fewer than the number of tabs fits. */
74
74
  export function howManyFit(width: number, total: number): number {
75
75
  const tabs = Math.floor(width / TAB_MIN_WIDTH);
76
76
  return Math.max(2, Math.min(total, tabs - 1));
@@ -83,9 +83,9 @@ const ArrowUpRight = () => (
83
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
84
  );
85
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.
86
+ // Tab: icon+label column. The active one gets a SHAPE (light pill), not just color —
87
+ // readable for people who can't tell colors apart. Shrinks on touch, like an app button.
88
+ // The view-transition name makes the highlight slide between tabs on page change.
89
89
  const tab = (active: boolean) =>
90
90
  [
91
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",
@@ -101,29 +101,29 @@ const row = (active?: boolean) =>
101
101
  const bubble = "flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-ink/[0.12] text-ink";
102
102
 
103
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.
104
+ * Main navigation on mobile: floating bar within thumb reach, app-like. It is
105
+ * the code of novo.creeta's BarraNavegacao (and ttcab's BottomNav), made
106
+ * neutral through tokens: `ink` on the bar, `surface` on the active tab.
107
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.
108
+ * - Dynamic: shows as many pages as fit the width (ResizeObserver, 64px per
109
+ * tab). The last tab is always "More", holding the rest and the extra
110
+ * exits. On a typical phone that's four pages plus "More".
111
+ * - Veil behind the bar: content fades out gradually before reaching it.
112
+ * - The "More" panel is a native popover: closes on Esc and on tap outside,
113
+ * returns focus to the button and opens even without JavaScript. Embedded
114
+ * (demo), it becomes an absolute panel inside the frame, with the same entrance.
115
+ * - Hidden from `hideFrom` up (md on creeta, lg on ttcab), where the header takes over.
116
116
  */
117
117
  export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg", labels, embedded = false }: Props) {
118
118
  const L: ElementType = linkComponent ?? "a";
119
- const t = { ...PT, ...labels };
119
+ const t = { ...EN, ...labels };
120
120
  const list = useRef<HTMLUListElement>(null);
121
121
  const panel = useRef<HTMLDivElement>(null);
122
122
  const wrap = useRef<HTMLDivElement>(null);
123
123
  const panelId = useId();
124
- // antes de medir: o caso do celular comum (5 abas = 4 páginas + Mais)
124
+ // before measuring: the typical phone case (5 tabs = 4 pages + More)
125
125
  const [visible, setVisible] = useState(() => howManyFit(5 * TAB_MIN_WIDTH, pages.length));
126
- const [open, setOpen] = useState(false); // só no modo embutido
126
+ const [open, setOpen] = useState(false); // embedded mode only
127
127
 
128
128
  useEffect(() => {
129
129
  const ul = list.current;
@@ -145,15 +145,15 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
145
145
  try {
146
146
  if (el?.matches(":popover-open")) el.hidePopover?.();
147
147
  } catch {
148
- /* navegador sem popover: não há o que fechar */
148
+ /* browser without popover support: nothing to close */
149
149
  }
150
150
  };
151
151
 
152
- // Trocou de página: o painel fecha.
152
+ // Page changed: the panel closes.
153
153
  const activeHref = pages.find((p) => p.active)?.href;
154
154
  useEffect(close, [activeHref]); // eslint-disable-line react-hooks/exhaustive-deps
155
155
 
156
- // Embutido: fecha com Esc e com toque fora (o popover nativo faz isso sozinho).
156
+ // Embedded: closes on Esc and on tap outside (the native popover does this by itself).
157
157
  useEffect(() => {
158
158
  if (!embedded || !open) return;
159
159
  const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
@@ -173,11 +173,12 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
173
173
  const inMore = pages.slice(visible);
174
174
  const moreActive = inMore.some((p) => p.active) || extras.some((x) => x.active);
175
175
  const pos = embedded ? "absolute" : "fixed";
176
- const hide = embedded ? "" : ` ${hideFrom}:hidden`;
176
+ // full class names (Tailwind only generates what it finds written in the code)
177
+ const hide = embedded ? "" : hideFrom === "md" ? " md:hidden" : " lg:hidden";
177
178
 
178
179
  const panelInner = (
179
180
  <nav aria-label={t.morePages}>
180
- {/* Um item por linha: em duas colunas o painel ficava confuso. */}
181
+ {/* One item per row: in two columns the panel got confusing. */}
181
182
  {inMore.length > 0 && (
182
183
  <ul className="grid list-none gap-1">
183
184
  {inMore.map(({ href, label, Icon, active }, i) => (
@@ -223,8 +224,8 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
223
224
  return (
224
225
  <div ref={wrap} className={embedded ? "contents" : undefined}>
225
226
  <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. */}
227
+ {/* Veil behind the bar: scrolling content fades out gradually before
228
+ reaching it, instead of showing up clipped by the edges. */}
228
229
  <div
229
230
  aria-hidden="true"
230
231
  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}`}
@@ -246,7 +247,7 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
246
247
  </li>
247
248
  ))}
248
249
  <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
+ {/* popovertarget in lowercase: React 18 doesn't know the attribute and passes it through as is. */}
250
251
  <button
251
252
  type="button"
252
253
  {...(embedded ? { "aria-expanded": open, "aria-controls": panelId, onClick: () => setOpen((o) => !o) } : { popovertarget: panelId })}
@@ -262,12 +263,12 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
262
263
 
263
264
  {embedded ? (
264
265
  open && (
265
- <div id={panelId} className="cg-mais-painel cg-mais-painel--embutido">
266
+ <div id={panelId} className="cg-more-panel cg-more-panel--embedded">
266
267
  {panelInner}
267
268
  </div>
268
269
  )
269
270
  ) : (
270
- <div ref={panel} id={panelId} {...{ popover: "auto" }} className={`cg-mais-painel cg-mais-painel--${hideFrom}`}>
271
+ <div ref={panel} id={panelId} {...{ popover: "auto" }} className={`cg-more-panel cg-more-panel--${hideFrom}`}>
271
272
  {panelInner}
272
273
  </div>
273
274
  )}
@@ -275,10 +276,10 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
275
276
  );
276
277
  }
277
278
 
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). */
279
+ /* "More" panel: right above the bar; enters growing from the corner of the
280
+ "More" button, in 0.18s (same as novo.creeta's .mais-painel). */
280
281
  const CSS = `
281
- .cg-mais-painel{
282
+ .cg-more-panel{
282
283
  position:fixed; inset:auto .75rem calc(5.75rem + env(safe-area-inset-bottom)) .75rem;
283
284
  width:auto; margin:0; border:0; border-radius:26px; padding:.5rem;
284
285
  background:#fff; color:var(--color-ink);
@@ -286,15 +287,15 @@ const CSS = `
286
287
  opacity:0; transform:translateY(8px) scale(.97); transform-origin:88% 100%;
287
288
  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
  }
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; }
290
+ .cg-more-panel:popover-open, .cg-more-panel--embedded{ opacity:1; transform:none; }
291
+ @starting-style{ .cg-more-panel:popover-open, .cg-more-panel--embedded{ opacity:0; transform:translateY(8px) scale(.97); } }
292
+ .cg-more-panel::backdrop{ background:transparent; }
293
+ .cg-more-panel--embedded{ position:absolute; z-index:50; }
293
294
  @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; } }
295
+ .cg-more-panel{ transform:none; transition:opacity 120ms ease; }
296
+ @starting-style{ .cg-more-panel:popover-open, .cg-more-panel--embedded{ transform:none; } }
296
297
  }
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; } }
298
+ /* From the breakpoint up the bar doesn't exist: neither does the panel. */
299
+ @media (min-width:768px){ .cg-more-panel--md{ display:none !important; } }
300
+ @media (min-width:1024px){ .cg-more-panel--lg{ display:none !important; } }
300
301
  `;
@@ -1,21 +1,22 @@
1
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). */
2
+ /** Side in px (square). This is the size of the visible glyph: the SVG box
3
+ * is tight to the ring + dot, with no inner padding (the space between
4
+ * symbol and name is up to the layout). */
5
5
  size?: number;
6
- /** Segundos de um ciclo respiratório completo (inspira+expira). */
6
+ /** Seconds of one full breathing cycle (inhale + exhale). */
7
7
  duration?: number;
8
- /** Anima? (ainda respeita prefers-reduced-motion). */
8
+ /** Animate? (still respects prefers-reduced-motion). */
9
9
  animated?: boolean;
10
10
  className?: string;
11
11
  title?: string;
12
12
  };
13
13
 
14
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.
15
+ * Living symbol of Breathe: an open ring (a fluid, continuous gesture) with a
16
+ * dot at the breath gap, an abstraction of the creeta logo (round shape + dot).
17
+ * NEUTRAL: uses currentColor and inherits the color from context (the palette
18
+ * comes from each app). It "breathes" with a soft scale pulse; respects
19
+ * prefers-reduced-motion.
19
20
  */
20
21
  export function BreatheMark({
21
22
  size = 96,
@@ -50,7 +51,7 @@ export function BreatheMark({
50
51
  style={{ ["--breathe-dur" as string]: `${duration}s` }}
51
52
  >
52
53
  <g className="breathe-mark__g">
53
- {/* anel aberto — gesto contínuo com um respiro no topo */}
54
+ {/* open ring: a continuous gesture with a breath gap at the top */}
54
55
  <path
55
56
  d="M 42 17.5 A 17 17 0 1 1 22 17.5"
56
57
  fill="none"
@@ -58,7 +59,7 @@ export function BreatheMark({
58
59
  strokeWidth="8.5"
59
60
  strokeLinecap="round"
60
61
  />
61
- {/* ponto do respiro */}
62
+ {/* the breath dot */}
62
63
  <circle cx="32" cy="14.5" r="4.4" fill="currentColor" />
63
64
  </g>
64
65
  </svg>
@@ -6,18 +6,18 @@ type Size = "sm" | "md" | "lg";
6
6
  const pillBase =
7
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
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). */
9
+ /* `link` variant: underlined text action (not a pill) — the button's lightweight
10
+ sibling, same typography (font-body semibold). Inherits the color from the
11
+ context (currentColor), so it works on light and on dark backgrounds. The
12
+ underline sits at 50% and goes solid on hover; an arrow (icon passed as a
13
+ child) nudges up-and-right. Ignores `size` (it is inline, no pill). */
14
14
  const linkBase =
15
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
16
 
17
17
  const variants: Record<Exclude<Variant, "link">, string> = {
18
- // petróleo sólido, texto creme (ex.: "Falar com a equipe")
18
+ // solid petrol, cream text (e.g. "Falar com a equipe")
19
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)
20
+ // action color (orange/magenta) + on-action text (from the token, varies per site)
21
21
  accent: "bg-action text-on-action hover:-translate-y-0.5 shadow-[0_6px_18px_rgba(20,71,83,0.22)]",
22
22
  outline: "border border-line text-ink hover:bg-ink/[0.05]",
23
23
  ghost: "text-ink hover:bg-ink/[0.06]",
@@ -40,11 +40,11 @@ type ButtonProps = CommonProps & ButtonHTMLAttributes<HTMLButtonElement> & { hre
40
40
  type LinkProps = CommonProps & AnchorHTMLAttributes<HTMLAnchorElement> & { href: string };
41
41
 
42
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).
43
+ * Button of the Cuidado Global family. Polymorphic: with `href` it renders an
44
+ * `<a>` (inject your Link via a wrapper if you need a router), otherwise a
45
+ * `<button>`. Variants: solid (petrol), accent (action color), outline, ghost
46
+ * and `link` (underlined text action — e.g. "See all", "Secondary action",
47
+ * "View on Google Maps"; inherits the color, works on light and dark).
48
48
  */
49
49
  export function Button(props: ButtonProps | LinkProps) {
50
50
  const { variant = "solid", size = "md", className = "", children, ...rest } = props;
@@ -2,17 +2,17 @@ import type { ReactNode } from "react";
2
2
 
3
3
  type Props = {
4
4
  children: ReactNode;
5
- /** Vira link se href for passado. */
5
+ /** Renders as a link when href is passed. */
6
6
  href?: string;
7
- /** Eleva no hover (para cards clicáveis). */
7
+ /** Lifts on hover (for clickable cards). */
8
8
  interactive?: boolean;
9
9
  className?: string;
10
10
  };
11
11
 
12
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.
13
+ * Base card of the family — style decided in the DS: **subtle border + subtle
14
+ * shadow**, 2xl radius (28px), surface background. With `href` it becomes a
15
+ * link; with `interactive` it lifts on hover. No strong borders or heavy shadows.
16
16
  */
17
17
  export function Card({ children, href, interactive, className = "" }: Props) {
18
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)] ${
@@ -2,15 +2,15 @@ import type { ReactNode } from "react";
2
2
 
3
3
  type Props = {
4
4
  children: ReactNode;
5
- /** Largura máxima. "content" (padrão) p/ leitura; "wide" p/ heros/rodapé. */
5
+ /** Max width. "content" (default) for reading; "wide" for heroes/footer. */
6
6
  width?: "content" | "wide";
7
7
  className?: string;
8
8
  };
9
9
 
10
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é).
11
+ * Container of the Cuidado Global family — max width + consistent side gutter
12
+ * (16px on mobile). "content" for text/sections, "wide" for full-bleed blocks
13
+ * (heroes, footer).
14
14
  */
15
15
  export function Container({ children, width = "content", className = "" }: Props) {
16
16
  const max = width === "wide" ? "max-w-[1600px]" : "max-w-[1120px]";