@breathe-ds/ui 0.1.1 → 0.3.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +219 -142
  3. package/components/Accordion.tsx +17 -14
  4. package/components/AnchorNav.tsx +33 -21
  5. package/components/Avatar.tsx +37 -0
  6. package/components/BackgroundVideo.tsx +14 -14
  7. package/components/BottomNav.tsx +70 -69
  8. package/components/Breadcrumb.tsx +45 -0
  9. package/components/BreatheMark.tsx +12 -24
  10. package/components/Button.tsx +12 -12
  11. package/components/Byline.tsx +103 -0
  12. package/components/CaptionBand.tsx +74 -0
  13. package/components/Card.tsx +5 -5
  14. package/components/Container.tsx +4 -4
  15. package/components/CtaCard.tsx +52 -0
  16. package/components/Curtain.tsx +152 -0
  17. package/components/FactStrip.tsx +55 -0
  18. package/components/Footer.tsx +169 -77
  19. package/components/Header.tsx +32 -17
  20. package/components/Heading.tsx +86 -0
  21. package/components/InfoCard.tsx +116 -0
  22. package/components/LanguageSwitcher.tsx +139 -0
  23. package/components/List.tsx +81 -0
  24. package/components/Lockup.tsx +38 -55
  25. package/components/MapCard.tsx +158 -0
  26. package/components/MediaCard.tsx +266 -0
  27. package/components/PageVideoHero.tsx +53 -53
  28. package/components/PersonChip.tsx +49 -0
  29. package/components/Pill.tsx +42 -0
  30. package/components/PillList.tsx +21 -48
  31. package/components/ProfessionalCard.tsx +341 -0
  32. package/components/ProfessionalModal.tsx +243 -0
  33. package/components/Rows.tsx +131 -0
  34. package/components/SectionKicker.tsx +3 -3
  35. package/components/TestimonialCard.tsx +138 -0
  36. package/components/Text.tsx +31 -0
  37. package/components/Timeline.tsx +69 -0
  38. package/components/VideoInviteCard.tsx +168 -0
  39. package/components/VideoStoryCard.tsx +382 -0
  40. package/components/icons.tsx +6 -6
  41. package/components/marks/creeta.tsx +22 -22
  42. package/components/marks/ttcab.tsx +13 -13
  43. package/package.json +28 -5
  44. package/theme.css +88 -22
  45. package/tokens/creeta.css +14 -12
  46. package/tokens/neutral.css +16 -10
  47. package/tokens/ttcab.css +9 -8
  48. package/components/Cortina.tsx +0 -152
@@ -5,30 +5,42 @@ import { Container } from "./Container";
5
5
 
6
6
  export type AnchorItem = { id: string; label: string };
7
7
 
8
+ type Labels = {
9
+ /** aria-label of the anchor nav */
10
+ nav: string;
11
+ };
12
+
13
+ const EN: Labels = {
14
+ nav: "On this page",
15
+ };
16
+
8
17
  type Props = {
9
18
  items: AnchorItem[];
10
- /** Classe de deslocamento do sticky (altura do header). Padrão `top-20` (header de 80px). */
19
+ /** Sticky offset class (header height). Default `top-20` (80px header). */
11
20
  stickyTop?: string;
12
- /** Componente de link do app (ex.: next-intl Link). Default: <a>. */
21
+ /** The app's link component (e.g. next-intl Link). Default: <a>. */
13
22
  linkComponent?: ElementType;
14
23
  className?: string;
24
+ /** Strings (English defaults); the app passes the ones for its locale. */
25
+ labels?: Partial<Labels>;
15
26
  };
16
27
 
17
28
  /**
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
+ * Contextual anchor navigation — floating capsule that follows the scroll
30
+ * (extracted from ttcab's TreatmentAnchorNav). Two behaviors:
31
+ * 1. Scroll-spy: an IntersectionObserver marks as active the section that
32
+ * crosses the band at ~35% from the top; the matching pill lights up.
33
+ * 2. Sliding indicator: an absolute <span> measures the active pill (offsetLeft/
34
+ * Width, immune to the capsule's horizontal scroll) and slides to it on the
35
+ * family's curve. On narrow screens the capsule scrolls and centers the active one.
36
+ * The active signal = the indicator (ink/12 fill + outline), NOT text color:
37
+ * the text is always `ink` on `surface`, with no low-contrast frame.
38
+ * prefers-reduced-motion removes the slide. Target sections need a
39
+ * `scroll-mt-*` compatible with the sticky offset (e.g. `scroll-mt-40`).
29
40
  */
