@breathe-ds/ui 0.5.4 → 0.6.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 (45) hide show
  1. package/README.md +29 -2
  2. package/components/Accordion.tsx +6 -3
  3. package/components/AnchorNav.tsx +10 -5
  4. package/components/Avatar.tsx +1 -1
  5. package/components/BottomNav.tsx +12 -5
  6. package/components/Button.tsx +5 -2
  7. package/components/Byline.tsx +25 -1
  8. package/components/Card.tsx +11 -3
  9. package/components/ContactRow.tsx +112 -0
  10. package/components/CourseCard.tsx +3 -3
  11. package/components/CtaCard.tsx +3 -1
  12. package/components/DateBadge.tsx +1 -1
  13. package/components/EmptyState.tsx +54 -0
  14. package/components/EventFacts.tsx +1 -1
  15. package/components/EventRow.tsx +3 -3
  16. package/components/FactStrip.tsx +1 -1
  17. package/components/FilterBar.tsx +50 -0
  18. package/components/FilterChips.tsx +63 -0
  19. package/components/FilterPill.tsx +35 -0
  20. package/components/Footer.tsx +1 -1
  21. package/components/Header.tsx +61 -33
  22. package/components/Heading.tsx +3 -2
  23. package/components/InfoCard.tsx +7 -2
  24. package/components/List.tsx +1 -1
  25. package/components/PageHero.tsx +74 -0
  26. package/components/PageVideoHero.tsx +15 -5
  27. package/components/PersonChip.tsx +1 -1
  28. package/components/Pill.tsx +1 -1
  29. package/components/ProfessionalCard.tsx +60 -5
  30. package/components/ProfileSpotlight.tsx +94 -0
  31. package/components/PublicationCard.tsx +215 -0
  32. package/components/PullQuote.tsx +87 -0
  33. package/components/RegistrationBox.tsx +1 -1
  34. package/components/SearchField.tsx +85 -0
  35. package/components/SelectField.tsx +75 -0
  36. package/components/SidePanel.tsx +6 -3
  37. package/components/StickyGroupHeading.tsx +38 -0
  38. package/components/TestimonialCard.tsx +179 -23
  39. package/components/Text.tsx +1 -1
  40. package/components/Timeline.tsx +1 -1
  41. package/components/VideoInviteCard.tsx +20 -6
  42. package/package.json +14 -2
  43. package/theme.css +50 -0
  44. package/tokens/creeta.css +1 -0
  45. package/tokens/ttcab.css +6 -1
package/README.md CHANGED
@@ -192,21 +192,48 @@ from color, not from extra markup.
192
192
 
193
193
  ## Navigation
194
194
 
195
+ - **ProfileSpotlight**: one person in focus (founder, course coordinator, guest) —
196
+ portrait on the left (photo or `living`, the team pages' living portrait), then
197
+ eyebrow, name, role, registration, bio and `actions`. `variant` `plain` (on the page
198
+ color) or `card` (white card). Stacks on phones.
199
+ - **Texture & rhythm (opt-in)**: `.cg-grain` adds a barely-there paper grain (noise in
200
+ the ink color) to any background — the body, a band. `bg-surface-alt` (token
201
+ `--cg-surface-alt`, falls back to `surface`) is a band one step deeper than the page,
202
+ for alternating sections; TTCAB `#f6eedd` (ink-soft 4.58:1), Creeta `#e4ecee`.
203
+ Neutral fills and shadows are tokens too: `bg-tint` (`--cg-tint`, icon bubbles, pills,
204
+ placeholders; default ink at 8%) and `--cg-shadow` (the hue of soft shadows). TTCAB
205
+ derives them from the logo's orange (warm neutrals, never grey).
206
+ `.cg-dots` (dot grid, 16px) or `.cg-dots-edges` (only on the side gutters, fading
207
+ out before the content column — the Cloudflare touch) layer dots over a background.
208
+ - **Search page, Apple-style**: `PageHero variant="centered"` (no box, title and one search
209
+ centred) + **FilterBar** (thin sticky bar of compact filters — `SelectField density="sm"` —
210
+ with the count and "clear" on the right; it publishes its height as `--cg-filterbar-h`, so
211
+ `StickyGroupHeading stickyTop="top-[calc(80px+var(--cg-filterbar-h,0px))]"` sticks right below).
212
+ - **PullQuote** + **Highlight**: one line set large between sections — drawn quote
213
+ mark in the action color, balanced lines, hand-drawn underline (`Highlight`) on the
214
+ key words. `cite` adds the person (Byline with role); without it, a brand statement.
195
215
  - **Header**: brand (the app passes the link to the home page) · items · item with dropdown
196
216
  (icons on sub-items; final link with a → arrow, or ↗ if `external`) · language · CTA.
197
217
  Site convention: "Home" is just the house (`icon` + `iconOnly`), with a label for
198
218
  screen readers.
219
+ Long dropdowns: `columns: 2` (compact rows, max 2 columns) and `align: "end"` to keep
220
+ the wider menu inside the viewport; `groups` (in place of `children`) adds labelled
221
+ groups, e.g. `[{ label: "Navigation", children: [...] }]`.
199
222
  - **BottomNav**: on phone/tablet, navigation leaves the header and becomes the
200
223
  floating bottom bar. It is the code of novo.creeta's BarraNavegacao (= ttcab's
201
224
  BottomNav), made neutral through tokens: it receives ALL pages in order of importance
