@breathe-ds/ui 0.4.0 → 0.5.4

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.
package/README.md CHANGED
@@ -57,6 +57,21 @@ For a site without a ready-made token file, define the roles on `:root` itself:
57
57
  and `scroll-padding` (6rem top, 7rem bottom) so keyboard focus never lands
58
58
  hidden behind the sticky Header or the BottomNav (WCAG 2.4.11).
59
59
 
60
+ ## Using it in a site (Tailwind v4)
61
+
62
+ ```css
63
+ @import "tailwindcss";
64
+ @import "@breathe-ds/ui/theme.css"; /* the roles */
65
+ @import "@breathe-ds/ui/tokens/ttcab.css"; /* this brand's hex (or creeta.css, neutral.css) */
66
+ /* Tailwind only generates the classes it finds written: scan the components */
67
+ @source "../../node_modules/@breathe-ds/ui/components";
68
+ ```
69
+
70
+ The components are TSX sources, not a build: in Next.js add
71
+ `transpilePackages: ["@breathe-ds/ui"]` to `next.config`. The CSS entries of
72
+ the package are exported with the `style` condition, which Tailwind's resolver
73
+ requires (a plain string export fails with "Can't resolve").
74
+
60
75
  ## Token roles
61
76
 
62
77
  | Token (Tailwind) | Role | `--cg-*` |
@@ -271,6 +286,30 @@ from color, not from extra markup.
271
286
  trail, title, PersonChip and date, the text with its notice, the video beside
272
287
  it sticky from md, the transcript open; children for "other posts" and the
273
288
  final call).