30
- export function AnchorNav({ items, stickyTop = "top-20", linkComponent, className = "" }: Props) {
41
+ export function AnchorNav({ items, stickyTop = "top-20", linkComponent, className = "", labels }: Props) {
31
42
  const L: ElementType = linkComponent ?? "a";
43
+ const t = { ...EN, ...labels };
32
44
  const [active, setActive] = useState(items[0]?.id ?? "");
33
45
  const listRef = useRef<HTMLUListElement>(null);
34
46
  const linkRefs = useRef<Record<string, HTMLAnchorElement | null>>({});
@@ -53,9 +65,9 @@ export function AnchorNav({ items, stickyTop = "top-20", linkComponent, classNam
53
65
  return () => io.disconnect();
54
66
  }, [items]);
55
67
 
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.
68
+ // Arriving with a #hash via client-side navigation: scrolls to the anchor (on
69
+ // a full load the browser already does it; here it's idempotent). Reapplies a
70
+ // few times to beat the router's late scroll reset.
59
71
  useEffect(() => {
60
72
  const hash = window.location.hash.slice(1);
61
73
  if (!hash || !items.some((it) => it.id === hash)) return;
@@ -76,7 +88,7 @@ export function AnchorNav({ items, stickyTop = "top-20", linkComponent, classNam
76
88
  setPill({ x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight, ready: true });
77
89
  };
78
90
 
79
- // Mede o pill ativo + revela na cápsula em telas estreitas
91
+ // Measures the active pill + reveals it in the capsule on narrow screens
80
92
  useLayoutEffect(() => {
81
93
  measure();
82
94
  const el = linkRefs.current[active];
@@ -88,7 +100,7 @@ export function AnchorNav({ items, stickyTop = "top-20", linkComponent, classNam
88
100
  // eslint-disable-next-line react-hooks/exhaustive-deps
89
101
  }, [active]);
90
102
 
91
- // Recalcula em resize (quebras/fontes mudam a largura)
103
+ // Recalculates on resize (line breaks/fonts change the width)
92
104
  useEffect(() => {
93
105
  window.addEventListener("resize", measure);
94
106
  return () => window.removeEventListener("resize", measure);
@@ -96,14 +108,14 @@ export function AnchorNav({ items, stickyTop = "top-20", linkComponent, classNam
96
108
  }, [active]);
97
109
 
98
110
  return (
99
- <nav aria-label="Nesta página" className={`sticky ${stickyTop} z-30 py-3 ${className}`}>
111
+ <nav aria-label={t.nav} className={`sticky ${stickyTop} z-30 py-3 ${className}`}>
100
112
  <Container>
101
113
  <div className="flex justify-center">
102
114
  <ul
103
115
  ref={listRef}
104
116
  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
117
  >
106
- {/* Indicador deslizante */}
118
+ {/* Sliding indicator */}
107
119
  <span
108
120
  aria-hidden="true"
109
121
  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"
@@ -0,0 +1,37 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ type Props = {
4
+ /** Person's name: the initials fallback and nothing else (the image is decorative). */
5
+ name: string;
6
+ /** Photo src. Without it, the initials in a tinted bubble (as the team cards of novo.creeta). */
7
+ photo?: string;
8
+ /** Your own image element (e.g. next/image), replaces `photo`. */
9
+ children?: ReactNode;
10
+ /** Side in px. */
11
+ size?: number;
12
+ /** White ring, for stacked groups. */
13
+ ring?: boolean;
14
+ className?: string;
15
+ };
16
+
17
+ export const initials = (name: string) =>
18
+ name
19
+ .trim()
20
+ .split(/\s+/)
21
+ .filter((w) => w.length > 2 || w === name)
22
+ .slice(0, 2)
23
+ .map((w) => w[0]?.toUpperCase() ?? "")
24
+ .join("");
25
+
26
+ /** Round portrait, decorative (the name is written next to it). */
27
+ export function Avatar({ name, photo, children, size = 40, ring = false, className = "" }: Props) {
28
+ const style = { width: size, height: size, fontSize: Math.round(size * 0.36) };
29
+ const base = `inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full ${ring ? "ring-2 ring-card" : ""} ${className}`;
30
+ if (children) return <span aria-hidden="true" className={base} style={style}>{children}</span>;
31
+ if (photo) return <img src={photo} alt="" loading="lazy" decoding="async" className={`${base} object-cover`} style={style} />;
32
+ return (
33
+ <span aria-hidden="true" className={`${base} bg-ink/[0.12] font-heading font-bold text-ink`} style={style}>
34
+ {initials(name)}
35
+ </span>
36
+ );
37
+ }
@@ -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,26 +276,26 @@ 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
- background:#fff; color:var(--color-ink);
285
+ background:var(--color-card); color:var(--color-ink);
285
286
  box-shadow:0 20px 48px -16px rgb(0 0 0 / .35), 0 1px 2px rgb(0 0 0 / .1);
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
  `;
@@ -0,0 +1,45 @@
1
+ import type { ElementType, ReactNode } from "react";
2
+
3
+ export type Crumb = { label: ReactNode; href: string };
4
+
5
+ type Props = {
6
+ /** From the section the page belongs to down to the parent of the current page. */
7
+ items: Crumb[];
8
+ linkComponent?: ElementType;
9
+ /** aria-label of the nav. */
10
+ label?: string;
11
+ className?: string;
12
+ };
13
+
14
+ // trail link: no resting underline (two underlines side by side weighed too much);
15
+ // it shows on hover and on focus
16
+ const link =
17
+ "inline-flex min-h-11 items-center text-ink underline decoration-transparent underline-offset-4 transition-[text-decoration-color] duration-150 hover:decoration-ink focus-visible:decoration-ink focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none";
18
+
19
+ /**
20
+ * Breadcrumb — the trail of a novo.creeta article: "← Section › Parent", so
21
+ * the text starts right away. The first crumb carries the back arrow; the
22
+ * current page is not listed (its title follows).
23
+ */
24
+ export function Breadcrumb({ items, linkComponent, label = "Trail", className = "" }: Props) {
25
+ const L: ElementType = linkComponent ?? "a";
26
+ return (
27
+ <nav aria-label={label} className={className}>
28
+ <ol className="m-0 flex list-none flex-wrap items-center gap-x-2 p-0 text-sm font-semibold text-ink/80">
29
+ {items.map((c, i) => (
30
+ <li key={`${i}-${c.href}`} className="flex items-center gap-x-2">
31
+ {i > 0 && (
32
+ <svg aria-hidden="true" viewBox="0 0 24 24" width={14} height={14} className="text-ink/50" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m9 18 6-6-6-6" /></svg>
33
+ )}
34
+ <L href={c.href} className={`${link} ${i === 0 ? "gap-1.5" : ""}`}>
35
+ {i === 0 && (
36
+ <svg aria-hidden="true" viewBox="0 0 24 24" width={16} height={16} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m12 19-7-7 7-7M19 12H5" /></svg>
37
+ )}
38
+ {c.label}
39
+ </L>
40
+ </li>
41
+ ))}
42
+ </ol>
43
+ </nav>
44
+ );
45
+ }
@@ -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,
@@ -26,19 +27,6 @@ export function BreatheMark({
26
27
  }: Props) {
27
28
  return (
28
29
  <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
30
  <svg
43
31
  width={size}
44
32
  height={size}
@@ -50,7 +38,7 @@ export function BreatheMark({
50
38
  style={{ ["--breathe-dur" as string]: `${duration}s` }}
51
39
  >
52
40
  <g className="breathe-mark__g">
53
- {/* anel aberto — gesto contínuo com um respiro no topo */}
41
+ {/* open ring: a continuous gesture with a breath gap at the top */}
54
42
  <path
55
43
  d="M 42 17.5 A 17 17 0 1 1 22 17.5"
56
44
  fill="none"
@@ -58,7 +46,7 @@ export function BreatheMark({
58
46
  strokeWidth="8.5"
59
47
  strokeLinecap="round"
60
48
  />
61
- {/* ponto do respiro */}
49
+ {/* the breath dot */}
62
50
  <circle cx="32" cy="14.5" r="4.4" fill="currentColor" />
63
51
  </g>
64
52
  </svg>