202
225
  (`pages`, with the lucide `Icon`) and shows as many as fit (64px per tab,
203
- ResizeObserver); the rest and the external exits (`extras`, with the domain spelled out
226
+ ResizeObserver), **at most 5 tabs counting "More"** (4 pages + More, as the iOS and
227
+ Android tab bars; family rule of 2026-10-30: more than that cuts the labels and
228
+ shrinks the tap targets); the rest and the external exits (`extras`, with the domain spelled out
204
229
  and a ↗ arrow) go to the "More" panel, a native popover (Esc, tap outside, no
205
230
  JS). Gradient veil behind the bar. `hideFrom` = `md` (creeta) or `lg`
206
231
  (ttcab). `labels` for another language. `embedded` for the demo frame.
207
232
  - **AnchorNav**: floating capsule of page anchors (scroll-spy + sliding
208
233
  indicator), extracted from ttcab's TreatmentAnchorNav. Target sections need
209
- a `scroll-mt-*` compatible with the sticky header.
234
+ a `scroll-mt-*` compatible with the sticky header. White capsule (`card`);
235
+ `tone="surface"` (default: active pill in the page color with a hairline) or
236
+ `tone="accent"` (action tint, active label in action-text — more emphasis).
210
237
  - **LanguageSwitcher**: the SeletorDeIdioma of novo.creeta — `menu` (button with
211
238
  the code, opens the three languages; header) or `list` (plain links; the Footer
212
239
  uses it). Same page in the other language, each language written in itself,
@@ -1,6 +1,8 @@
1
1
  "use client";
2
2
 
3
- import { useId, useState, type ReactNode } from "react";
3
+ import { version as REACT_VERSION, useId, useState, type ReactNode } from "react";
4
+
5
+ const INERT_ON = parseInt(REACT_VERSION, 10) >= 19 ? true : "";
4
6
 