289
+ - **SidePanel**: Luma's drawer — a native `<dialog>` that slides in from the
290
+ right, full height, up to 32rem wide (full width on the phone), with a top
291
+ bar (close on the left, actions such as "Copy link" and "Course page ↗" on
292
+ the right) and a scrolling body. Esc, the backdrop and the button close it;
293
+ focus goes back to what opened it. The playground opens one from a
294
+ CourseCard (`onSelect`) with the whole class inside: cover, facts,
295
+ RegistrationBox with the button straight to the school's cart, programme,
296
+ discounts, rules.
297
+ - **CourseCard**: the class card (ttcab's "Formação", the school's class list)
298
+ in the family's own vocabulary: course name as the category line, the city as
299
+ the title with the seats pill, the facts as EventFacts tiles (DateBadge for
300
+ the start, pin for the venue with its map link, clock for the workload), who
301
+ teaches as a Byline, a cover photo at the top, and at the foot the price with
302
+ its note beside the Button `accent`; `soldOut` puts the pill in the button's
303
+ place; `onSelect` makes the card a button stretched over it (the links inside
304
+ keep working) to open its details. By tone: no border, no shadow, no lift.
305
+ - **Events** (after Luma and Meetup, for the school's classes): **EventList**
306
+ (days down a dotted rail, each with its rows), **EventRow** (time with an
307
+ accented end, title as the stretched link, or a button with `onSelect` to
308
+ open the details in a SidePanel, organizers, location, who is going with
309
+ "+N", square cover, an optional badge), **EventFacts** (date on a
310
+ calendar tile, venue on a pin tile with its link), **DateBadge** and
311
+ **RegistrationBox** (a strip title, ticket options as a radio group with the
312
+ remaining pill and the price, the one button at the foot).
274
313
  - **StoryPanel**: creeta's "origin" block — one dark panel (surface-panel)
275
314
  grouping a story: media at the top with a film that fades into the panel, the
276
315
  logo at its base, title, lead, three pillars in columns with a hairline above
@@ -307,7 +346,7 @@ from color, not from extra markup.
307
346
  **Data rule:** every professional MUST have a **CREFITO** attached (COFFITO requirement).
308
347
  - [x] **Pill / PillList** — the Atalho of novo.creeta (white 44px pill, arrow bubble: ↓ anchor, ↗ other page, optional meta); wrapping row + "See all"
309
348
  - [x] **AnchorNav** (floating capsule of anchors, scroll-spy + sliding indicator) — extracted from ttcab (10/29)
310
- - [x] **Footer** · **PageVideoHero** (loop, and player from a YouTube id or the site's own mp4) · **Header** (dropdown, house) · **BottomNav** · **Curtain** · **Lockup** (5 arrangements) · **BreatheMark**
349
+ - [x] **Footer** · **PageVideoHero** (loop, and player from a YouTube id or the site's own mp4; `panel.actions` puts two or more calls to action inside the panel) · **Header** (dropdown, house) · **BottomNav** · **Curtain** · **Lockup** (5 arrangements) · **BreatheMark**
311
350
  - [x] **Site brand marks** (`components/marks/`): real symbol + wordmark for TTCAB and Creeta (10/29)
312
351
  - [ ] **ThreadRail** (side graphic device — a thread running down the left linking sections) — approved,
313
352
  **already exists in ttcab** → extract.
@@ -0,0 +1,139 @@
1
+ import type { ElementType, ReactNode } from "react";
2
+ import { Clock } from "lucide-react";
3
+ import { Byline, type Person } from "./Byline";
4
+ import { EventFacts, DateBadge, type EventFact } from "./EventFacts";
5
+
6
+ type Labels = {
7
+ /** Prefix before the instructors' names ("With"). */
8
+ with: ReactNode;
9
+ /** The pill on the title line and the one in the button's place when sold out. */
10
+ soldOut: ReactNode;
11
+ };
12
+ const EN: Labels = { with: "With", soldOut: "Sold out" };
13
+
14
+ type Props = {
15
+ /** Cover photo at the top (the school's pages always have one), 4:3,
16
+ * focused near the top for the portrait photos the school uses. */
17
+ cover?: { src: string; alt?: string; position?: string };
18
+ /** The course ("Basic RTA course 2026"): the heading of the card. */
19
+ course?: ReactNode;
20
+ /** The class ("City · ST"): the line under the heading, with the seats pill.
21
+ * Without `course`, it is the heading itself. */
22
+ title: ReactNode;
23
+ /** The start, for the calendar tile. */
24
+ start: { month: ReactNode; day: ReactNode };
25
+ /** The dates: "3 to 9 November 2026 · 08:30–17:30" and, below, the extra day. */
26
+ when: { title: ReactNode; subtitle?: ReactNode };
27
+ /** The venue (a link to the map when it has an href) and the address. */
28
+ where?: { title: ReactNode; subtitle?: ReactNode; href?: string; external?: boolean };
29
+ /** "69 class hours". */
30
+ workload?: ReactNode;
31
+ /** Seats, as a pill on the title line ("28 seats"). */
32
+ seats?: ReactNode;
33
+ /** Who teaches: portraits and names, each a link when it has an href. */
34
+ instructors?: Person[];
35
+ /** The price beside the button ("R$ 7.280") and a small note ("up to 12 instalments"). */
36
+ price?: { value: ReactNode; note?: ReactNode };
37
+ /** Small note at the foot ("Only physiotherapists can attend."). */
38
+ footnote?: ReactNode;
39
+ /** The call to action: a Button `accent`, the one full-colour element of the card. */
40
+ action?: ReactNode;
41
+ /** No seats left: the pill says so and takes the button's place. */
42
+ soldOut?: boolean;
43
+ /** Makes the card clickable (the title becomes a button stretched over the
44
+ * card) to open its details, e.g. in a SidePanel. Links inside (the map,
45
+ * the instructors, the action) keep working on their own. */
46
+ onSelect?: () => void;
47
+ headingLevel?: "h2" | "h3";
48
+ linkComponent?: ElementType;
49
+ labels?: Partial<Labels>;
50
+ className?: string;
51
+ };
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";
54
+
55
+ /**
56
+ * Course card of the Cuidado Global family — the class card (ttcab's
57
+ * "Formação", the school's class list), in the family's own vocabulary: the
58
+ * course name as the category line, the city as the title with the seats pill
59
+ * (as the EventRow), the facts as tiles (the DateBadge for the start, the pin
60
+ * for the venue, a clock for the workload, as the EventFacts), who teaches as
61
+ * a Byline, and the Button accent pinned at the foot. By tone: no border, no
62
+ * shadow, no lift.
63
+ */
64
+ export function CourseCard({ cover, course, title, start, when, where, workload, seats, instructors, price, footnote, action, soldOut = false, onSelect, headingLevel: Title = "h3", linkComponent, labels, className = "" }: Props) {
65
+ const t = { ...EN, ...labels };
66
+ const facts: EventFact[] = [{ tile: <DateBadge month={start.month} day={start.day} />, title: when.title, subtitle: when.subtitle }];
67
+ if (where) facts.push({ tile: "pin", title: where.title, subtitle: where.subtitle, href: where.href, external: where.external });
68
+ if (workload)
69
+ facts.push({
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">
72
+ <Clock size={20} strokeWidth={2} />
73
+ </span>
74
+ ),
75
+ title: workload,
76
+ });
77
+ const pill = soldOut ? t.soldOut : seats;
78
+
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>
81
+ ) : (
82
+ action
83
+ );
84
+
85
+ return (
86
+ <article className={`relative flex flex-col overflow-hidden rounded-[28px] bg-card ${onSelect ? "transition-transform duration-200 ease-out active:scale-[0.99] motion-reduce:transition-none motion-reduce:active:scale-100 [&_a]:relative [&_a]:z-10 [&_button]:relative [&_button]:z-10" : ""} ${className}`}>
87
+ {cover && (
88
+ <div className="relative aspect-[4/3] overflow-hidden">
89
+ {/* eslint-disable-next-line @next/next/no-img-element */}
90
+ <img src={cover.src} alt={cover.alt ?? ""} loading="lazy" decoding="async" className="absolute inset-0 h-full w-full object-cover" style={{ objectPosition: cover.position ?? "50% 25%" }} />
91
+ </div>
92
+ )}
93
+ <div className="flex flex-1 flex-col p-6 md:p-7">
94
+ {/* the course is what the person looks for: it is the heading; the city
95
+ is the variant, on the line below with the seats pill (2026-10-29) */}
96
+ <Title className="font-heading text-xl font-medium leading-snug text-ink md:text-[1.375rem]">
97
+ {onSelect ? (
98
+ // the stretched hit area of the card: this button is the exception to [&_button]:z-10 (static, after: covers the card)
99
+ <button type="button" onClick={onSelect} className="static! z-auto! text-left after:absolute after:inset-0 after:z-0 after:rounded-[28px] focus-visible:outline-none focus-visible:after:outline focus-visible:after:outline-2 focus-visible:after:-outline-offset-2 focus-visible:after:outline-ink">
100
+ {course ?? title}
101
+ </button>
102
+ ) : (
103
+ course ?? title
104
+ )}
105
+ </Title>
106
+ {(course || pill) && (
107
+ <p className="mt-2 flex flex-wrap items-center gap-2 text-base font-semibold text-ink">
108
+ {course && <span>{title}</span>}
109
+ {pill && <span className={PILL}>{pill}</span>}
110
+ </p>
111
+ )}
112
+
113
+ <EventFacts className="mt-5" facts={facts} linkComponent={linkComponent} />
114
+
115
+ {instructors && instructors.length > 0 && (
116
+ <div className="mt-5 flex items-center gap-3 text-sm text-ink-body">
117
+ <span className="shrink-0 text-ink-soft">{t.with}</span>
118
+ <Byline people={instructors} linkComponent={linkComponent} className="min-w-0" />
119
+ </div>
120
+ )}
121
+
122
+ {footnote && <p className="mt-5 text-sm text-ink-soft">{footnote}</p>}
123
+
124
+ {/* at the foot: the price on the left, the button on the right; cards side by side align here */}
125
+ {(foot || price) && (
126
+ <div className="mt-auto flex flex-wrap items-center justify-between gap-x-4 gap-y-3 pt-6">
127
+ {price && (
128
+ <p className="leading-tight">
129
+ <span className="block font-heading text-xl font-semibold text-ink">{price.value}</span>
130
+ {price.note && <span className="block text-sm text-ink-soft">{price.note}</span>}
131
+ </p>
132
+ )}
133
+ {foot}
134
+ </div>
135
+ )}
136
+ </div>
137
+ </article>
138
+ );
139
+ }
@@ -0,0 +1,24 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ type Props = {
4
+ /** Short month ("Nov"). */
5
+ month: ReactNode;
6
+ /** Day of the month. */
7
+ day: ReactNode;
8
+ /** 48px (default) or 56px. */
9
+ size?: "md" | "lg";
10
+ className?: string;
11
+ };
12
+
13
+ /** The date as a small calendar tile (Luma's "NOV / 6"): the month small on
14
+ * top, the day large below, on a tinted bubble. Decorative beside the written
15
+ * date; alone, give the parent an accessible text. */
16
+ export function DateBadge({ month, day, size = "md", className = "" }: Props) {
17
+ const box = size === "lg" ? "h-14 w-14 rounded-2xl" : "h-12 w-12 rounded-xl";
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}`}>
20
+ <span className="text-[0.6rem] font-bold uppercase leading-none tracking-[0.12em] text-ink-soft">{month}</span>
21
+ <span className={`mt-1 font-heading font-bold leading-none ${size === "lg" ? "text-2xl" : "text-lg"}`}>{day}</span>
22
+ </span>
23
+ );
24
+ }
@@ -0,0 +1,62 @@
1
+ import type { ElementType, ReactNode } from "react";
2
+ import { MapPin } from "lucide-react";
3
+ import { DateBadge } from "./DateBadge";
4
+
5
+ export type EventFact = {
6
+ /** A DateBadge, or an icon; "pin" draws the map pin. */
7
+ tile: ReactNode | "pin";
8
+ title: ReactNode;
9
+ subtitle?: ReactNode;
10
+ /** The title becomes a link (the venue's map page), with the arrow. */
11
+ href?: string;
12
+ external?: boolean;
13
+ };
14
+
15
+ type Props = {
16
+ facts: EventFact[];
17
+ linkComponent?: ElementType;
18
+ newTabLabel?: string;
19
+ className?: string;
20
+ };
21
+
22
+ const ArrowUpRight = () => (
23
+ <svg aria-hidden="true" viewBox="0 0 24 24" width={16} height={16} className="cg-arrow-go" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
24
+ );
25
+
26
+ /** The facts of an event as tiles (Luma): the date on a calendar tile with
27
+ * the written date beside it, the place on a pin tile with the venue (a link)
28
+ * and the city. `DateBadge` is exported for the date tile. */
29
+ export function EventFacts({ facts, linkComponent, newTabLabel = "(opens in a new tab)", className = "" }: Props) {
30
+ const L: ElementType = linkComponent ?? "a";
31
+ return (
32
+ <ul className={`flex list-none flex-col gap-4 ${className}`}>
33
+ {facts.map((f, i) => (
34
+ <li key={i} className="flex items-center gap-4">
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">
37
+ <MapPin size={20} strokeWidth={2} />
38
+ </span>
39
+ ) : (
40
+ f.tile
41
+ )}
42
+ <span className="min-w-0 leading-snug">
43
+ <span className="block font-semibold text-ink">
44
+ {f.href ? (
45
+ <L href={f.href} {...(f.external ? { target: "_blank", rel: "noopener noreferrer" } : {})} className="inline-flex items-center gap-1 underline decoration-transparent underline-offset-4 transition-[text-decoration-color] duration-150 hover:decoration-ink focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none">
46
+ {f.title}
47
+ <ArrowUpRight />
48
+ {f.external && <span className="sr-only"> {newTabLabel}</span>}
49
+ </L>
50
+ ) : (
51
+ f.title
52
+ )}
53
+ </span>
54
+ {f.subtitle && <span className="mt-0.5 block text-sm text-ink-body">{f.subtitle}</span>}
55
+ </span>
56
+ </li>
57
+ ))}
58
+ </ul>
59
+ );
60
+ }
61
+
62
+ export { DateBadge };
@@ -0,0 +1,49 @@
1
+ import type { ReactNode } from "react";
2
+ import { EventRow, type EventRowProps } from "./EventRow";
3
+
4
+ export type EventGroup = {
5
+ /** The day ("6 Nov"). */
6
+ date: ReactNode;
7
+ /** The weekday, one tone below ("Friday"). */
8
+ weekday?: ReactNode;
9
+ /** ISO date for the <time>. */
10
+ iso?: string;
11
+ events: EventRowProps[];
12
+ };
13
+
14
+ type Props = {
15
+ groups: EventGroup[];
16
+ /** aria-label of the list. */
17
+ label?: string;
18
+ className?: string;
19
+ };
20
+
21
+ /**
22
+ * Events grouped by day, down a dotted rail (Luma): each day has a dot on the
23
+ * rail, the date and the weekday, and its rows below. One <ol> per day keeps
24
+ * the order for the screen reader.
25
+ */
26
+ export function EventList({ groups, label = "Events", className = "" }: Props) {
27
+ return (
28
+ <div aria-label={label} role="list" className={`relative ${className}`}>
29
+ {/* the rail: a dotted line down the left */}
30
+ <span aria-hidden="true" className="absolute bottom-6 left-[5px] top-3 w-px border-l-2 border-dotted border-ink/15" />
31
+ {groups.map((g, i) => (
32
+ <section key={i} role="listitem" className="relative pl-9 pb-8 last:pb-0">
33
+ <span aria-hidden="true" className="absolute left-0 top-[7px] h-3 w-3 rounded-full bg-ink/20" />
34
+ <h2 className="font-heading text-lg font-semibold text-ink">
35
+ {g.iso ? <time dateTime={g.iso}>{g.date}</time> : g.date}
36
+ {g.weekday && <span className="ml-2 font-body text-base font-normal text-ink-soft">{g.weekday}</span>}
37
+ </h2>
38
+ <ol className="mt-4 flex list-none flex-col gap-3">
39
+ {g.events.map((e, j) => (
40
+ <li key={j}>
41
+ <EventRow {...e} />
42
+ </li>
43
+ ))}
44
+ </ol>
45
+ </section>
46
+ ))}
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,114 @@
1
+ import type { ElementType, ReactNode } from "react";
2
+ import { MapPin } from "lucide-react";
3
+ import { Avatar } from "./Avatar";
4
+ import type { Person } from "./Byline";
5
+
6
+ type Labels = {
7
+ /** "+34" pill after the stacked portraits. */
8
+ more: (n: number) => string;
9
+ /** Prefix of the organizer line ("By"). */
10
+ by: ReactNode;
11
+ };
12
+ const EN: Labels = { more: (n) => `+${n}`, by: "By" };
13
+
14
+ // the hit area of the row: the title's link or button, stretched over it
15
+ const STRETCH = "after:absolute after:inset-0 after:rounded-[24px] focus-visible:outline-none focus-visible:after:outline focus-visible:after:outline-2 focus-visible:after:-outline-offset-2 focus-visible:after:outline-ink";
16
+
17
+ export type EventRowProps = {
18
+ /** The time line ("09:00 · 12:00 GMT+0", or "3 to 9 Nov · 08:30–17:30"). */
19
+ time: ReactNode;
20
+ /** The end of the time line, in the soft action tone (Luma shows the end
21
+ * time this way). Optional. */
22
+ timeAccent?: ReactNode;
23
+ title: ReactNode;
24
+ /** The event's page. With `onSelect` instead, the row opens its details in place. */
25
+ href?: string;
26
+ /** Makes the title a button stretched over the row (a SidePanel opens, say). */
27
+ onSelect?: () => void;
28
+ /** Who organizes or teaches: small portrait(s) and the name(s). */
29
+ organizers?: Person[];
30
+ location?: ReactNode;
31
+ /** Who is going (or who teaches, on a course): stacked portraits and "+N". */
32
+ people?: Person[];
33
+ /** How many beyond the portraits shown. */
34
+ peopleMore?: number;
35
+ /** Square cover on the right (1:1). */
36
+ cover?: { src: string; alt?: string };
37
+ /** A small state on the title line ("Sold out", "Online"). */
38
+ badge?: ReactNode;
39
+ linkComponent?: ElementType;
40
+ labels?: Partial<Labels>;
41
+ className?: string;
42
+ };
43
+
44
+ /**
45
+ * Event row of the Cuidado Global family (after Luma and Meetup): the time
46
+ * line, the title (the link, stretched over the whole row), who organizes,
47
+ * where, who is going, and the square cover on the right. Lives in an
48
+ * EventList, under a date heading.
49
+ */
50
+ export function EventRow({ time, timeAccent, title, href, onSelect, organizers, location, people, peopleMore, cover, badge, linkComponent, labels, className = "" }: EventRowProps) {
51
+ const L: ElementType = linkComponent ?? "a";
52
+ const t = { ...EN, ...labels };
53
+ const shown = people?.slice(0, 4) ?? [];
54
+ return (
55
+ <article className={`group relative flex gap-5 rounded-[24px] bg-card p-5 transition-transform duration-200 ease-out active:scale-[0.99] motion-reduce:transition-none motion-reduce:active:scale-100 md:p-6 ${className}`}>
56
+ <div className="flex min-w-0 flex-1 flex-col">
57
+ <p className="text-sm font-medium text-ink-soft">
58
+ {time}
59
+ {timeAccent && (
60
+ <>
61
+ <span aria-hidden="true"> · </span>
62
+ <span className="font-semibold text-action-text">{timeAccent}</span>
63
+ </>
64
+ )}
65
+ </p>
66
+ <h3 className="mt-1.5 font-heading text-xl font-medium leading-snug text-ink md:text-[1.375rem]">
67
+ {onSelect ? (
68
+ <button type="button" onClick={onSelect} className={`text-left ${STRETCH}`}>
69
+ {title}
70
+ </button>
71
+ ) : (
72
+ <L href={href} className={STRETCH}>
73
+ {title}
74
+ </L>
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>}
77
+ </h3>
78
+ {organizers && organizers.length > 0 && (
79
+ <p className="mt-2 flex items-center gap-2 text-sm text-ink-body">
80
+ <span className="flex -space-x-1.5">
81
+ {organizers.slice(0, 3).map((p) => (
82
+ <Avatar key={p.name} name={p.name} photo={p.photo} size={20} ring>{p.avatar}</Avatar>
83
+ ))}
84
+ </span>
85
+ <span className="truncate">{t.by} {organizers.map((p) => p.name).join(", ")}</span>
86
+ </p>
87
+ )}
88
+ {location && (
89
+ <p className="mt-1.5 flex items-center gap-2 text-sm text-ink-body">
90
+ <MapPin aria-hidden="true" size={16} strokeWidth={2} className="shrink-0 text-ink-soft" />
91
+ <span className="truncate">{location}</span>
92
+ </p>
93
+ )}
94
+ {shown.length > 0 && (
95
+ <p className="mt-auto flex items-center gap-2 pt-4">
96
+ <span className="flex -space-x-2">
97
+ {shown.map((p) => (
98
+ <Avatar key={p.name} name={p.name} photo={p.photo} size={28} ring>{p.avatar}</Avatar>
99
+ ))}
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}
102
+ <span className="sr-only">{shown.map((p) => p.name).join(", ")}</span>
103
+ </p>
104
+ )}
105
+ </div>
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">
108
+ {/* eslint-disable-next-line @next/next/no-img-element */}
109
+ <img src={cover.src} alt={cover.alt ?? ""} loading="lazy" decoding="async" className="absolute inset-0 h-full w-full object-cover" />
110
+ </div>
111
+ )}
112
+ </article>
113
+ );
114
+ }
@@ -50,8 +50,10 @@ export function List({ items, marker = "check", linkComponent, newTabLabel = "(o
50
50
  const mark = marker === "check" ? bubble : <CornerDownRight />;
51
51
  const gap = marker === "check" ? "gap-3" : "gap-2.5";
52
52
 
53
+ // leading-snug (1.375): in a two-line item the lines stay closer than the
54
+ // items are to each other; relaxed leading read as air inside the item (2026-10-29)
53
55
  return (
54
- <ul className={`list-none ${marker === "check" ? "space-y-3 text-ink" : "space-y-2 leading-relaxed text-ink/80"} ${className}`}>
56
+ <ul className={`list-none leading-snug ${marker === "check" ? "space-y-3 text-ink" : "space-y-2.5 text-ink/80"} ${className}`}>
55
57
  {items.map((it, i) => (
56
58
  <li key={`${i}-${it.href ?? ""}`}>
57
59
  {it.href ? (
@@ -3,7 +3,10 @@
3
3
  import { useEffect, useRef, useState, type ReactNode } from "react";
4
4
  import { BackgroundVideo } from "./BackgroundVideo";
5
5
 
6
- type Panel = { kicker?: ReactNode; title: ReactNode; lead?: ReactNode };
6
+ /** The glass panel: kicker, title, lead and, when the page has more than one
7
+ * call to action, the buttons inside the panel (the bottom-right slot fits
8
+ * one button beside the panel; two would run into the title). */
9
+ type Panel = { kicker?: ReactNode; title: ReactNode; lead?: ReactNode; actions?: ReactNode };
7
10
 
8
11
  /** Local background clip (loop mode): muted, looping. */
9
12
  type LoopPreview = {
@@ -247,6 +250,7 @@ export function PageVideoHero({
247
250
  posterLarge={(video as VideoLoop).preview.posterLarge}
248
251
  trimEdges={(video as VideoLoop).preview.trimEdges}
249
252
  objectPosition={(video as VideoLoop).preview.objectPosition}
253
+ labels={previewLabels}
250
254
  className="absolute inset-0"
251
255
  />
252
256
  )}
@@ -265,7 +269,7 @@ export function PageVideoHero({
265
269
  {/* panel / caption — in flow (justify-end), m-6 */}
266
270
  <div className="relative z-10 p-6 md:m-6 md:p-0">
267
271
  {panel ? (
268
- <div className="inline-flex max-w-full items-center rounded-[24px] bg-ink/75 px-6 py-4 backdrop-blur-md md:max-w-[calc(100%-16rem)] lg:px-7 lg:py-5">
272
+ <div className={`inline-flex max-w-full items-center rounded-[24px] bg-ink/75 px-6 py-4 backdrop-blur-md lg:px-7 lg:py-5 ${panel.actions && actions === undefined ? "" : "md:max-w-[calc(100%-16rem)]"}`}>
269
273
  <div>
270
274
  {panel.kicker && (
271
275
  <p className="mb-3 font-heading text-[0.82rem] font-bold tracking-[0.1em] text-surface/80 uppercase">{panel.kicker}</p>
@@ -274,6 +278,7 @@ export function PageVideoHero({
274
278
  {panel.lead && (
275
279
  <p className="mt-4 max-w-[46ch] text-base leading-[1.55] text-pretty text-surface/85 md:text-lg">{panel.lead}</p>
276
280
  )}
281
+ {panel.actions && <div className="mt-6 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">{panel.actions}</div>}
277
282
  </div>
278
283
  </div>
279
284
  ) : caption ? (
@@ -0,0 +1,82 @@
1
+ "use client";
2
+
3
+ import { useState, type ReactNode } from "react";
4
+ import { Check } from "lucide-react";
5
+
6
+ export type TicketOption = {
7
+ id: string;
8
+ label: ReactNode;
9
+ /** "6 left", as a small pill after the label. */
10
+ remaining?: ReactNode;
11
+ /** "Free", "R$ 1.900". */
12
+ price: ReactNode;
13
+ disabled?: boolean;
14
+ };
15
+
16
+ type Props = {
17
+ /** The strip at the top ("Registration"). */
18
+ title: ReactNode;
19
+ /** The sentence above the options ("Hi! Choose your ticket:"). */
20
+ intro?: ReactNode;
21
+ options: TicketOption[];
22
+ /** Selected option id (controlled); without it, the first enabled one. */
23
+ value?: string;
24
+ onChange?: (id: string) => void;
25
+ /** The button at the foot (a Button `accent`, or `solid`); receives nothing: read the selection through onChange. */
26
+ action: ReactNode;
27
+ /** Small line under the button ("Only physiotherapists can attend."). */
28
+ footnote?: ReactNode;
29
+ /** Name of the radio group, for the form. */
30
+ name?: string;
31
+ className?: string;
32
+ };
33
+
34
+ /**
35
+ * Registration box of an event (Luma): a strip with the title, a sentence,
36
+ * the ticket options as a radio group (the chosen one gets the ring and the
37
+ * check), the price on the right, and the one button at the foot. By tone:
38
+ * the box is a surface card inside the page's card.
39
+ */
40
+ export function RegistrationBox({ title, intro, options, value, onChange, action, footnote, name = "ticket", className = "" }: Props) {
41
+ const first = options.find((o) => !o.disabled)?.id;
42
+ const [inner, setInner] = useState(first);
43
+ const selected = value ?? inner;
44
+ const choose = (id: string) => {
45
+ setInner(id);
46
+ onChange?.(id);
47
+ };
48
+ return (
49
+ <section className={`overflow-hidden rounded-[24px] bg-surface ${className}`}>
50
+ <h2 className="border-b border-ink/10 px-5 py-3 text-sm font-semibold text-ink-soft">{title}</h2>
51
+ <div className="p-5">
52
+ {intro && <p className="mb-4 text-ink">{intro}</p>}
53
+ <fieldset className="m-0 border-0 p-0">
54
+ <legend className="sr-only">{title}</legend>
55
+ <div className="flex flex-col gap-2">
56
+ {options.map((o) => {
57
+ const on = o.id === selected;
58
+ return (
59
+ <label
60
+ key={o.id}
61
+ className={`flex min-h-12 cursor-pointer items-center gap-3 rounded-2xl px-4 py-2.5 transition-colors duration-150 motion-reduce:transition-none ${on ? "bg-card shadow-[inset_0_0_0_2px_var(--color-ink)]" : "bg-card/60 hover:bg-card"} ${o.disabled ? "cursor-not-allowed opacity-50" : ""}`}
62
+ >
63
+ <input type="radio" name={name} value={o.id} checked={on} disabled={o.disabled} onChange={() => choose(o.id)} className="sr-only" />
64
+ <span aria-hidden="true" className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full ${on ? "bg-ink text-surface" : "border-2 border-ink/25"}`}>
65
+ {on && <Check size={12} strokeWidth={3} />}
66
+ </span>
67
+ <span className="flex min-w-0 flex-1 flex-wrap items-center gap-2 font-semibold text-ink">
68
+ {o.label}
69
+ {o.remaining && <span className="rounded-pill bg-ink/[0.08] px-2 py-0.5 text-xs font-semibold text-ink-soft">{o.remaining}</span>}
70
+ </span>
71
+ <span className="shrink-0 font-semibold text-ink">{o.price}</span>
72
+ </label>
73
+ );
74
+ })}
75
+ </div>
76
+ </fieldset>
77
+ <div className="mt-4 [&>*]:w-full [&>*]:justify-center">{action}</div>
78
+ {footnote && <p className="mt-3 text-sm text-ink-soft">{footnote}</p>}
79
+ </div>
80
+ </section>
81
+ );
82
+ }
@@ -0,0 +1,96 @@
1
+ "use client";
2
+
3
+ import { useEffect, useId, useRef, type ReactNode } from "react";
4
+ import { X } from "lucide-react";
5
+
6
+ type Labels = {
7
+ close: string;
8
+ };
9
+ const EN: Labels = { close: "Close" };
10
+
11
+ type Props = {
12
+ open: boolean;
13
+ /** Called when the person closes it (Esc, the backdrop, the close button). */
14
+ onClose: () => void;
15
+ /** Accessible name of the panel (sr-only unless you render it in `children`). */
16
+ title: string;
17
+ /** Small actions on the top bar, beside the close button ("Copy link", "Event page ↗"). */
18
+ actions?: ReactNode;
19
+ children: ReactNode;
20
+ labels?: Partial<Labels>;
21
+ className?: string;
22
+ };
23
+
24
+ /**
25
+ * Side panel of the Cuidado Global family (Luma's event drawer): a native
26
+ * <dialog> that slides in from the right, full height, up to 32rem wide (full
27
+ * width on the phone), with a top bar (close on the left, actions on the
28
+ * right) and a scrolling body. Focus is trapped by the dialog, Esc and the
29
+ * backdrop close it, focus goes back to what opened it. The content is
30
+ * whatever you pass: a course, an event, a person.
31
+ */
32
+ export function SidePanel({ open, onClose, title, actions, children, labels, className = "" }: Props) {
33
+ const t = { ...EN, ...labels };
34
+ const box = useRef<HTMLDialogElement>(null);
35
+ const headingId = useId();
36
+
37
+ // open/close follow the prop; the way out waits for the slide to finish
38
+ useEffect(() => {
39
+ const d = box.current;
40
+ if (!d) return;
41
+ if (open) {
42
+ if (!d.open) d.showModal();
43
+ // paint the closed state first (the dialog was display:none), then open:
44
+ // a forced reflow plus a frame, or the slide would not play
45
+ void d.getBoundingClientRect();
46
+ const frame = requestAnimationFrame(() => d.setAttribute("data-open", ""));
47
+ return () => cancelAnimationFrame(frame);
48
+ }
49
+ if (!d.open) return;
50
+ d.removeAttribute("data-open");
51
+ const wait = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ? 0 : 260;
52
+ const timer = window.setTimeout(() => d.close(), wait);
53
+ return () => window.clearTimeout(timer);
54
+ }, [open]);
55
+
56
+ // Esc (cancel) and click on the backdrop → the parent closes it
57
+ useEffect(() => {
58
+ const d = box.current;
59
+ if (!d) return;
60
+ const onCancel = (e: Event) => {
61
+ e.preventDefault();
62
+ onClose();
63
+ };
64
+ const onClick = (e: MouseEvent) => {
65
+ if (e.target === d) onClose();
66
+ };
67
+ d.addEventListener("cancel", onCancel);
68
+ d.addEventListener("click", onClick);
69
+ return () => {
70
+ d.removeEventListener("cancel", onCancel);
71
+ d.removeEventListener("click", onClick);
72
+ };
73
+ }, [onClose]);
74
+
75
+ return (
76
+ <dialog
77
+ ref={box}
78
+ aria-labelledby={headingId}
79
+ className={`fixed inset-y-0 right-0 left-auto m-0 flex h-dvh max-h-none w-[min(100vw,32rem)] max-w-none translate-x-full flex-col overflow-hidden bg-card p-0 text-ink shadow-2xl [transition:translate_280ms_var(--ease-cg)] data-open:translate-x-0 motion-reduce:transition-none backdrop:bg-ink/50 backdrop:backdrop-blur-sm backdrop:opacity-0 backdrop:[transition:opacity_240ms_ease-out] [&[data-open]::backdrop]:opacity-100 ${className}`}
80
+ >
81
+ <h2 id={headingId} className="sr-only">{title}</h2>
82
+ <div className="flex shrink-0 items-center justify-between gap-3 border-b border-ink/10 px-4 py-3">
83
+ <button
84
+ type="button"
85
+ onClick={onClose}
86
+ className="flex h-11 w-11 items-center justify-center rounded-full text-ink transition-colors duration-150 hover:bg-ink/[0.06] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none"
87
+ >
88
+ <X aria-hidden="true" size={20} strokeWidth={2} />
89
+ <span className="sr-only">{t.close}</span>
90
+ </button>
91
+ {actions && <div className="flex flex-wrap items-center justify-end gap-2">{actions}</div>}
92
+ </div>
93
+ <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">{children}</div>
94
+ </dialog>
95
+ );
96
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@breathe-ds/ui",
3
- "version": "0.4.0",
3
+ "version": "0.5.4",
4
4
  "description": "Breathe DS — the design system of the Cuidado Global family of sites (creeta, ttcab, metodorta). Themeable tokens + React components.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -15,9 +15,18 @@
15
15
  "LICENSE"
16
16
  ],