5
7
  export type AccordionItem = {
6
8
  id: string;
@@ -85,8 +87,9 @@ export function Accordion({ items, defaultOpenId, single = true }: Props) {
85
87
  id={panelId}
86
88
  role="region"
87
89
  aria-labelledby={btnId}
88
- // React 18 does not know `inert` as a boolean: empty string turns it on, undefined turns it off (in 19 the boolean works the same way).
89
- {...({ inert: isOpen ? undefined : "" } as Record<string, unknown>)}
90
+ // closed panel out of the tab order: React 19 takes `inert` as a boolean (an empty
91
+ // string there means false); React 18 does not know it and needs the empty string
92
+ {...({ inert: isOpen ? undefined : INERT_ON } as Record<string, unknown>)}
90
93
  className={`grid transition-[grid-template-rows] duration-300 ease-[cubic-bezier(.2,.8,.2,1)] motion-reduce:transition-none ${
91
94
  isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
92
95
  }`}
@@ -21,6 +21,10 @@ type Props = {
21
21
  /** The app's link component (e.g. next-intl Link). Default: <a>. */
22
22
  linkComponent?: ElementType;
23
23
  className?: string;
24
+ /** Tone of the active and hover pill. `surface` (default): the page color on the
25
+ * white capsule, with a hairline. `accent`: the light action tint, the active
26
+ * label in action-text (more emphasis). */
27
+ tone?: "surface" | "accent";
24
28
  /** Strings (English defaults); the app passes the ones for its locale. */
25
29
  labels?: Partial<Labels>;
26
30
  };
@@ -39,7 +43,8 @@ type Props = {
39
43
  * `scroll-mt-*` that, added to the theme's `scroll-padding-top` (96px), clears
40
44
  * the header plus the capsule (e.g. `scroll-mt-20` with an 80px header).
41
45
  */
42
- export function AnchorNav({ items, stickyTop = "top-20", linkComponent, className = "", labels }: Props) {
46
+ export function AnchorNav({ items, stickyTop = "top-20", linkComponent, className = "", tone = "surface", labels }: Props) {
47
+ const accent = tone === "accent";
43
48
  const L: ElementType = linkComponent ?? "a";
44
49
  const t = { ...EN, ...labels };
45
50
  const [active, setActive] = useState(items[0]?.id ?? "");
@@ -121,12 +126,12 @@ export function AnchorNav({ items, stickyTop = "top-20", linkComponent, classNam
121
126
  <div className="flex justify-center">
122
127
  <ul
123
128
  ref={listRef}
124
- 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"
129
+ className="relative flex max-w-full list-none gap-1 overflow-x-auto rounded-pill border border-line bg-card p-1.5 shadow-[0_10px_28px_-14px_color-mix(in_srgb,var(--color-shadow)_35%,transparent)] backdrop-blur [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
125
130
  >
126
131
  {/* Sliding indicator */}
127
132
  <span
128
133
  aria-hidden="true"
129
- 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"
134
+ className={`pointer-events-none absolute top-0 left-0 rounded-pill ${accent ? "bg-action-tint" : "bg-surface shadow-[inset_0_0_0_1px_var(--color-line)]"} transition-[transform,width,height,opacity] duration-[320ms] ease-cg motion-reduce:transition-none`}
130
135
  style={{ width: pill.w, height: pill.h, opacity: pill.ready ? 1 : 0, transform: `translate(${pill.x}px, ${pill.y}px)` }}
131
136
  />
132
137
  {items.map((n) => {
@@ -140,8 +145,8 @@ export function AnchorNav({ items, stickyTop = "top-20", linkComponent, classNam
140
145
  href={`#${n.id}`}
141
146
  aria-current={isActive ? "true" : undefined}
142
147
  onClick={() => setActive(n.id)}
143
- className={`relative z-10 inline-block whitespace-nowrap rounded-pill border border-transparent px-3.5 py-2 font-heading text-[0.92rem] font-semibold text-ink transition-[background-color,border-color] duration-200 ease-cg focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none ${
144
- isActive ? "" : "hover:border-ink/15 hover:bg-ink/[0.06]"
148
+ className={`relative z-10 inline-block whitespace-nowrap rounded-pill border border-transparent px-3.5 py-2 font-heading text-[0.92rem] font-semibold ${isActive && accent ? "text-action-text" : "text-ink"} transition-[background-color,border-color,color] duration-200 ease-cg focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none ${
149
+ isActive ? "" : accent ? "hover:bg-action-tint/60" : "hover:bg-surface"
145
150
  }`}
146
151
  >
147
152
  {n.label}
@@ -30,7 +30,7 @@ export function Avatar({ name, photo, children, size = 40, ring = false, classNa
30
30
  if (children) return <span aria-hidden="true" className={base} style={style}>{children}</span>;
31
31
  if (photo) return <img src={photo} alt="" loading="lazy" decoding="async" className={`${base} object-cover`} style={style} />;
32
32
  return (
33
- <span aria-hidden="true" className={`${base} bg-ink/[0.12] font-heading font-bold text-ink`} style={style}>
33
+ <span aria-hidden="true" className={`${base} bg-tint font-heading font-bold text-ink`} style={style}>
34
34
  {initials(name)}
35
35
  </span>
36
36
  );
@@ -1,6 +1,9 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useId, useRef, useState, type ComponentType, type ElementType, type ReactNode } from "react";
3
+ import { version as REACT_VERSION, useEffect, useId, useRef, useState, type ComponentType, type ElementType, type ReactNode } from "react";
4
+
5
+ // the popover trigger attribute: camelCase in React 19+, lowercase passthrough in 18
6
+ const POPOVER_TARGET = parseInt(REACT_VERSION, 10) >= 19 ? "popoverTarget" : "popovertarget";
4
7
 
5
8
  /** Icon in the lucide pattern (the component, not the node): the bar decides the size. */
6
9
  export type IconType = ComponentType<{ size?: number; strokeWidth?: number; className?: string; "aria-hidden"?: boolean | "true" }>;
@@ -71,8 +74,12 @@ export const TAB_MIN_WIDTH = 64;
71
74
  /** How many pages show directly on the bar, for a given usable width. The last
72
75
  * tab is always "More" (that's where the pages that didn't fit and the extra
73
76
  * exits live), so one page fewer than the number of tabs fits. */
77
+ /** At most 5 tabs (4 pages + More), as the iOS and Android tab bars: on a wider
78
+ * bar each tab gets room for its label instead of more tabs squeezing in. */
79
+ export const MAX_TABS = 5;
80
+
74
81
  export function howManyFit(width: number, total: number): number {
75
- const tabs = Math.floor(width / TAB_MIN_WIDTH);
82
+ const tabs = Math.min(MAX_TABS, Math.floor(width / TAB_MIN_WIDTH));
76
83
  return Math.max(2, Math.min(total, tabs - 1));
77
84
  }
78
85
 
@@ -98,7 +105,7 @@ const row = (active?: boolean) =>
98
105
  `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
106
  active ? "bg-surface text-ink" : "text-ink"
100
107
  }`;
101
- const bubble = "flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-ink/[0.12] text-ink";
108
+ const bubble = "flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-tint text-ink";
102
109
 
103
110
  /**
104
111
  * Main navigation on mobile: floating bar within thumb reach, app-like. It is
@@ -247,10 +254,10 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
247
254
  </li>
248
255
  ))}
249
256
  <li className="min-w-0 flex-1">
250
- {/* popovertarget in lowercase: React 18 doesn't know the attribute and passes it through as is. */}
257
+ {/* React 19 knows `popoverTarget`; React 18 only passes the lowercase attribute through as is. */}
251
258
  <button
252
259
  type="button"
253
- {...(embedded ? { "aria-expanded": open, "aria-controls": panelId, onClick: () => setOpen((o) => !o) } : { popovertarget: panelId })}
260
+ {...(embedded ? { "aria-expanded": open, "aria-controls": panelId, onClick: () => setOpen((o) => !o) } : { [POPOVER_TARGET]: panelId })}
254
261
  className={tab(embedded ? open || moreActive : moreActive)}
255
262
  >
256
263
  <MenuIcon />
@@ -1,6 +1,6 @@
1
1
  import type { ReactNode, ButtonHTMLAttributes, AnchorHTMLAttributes } from "react";
2
2
 
3
- type Variant = "solid" | "accent" | "outline" | "ghost" | "link";
3
+ type Variant = "solid" | "accent" | "outline" | "ghost" | "link" | "outline-inverse";
4
4
  type Size = "sm" | "md" | "lg";
5
5
 
6
6
  const pillBase =
@@ -27,7 +27,10 @@ const variants: Record<Exclude<Variant, "link">, string> = {
27
27
  // the 4.5:1 of normal text. Keep the accent for ONE large button per block.
28
28
  accent: "bg-action text-on-action hover:brightness-[0.92]",
29
29
  outline: "border border-line text-ink hover:bg-ink/[0.05]",
30
- ghost: "text-ink hover:bg-ink/[0.06]",
30
+ ghost: "text-ink hover:bg-tint",
31
+ // secondary on a dark panel (hero panel, StoryPanel, surface-panel): light outline,
32
+ // surface text (cream on petrol 9.41:1 on ttcab)
33
+ "outline-inverse": "border border-surface/45 text-surface hover:bg-surface/10",
31
34
  };
32
35
 
33
36
  const sizes: Record<Size, string> = {
@@ -9,6 +9,11 @@ type Props = {
9
9
  * and the names go on their own line; without it, one inline line:
10
10
  * avatar · name · action. */
11
11
  eyebrow?: ReactNode;
12
+ /** Hide the portraits (the card already shows a big photo of the person). */
13
+ hideAvatars?: boolean;
14
+ /** Small line under the name(s), aligned with them ("Physiotherapist").
15
+ * Ignored when there is an `eyebrow`. */
16
+ subtitle?: ReactNode;
12
17
  /** Link after the name(s) ("Watch the video ↗"). */
13
18
  action?: { label: ReactNode; href: string; external?: boolean };
14
19
  /** Joiners between names: ", " and " and ". */
@@ -35,7 +40,7 @@ const nameLink =
35
40
  * - inline (under a topic card): portrait, name and "Watch the video ↗".
36
41
  * Portraits are decorative: the names carry the information.
37
42
  */
38
- export function Byline({ people, eyebrow, action, joiners = { sep: ", ", last: " and " }, cite = false, newTabLabel = "(opens in a new tab)", linkComponent, className = "" }: Props) {
43
+ export function Byline({ people, eyebrow, subtitle, hideAvatars = false, action, joiners = { sep: ", ", last: " and " }, cite = false, newTabLabel = "(opens in a new tab)", linkComponent, className = "" }: Props) {
39
44
  const L: ElementType = linkComponent ?? "a";
40
45
  // portraits stack with a ring (as on the site); initials would lose their letters, so they sit side by side
41
46
  const stack = people.length > 1 && people.every((p) => p.photo || p.avatar);
@@ -65,6 +70,25 @@ export function Byline({ people, eyebrow, action, joiners = { sep: ", ", last: "
65
70
  </L>
66
71
  ) : null;
67
72
 
73
+ if (subtitle && !eyebrow) {
74
+ return (
75
+ <div className={`flex items-center gap-3 ${className}`}>
76
+ <span className={hideAvatars ? "hidden" : row}>
77
+ {people.map((p) => (
78
+ <Avatar key={p.name} name={p.name} photo={p.photo} size={people.length === 1 ? 40 : 36} ring={stack}>{p.avatar}</Avatar>
79
+ ))}
80
+ </span>
81
+ <div className="leading-snug">
82
+ <p className="m-0 text-sm font-semibold text-ink">
83
+ <Names className="not-italic">{names}</Names>
84
+ {actionEl && <> · {actionEl}</>}
85
+ </p>
86
+ <p className="m-0 mt-0.5 text-sm text-ink-soft">{subtitle}</p>
87
+ </div>
88
+ </div>
89
+ );
90
+ }
91
+
68
92
  if (eyebrow) {
69
93
  return (
70
94
  <div className={`flex items-center gap-3 ${className}`}>
@@ -6,6 +6,10 @@ type Props = {
6
6
  href?: string;
7
7
  /** Lifts on hover (for clickable cards). */
8
8
  interactive?: boolean;
9
+ /** `outline` (default): page color with a hairline and a soft shadow.
10
+ * `solid`: white card, no border, 28px corners — the family's testimonial
11
+ * and profile cards. */
12
+ variant?: "outline" | "solid";
9
13
  className?: string;
10
14
  };
11
15
 
@@ -14,10 +18,14 @@ type Props = {
14
18
  * shadow**, 2xl radius (28px), surface background. With `href` it becomes a
15
19
  * link; with `interactive` it lifts on hover. No strong borders or heavy shadows.
16
20
  */
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)] ${
21
+ export function Card({ children, href, interactive, variant = "outline", className = "" }: Props) {
22
+ const look =
23
+ variant === "solid"
24
+ ? "rounded-[28px] bg-card p-5 md:p-8"
25
+ : "rounded-2xl border border-line bg-surface p-[clamp(20px,3vw,32px)] shadow-[0_2px_12px_color-mix(in_srgb,var(--color-shadow)_6%,transparent)]";
26
+ const cls = `block ${look} ${
19
27
  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"
28
+ ? "transition duration-200 hover:-translate-y-0.5 hover:shadow-[0_14px_34px_color-mix(in_srgb,var(--color-shadow)_12%,transparent)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none motion-reduce:hover:translate-y-0"
21
29
  : ""
22
30
  } ${className}`;
23
31
  if (href) {
@@ -0,0 +1,112 @@
1
+ "use client";
2
+
3
+ import { useId, useState, type ReactNode } from "react";
4
+ import { ChevronDown } from "lucide-react";
5
+ import { Avatar } from "./Avatar";
6
+ import { FADE_IN } from "./Rows";
7
+
8
+ export type ContactAction = {
9
+ /** What the button does ("Ligar", "E-mail"). */
10
+ label: ReactNode;
11
+ /** The value shown under it: the number, the address. */
12
+ value: ReactNode;
13
+ href: string;
14
+ icon?: ReactNode;
15
+ /** sr-only after the label: who it reaches (" para Ana Souza"). */
16
+ srLabel?: ReactNode;
17
+ /** Long unbroken value (an e-mail) may wrap, only when it does not fit. */
18
+ breakAll?: boolean;
19
+ /** Opens in a new tab (WhatsApp). Say so in `srLabel`. */
20
+ external?: boolean;
21
+ };
22
+
23
+ type Props = {
24
+ /** Plain name: the initials. */
25
+ name: string;
26
+ /** The name as rendered (e.g. with the search match marked). */
27
+ nameDisplay?: ReactNode;
28
+ /** One quiet line under the name: the city. */
29
+ meta?: ReactNode;
30
+ /** Revealed when the row opens. */
31
+ actions: ContactAction[];
32
+ /** Start open (e.g. the only result of a search). */
33
+ defaultOpen?: boolean;
34
+ headingLevel?: "h3" | "h4";
35
+ className?: string;
36
+ };
37
+
38
+ /**
39
+ * ContactRow — one person in a long directory, as in the iPhone's Contacts:
40
+ * the list shows only what you scan by (initials, name, city); the ways to
41
+ * reach the person appear when you tap the row. 140 people with phone and
42
+ * e-mail always visible was a wall of data; this is a list of names.
43
+ *
44
+ * Put rows in a `ContactList` (the white inset-grouped panel with hairlines).
45
+ * The row is a button with `aria-expanded`; the actions are real links
46
+ * (wa.me, tel:, mailto:) written as plain text — icon + the value, 44px tall,
47
+ * underlined on hover — so the number can also be read and copied. No hover
48
+ * fill on the row (only a light press tint): open, row and actions share one
49
+ * white surface.
50
+ */
51
+ export function ContactRow({ name, nameDisplay, meta, actions, defaultOpen = false, headingLevel: H = "h3", className = "" }: Props) {
52
+ const [open, setOpen] = useState(defaultOpen);
53
+ const panel = useId();
54
+ return (
55
+ <li className={`relative list-none ${className}`}>
56
+ <H className="m-0 font-normal">
57
+ <button
58
+ type="button"
59
+ aria-expanded={open}
60
+ aria-controls={panel}
61
+ onClick={() => setOpen((v) => !v)}
62
+ className="group/row flex min-h-16 w-full items-center gap-3.5 px-4 py-2.5 text-left transition-colors duration-150 active:bg-tint/50 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none md:px-5"
63
+ >
64
+ <Avatar name={name} size={40} />
65
+ <span className="min-w-0 flex-1">
66
+ <span className="line-clamp-2 block font-heading text-[1.0625rem] leading-snug font-medium text-ink">{nameDisplay ?? name}</span>
67
+ {meta && <span className="mt-0.5 block truncate text-sm text-ink-soft">{meta}</span>}
68
+ </span>
69
+ <ChevronDown
70
+ aria-hidden="true"
71
+ size={18}
72
+ className={`shrink-0 text-ink-soft transition-transform duration-200 motion-reduce:transition-none ${open ? "rotate-180" : "group-hover/row:translate-y-0.5"}`}
73
+ />
74
+ </button>
75
+ </H>
76
+ <div id={panel} hidden={!open} className={`${FADE_IN} pr-4 pb-4 pl-[4.375rem] md:pr-5 md:pl-[4.625rem]`}>
77
+ {/* minimalista: ícone + valor, texto em ink sublinhado no hover; o nome da
78
+ ação ("WhatsApp", "E-mail") fica para o leitor de tela */}
79
+ <ul className="m-0 flex list-none flex-col gap-0.5 p-0 sm:flex-row sm:flex-wrap sm:gap-x-6">
80
+ {actions.map((a, i) => (
81
+ <li key={i}>
82
+ <a
83
+ href={a.href}
84
+ {...(a.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
85
+ className="group/act inline-flex min-h-11 max-w-full items-center gap-2 rounded-[2px] text-[0.9375rem] text-ink underline decoration-transparent decoration-[1.5px] underline-offset-4 transition-[text-decoration-color] duration-150 hover:decoration-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none"
86
+ >
87
+ {a.icon && <span className="flex shrink-0 text-ink-soft group-hover/act:text-ink">{a.icon}</span>}
88
+ <span className="sr-only">{a.label}</span>
89
+ <span className={a.breakAll ? "[overflow-wrap:anywhere]" : undefined}>{a.value}</span>
90
+ {a.srLabel && <span className="sr-only">{a.srLabel}</span>}
91
+ </a>
92
+ </li>
93
+ ))}
94
+ </ul>
95
+ </div>
96
+ </li>
97
+ );
98
+ }
99
+
100
+ /** The white inset-grouped panel of a section (iOS lists): rounded corners on
101
+ * the group, rows separated by a hairline that starts at the name (after the
102
+ * 40px avatar + its padding: 70px, 74px from md), drawn as a ::before so the
103
+ * row itself never moves. */
104
+ export function ContactList({ children, className = "" }: { children: ReactNode; className?: string }) {
105
+ return (
106
+ <ul
107
+ className={`m-0 list-none overflow-hidden rounded-[22px] bg-card p-0 [&>li+li]:before:absolute [&>li+li]:before:top-0 [&>li+li]:before:right-0 [&>li+li]:before:left-[4.375rem] [&>li+li]:before:h-px [&>li+li]:before:bg-line [&>li+li]:before:content-[''] md:[&>li+li]:before:left-[4.625rem] ${className}`}
108
+ >
109
+ {children}
110
+ </ul>
111
+ );
112
+ }
@@ -50,7 +50,7 @@ type Props = {
50
50
  className?: string;
51
51
  };
52
52
 
53
- const PILL = "inline-flex items-center rounded-pill bg-ink/[0.08] px-2.5 py-0.5 text-xs font-semibold text-ink";
53
+ const PILL = "inline-flex items-center rounded-pill bg-tint px-2.5 py-0.5 text-xs font-semibold text-ink";
54
54
 
55
55
  /**
56
56
  * Course card of the Cuidado Global family — the class card (ttcab's
@@ -68,7 +68,7 @@ export function CourseCard({ cover, course, title, start, when, where, workload,
68
68
  if (workload)
69
69
  facts.push({
70
70
  tile: (
71
- <span aria-hidden="true" className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-ink/[0.06] text-ink">
71
+ <span aria-hidden="true" className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-tint text-ink">
72
72
  <Clock size={20} strokeWidth={2} />
73
73
  </span>
74
74
  ),
@@ -77,7 +77,7 @@ export function CourseCard({ cover, course, title, start, when, where, workload,
77
77
  const pill = soldOut ? t.soldOut : seats;
78
78
 
79
79
  const foot = soldOut ? (
80
- <span className="inline-flex min-h-11 items-center rounded-pill bg-ink/[0.08] px-5 font-heading font-semibold text-ink">{t.soldOut}</span>
80
+ <span className="inline-flex min-h-11 items-center rounded-pill bg-tint px-5 font-heading font-semibold text-ink">{t.soldOut}</span>
81
81
  ) : (
82
82
  action
83
83
  );
@@ -10,7 +10,9 @@ type Props = {
10
10
  action: ReactNode;
11
11
  /** Side card with the speaker's video (VideoInviteCard props). Omit for the text card alone. */
12
12
  video?: Omit<VideoInviteCardProps, "className">;
13
- /** Any other side media (e.g. a still photo), placed in the same frame as the video. */
13
+ /** Any other side media (e.g. a still photo), placed in the same frame as the video.
14
+ * It must fill the frame (`absolute inset-0 h-full w-full object-cover`): the frame
15
+ * takes the height of the text card, not the photo's. */
14
16
  media?: ReactNode;
15
17
  className?: string;
16
18
  };
@@ -16,7 +16,7 @@ type Props = {
16
16
  export function DateBadge({ month, day, size = "md", className = "" }: Props) {
17
17
  const box = size === "lg" ? "h-14 w-14 rounded-2xl" : "h-12 w-12 rounded-xl";
18
18
  return (
19
- <span aria-hidden="true" className={`flex ${box} shrink-0 flex-col items-center justify-center bg-ink/[0.06] text-ink ${className}`}>
19
+ <span aria-hidden="true" className={`flex ${box} shrink-0 flex-col items-center justify-center bg-tint text-ink ${className}`}>
20
20
  <span className="text-[0.6rem] font-bold uppercase leading-none tracking-[0.12em] text-ink-soft">{month}</span>
21
21
  <span className={`mt-1 font-heading font-bold leading-none ${size === "lg" ? "text-2xl" : "text-lg"}`}>{day}</span>
22
22
  </span>
@@ -0,0 +1,54 @@
1
+ import type { ReactNode } from "react";
2
+ import { SearchX } from "lucide-react";
3
+
4
+ type Props = {
5
+ /** What happened, in the product's words: "Ninguém encontrado para “Kirs”". */
6
+ title: ReactNode;
7
+ /** One sentence on why, or what the list contains. */
8
+ children?: ReactNode;
9
+ /** The fastest way out first: a "did you mean" line, a "widen the search" button. */
10
+ recovery?: ReactNode;
11
+ /** Short tips (spelling, fewer words). Rendered as a list. */
12
+ tips?: ReactNode[];
13
+ /** Actions at the foot: clear the filters, talk to the team. */
14
+ actions?: ReactNode;
15
+ /** Replaces the magnifier (an icon at 22px). */
16
+ icon?: ReactNode;
17
+ /** h2 by default; h3 inside a section that already has an h2. */
18
+ headingLevel?: "h2" | "h3";
19
+ className?: string;
20
+ };
21
+
22
+ /**
23
+ * EmptyState — what a list shows when nothing matches. Never a dead end: it
24
+ * names what was searched, offers the nearest way out (a corrected spelling,
25
+ * a wider filter), then tips, then a person to ask. A white card (the family's
26
+ * solid card) so the place of the results does not go blank; left aligned,
27
+ * because it is read, not admired.
28
+ *
29
+ * Render it inside an `aria-live="polite"` region (or announce the count
30
+ * elsewhere) so a screen reader hears that the results changed.
31
+ */
32
+ export function EmptyState({ title, children, recovery, tips, actions, icon, headingLevel: H = "h2", className = "" }: Props) {
33
+ return (
34
+ <div className={`rounded-[28px] bg-card p-[clamp(24px,4vw,44px)] ${className}`}>
35
+ <span aria-hidden="true" className="flex h-12 w-12 items-center justify-center rounded-full bg-tint text-ink">
36
+ {icon ?? <SearchX size={22} strokeWidth={1.75} />}
37
+ </span>
38
+ <H className="mt-5 font-heading text-[1.375rem] leading-snug font-medium text-balance text-ink md:text-2xl">{title}</H>
39
+ {children && <div className="mt-2 max-w-[60ch] leading-relaxed text-ink-body">{children}</div>}
40
+ {recovery && <div className="mt-5">{recovery}</div>}
41
+ {tips && tips.length > 0 && (
42
+ <ul className="mt-6 max-w-[60ch] list-none space-y-2 border-t border-line pt-5">
43
+ {tips.map((tip, i) => (
44
+ <li key={i} className="relative pl-5 text-sm leading-relaxed text-ink-soft">
45
+ <span aria-hidden="true" className="absolute top-[0.6em] left-0 h-1.5 w-1.5 rounded-full bg-ink-soft/60" />
46
+ {tip}
47
+ </li>
48
+ ))}
49
+ </ul>
50
+ )}
51
+ {actions && <div className="mt-7 flex flex-wrap items-center gap-3">{actions}</div>}
52
+ </div>
53
+ );
54
+ }
@@ -33,7 +33,7 @@ export function EventFacts({ facts, linkComponent, newTabLabel = "(opens in a ne
33
33
  {facts.map((f, i) => (
34
34
  <li key={i} className="flex items-center gap-4">
35
35
  {f.tile === "pin" ? (
36
- <span aria-hidden="true" className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-ink/[0.06] text-ink">
36
+ <span aria-hidden="true" className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-tint text-ink">
37
37
  <MapPin size={20} strokeWidth={2} />
38
38
  </span>
39
39
  ) : (
@@ -73,7 +73,7 @@ export function EventRow({ time, timeAccent, title, href, onSelect, organizers,
73
73
  {title}
74
74
  </L>
75
75
  )}
76
- {badge && <span className="ml-2 inline-flex translate-y-[-2px] items-center rounded-pill bg-ink/[0.08] px-2.5 py-0.5 align-middle text-xs font-semibold text-ink">{badge}</span>}
76
+ {badge && <span className="ml-2 inline-flex translate-y-[-2px] items-center rounded-pill bg-tint px-2.5 py-0.5 align-middle text-xs font-semibold text-ink">{badge}</span>}
77
77
  </h3>
78
78
  {organizers && organizers.length > 0 && (
79
79
  <p className="mt-2 flex items-center gap-2 text-sm text-ink-body">
@@ -98,13 +98,13 @@ export function EventRow({ time, timeAccent, title, href, onSelect, organizers,
98
98
  <Avatar key={p.name} name={p.name} photo={p.photo} size={28} ring>{p.avatar}</Avatar>
99
99
  ))}
100
100
  </span>
101
- {peopleMore ? <span className="rounded-pill bg-ink/[0.08] px-2 py-0.5 text-xs font-semibold text-ink">{t.more(peopleMore)}</span> : null}
101
+ {peopleMore ? <span className="rounded-pill bg-tint px-2 py-0.5 text-xs font-semibold text-ink">{t.more(peopleMore)}</span> : null}
102
102
  <span className="sr-only">{shown.map((p) => p.name).join(", ")}</span>
103
103
  </p>
104
104
  )}
105
105
  </div>
106
106
  {cover && (
107
- <div className="relative h-24 w-24 shrink-0 overflow-hidden rounded-2xl bg-ink/[0.06] md:h-36 md:w-36">
107
+ <div className="relative h-24 w-24 shrink-0 overflow-hidden rounded-2xl bg-tint md:h-36 md:w-36">
108
108
  {/* eslint-disable-next-line @next/next/no-img-element */}
109
109
  <img src={cover.src} alt={cover.alt ?? ""} loading="lazy" decoding="async" className="absolute inset-0 h-full w-full object-cover" />
110
110
  </div>
@@ -36,7 +36,7 @@ export function FactStrip({ facts, label, className = "" }: Props) {
36
36
  >
37
37
  {facts.map(({ Icon, title, text, separator = ", " }, i) => (
38
38
  <li key={i} className="flex items-center gap-4 py-5 md:px-8 md:py-0 lg:px-10">
39
- <span aria-hidden="true" className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-ink/[0.12] text-ink">
39
+ <span aria-hidden="true" className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-tint text-ink">
40
40
  <Icon size={22} strokeWidth={1.75} />
41
41
  </span>
42
42
  <p className="m-0 leading-snug">
@@ -0,0 +1,50 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, type ReactNode } from "react";
4
+
5
+ type Props = {
6
+ /** The filters (compact SelectField density="sm", chips…), on the left. */
7
+ children: ReactNode;
8
+ /** On the right: the count and a "clear" action. */
9
+ aside?: ReactNode;
10
+ /** Accessible name of the bar ("Filtrar por local"). */
11
+ label: string;
12
+ /** Where it sticks, below the site's sticky header. A literal Tailwind class
13
+ * written in your source (`top-20` = an 80px header). */
14
+ stickyTop?: string;
15
+ className?: string;
16
+ };
17
+
18
+ /**
19
+ * FilterBar — the thin bar of refining filters that comes after a search
20
+ * (Apple's store finder, Contacts): compact pills on the left, the count and
21
+ * "clear" on the right. It sticks below the header while the list scrolls; on
22
+ * the page colour, translucent, with a hairline under it.
23
+ *
24
+ * It measures itself and publishes its height as `--cg-filterbar-h` on the
25
+ * document, so headings that stick below it can use
26
+ * `top-[calc(80px+var(--cg-filterbar-h))]` and never slide under it, even when
27
+ * the bar wraps on a phone.
28
+ */
29
+ export function FilterBar({ children, aside, label, stickyTop = "top-20", className = "" }: Props) {
30
+ const box = useRef<HTMLDivElement>(null);
31
+ useEffect(() => {
32
+ const el = box.current;
33
+ if (!el || typeof ResizeObserver !== "function") return;
34
+ const root = document.documentElement;
35
+ const ro = new ResizeObserver(() => root.style.setProperty("--cg-filterbar-h", `${el.offsetHeight}px`));
36
+ ro.observe(el);
37
+ return () => {
38
+ ro.disconnect();
39
+ root.style.removeProperty("--cg-filterbar-h");
40
+ };
41
+ }, []);
42
+ return (
43
+ <div ref={box} role="group" aria-label={label} className={`sticky ${stickyTop} z-20 -mx-4 border-b border-line bg-surface/90 px-4 py-3 backdrop-blur-md md:-mx-6 md:px-6 ${className}`}>
44
+ <div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-2">
45
+ <div className="grid w-full grid-cols-2 gap-2 sm:flex sm:w-auto sm:flex-wrap sm:items-center">{children}</div>
46
+ {aside && <div className="flex items-center gap-4 text-sm text-ink-soft">{aside}</div>}
47
+ </div>
48
+ </div>
49
+ );
50
+ }