17
17
  "exports": {
18
- "./theme.css": "./theme.css",
19
- "./tokens/ttcab.css": "./tokens/ttcab.css",
20
- "./tokens/creeta.css": "./tokens/creeta.css",
18
+ "./theme.css": {
19
+ "style": "./theme.css",
20
+ "default": "./theme.css"
21
+ },
22
+ "./tokens/ttcab.css": {
23
+ "style": "./tokens/ttcab.css",
24
+ "default": "./tokens/ttcab.css"
25
+ },
26
+ "./tokens/creeta.css": {
27
+ "style": "./tokens/creeta.css",
28
+ "default": "./tokens/creeta.css"
29
+ },
21
30
  "./components/Accordion": "./components/Accordion.tsx",
22
31
  "./components/PageVideoHero": "./components/PageVideoHero.tsx",
23
32
  "./components/Footer": "./components/Footer.tsx",
@@ -42,7 +51,10 @@
42
51
  "./components/BreatheMark": "./components/BreatheMark.tsx",
43
52
  "./components/Curtain": "./components/Curtain.tsx",
44
53
  "./components/Lockup": "./components/Lockup.tsx",
45
- "./tokens/neutral.css": "./tokens/neutral.css",
54
+ "./tokens/neutral.css": {
55
+ "style": "./tokens/neutral.css",
56
+ "default": "./tokens/neutral.css"
57
+ },
46
58
  "./components/icons": "./components/icons.tsx",
47
59
  "./components/AnchorNav": "./components/AnchorNav.tsx",
48
60
  "./components/LanguageSwitcher": "./components/LanguageSwitcher.tsx",
@@ -68,7 +80,14 @@
68
80
  "./components/StoryPanel": "./components/StoryPanel.tsx",
69
81
  "./components/PostCard": "./components/PostCard.tsx",
70
82
  "./components/FeaturedPosts": "./components/FeaturedPosts.tsx",
71
- "./components/PostArticle": "./components/PostArticle.tsx"
83
+ "./components/PostArticle": "./components/PostArticle.tsx",
84
+ "./components/CourseCard": "./components/CourseCard.tsx",
85
+ "./components/DateBadge": "./components/DateBadge.tsx",
86
+ "./components/EventRow": "./components/EventRow.tsx",
87
+ "./components/EventList": "./components/EventList.tsx",
88
+ "./components/EventFacts": "./components/EventFacts.tsx",
89
+ "./components/RegistrationBox": "./components/RegistrationBox.tsx",
90
+ "./components/SidePanel": "./components/SidePanel.tsx"
72
91
  },
73
92
  "publishConfig": {
74
93
  "access": "public"