@breathe-ds/ui 0.6.1 → 0.6.3

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
@@ -209,6 +209,24 @@ from color, not from extra markup.
209
209
  centred) + **FilterBar** (thin sticky bar of compact filters — `SelectField density="sm"` —
210
210
  with the count and "clear" on the right; it publishes its height as `--cg-filterbar-h`, so
211
211
  `StickyGroupHeading stickyTop="top-[calc(80px+var(--cg-filterbar-h,0px))]"` sticks right below).
212
+ - **Callout** (a.k.a. info well): a secondary invitation under a page's main control
213
+ (location invite under the professionals search, a tip under the articles search), at
214
+ most one action. Never for errors or warnings. Default `tone="wash"`: no border, the
215
+ brand's `--cg-callout`, with `<Button variant="tonal">` and a tonal icon bubble
216
+ (`--cg-callout-deep`, hover `--cg-callout-deeper`). **Colour rule:** the wash is the
217
+ page's **split complement** (page hue ± 150°, picking the one farther from the action
218
+ hue), one luminance step off the page (1.19:1), so text keeps AA/AAA. TTCAB: cream 40° →
219
+ petrol mist `#d2e6e9`; Creeta: mist 190° → sand `#eddec1`; Neutral (no hue): gray
220
+ `#e1e1e4`. The kicker goes in ink-body (action-text drops under 4.5:1 on the wash).
221
+ `tone="accent"` (action-tint + action border) and `"neutral"` (white) remain.
222
+ - **Duotone** (`.cg-duotone` on the element wrapping an `<img>`; `CourseCard cover.treatment="duotone"`):
223
+ for untreated photos straight from a phone — ink in the shadows, page colour in the lights,
224
+ pure CSS (file and alt untouched). Tune per brand with `--cg-duotone-dark` / `--cg-duotone-light`.
225
+ - **KenBurnsImage** (family motion, used on covers): the slow documentary zoom as a
226
+ boomerang — while the card (closest `.group`) is hovered or focused, the photo zooms in
227
+ 10% and back out, 9s each way, endlessly; on leave it eases back to rest from wherever
228
+ it was (1.6s, Web Animations API, no snap). Drop-in for the cover `<img>`; `zoom`,
229
+ `duration`, `returnDuration` to tune. Off with `prefers-reduced-motion`. CourseCard uses it.
212
230
  - **PullQuote** + **Highlight**: one line set large between sections — drawn quote
213
231
  mark in the action color, balanced lines, hand-drawn underline (`Highlight`) on the
214
232
  key words. `cite` adds the person (Byline with role); without it, a brand statement.
@@ -1,6 +1,6 @@
1
1
  import type { ReactNode, ButtonHTMLAttributes, AnchorHTMLAttributes } from "react";
2
2
 
3
- type Variant = "solid" | "accent" | "outline" | "ghost" | "link" | "outline-inverse";
3
+ type Variant = "solid" | "accent" | "outline" | "ghost" | "link" | "outline-inverse" | "tonal";
4
4
  type Size = "sm" | "md" | "lg";
5
5
 
6
6
  const pillBase =
@@ -27,6 +27,9 @@ 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
+ // tonal: on a Callout, a step deeper than its wash with ink text — the action
31
+ // without competing with the page's main control
32
+ tonal: "bg-callout-deep text-ink hover:bg-callout-deeper",
30
33
  ghost: "text-ink hover:bg-tint",
31
34
  // secondary on a dark panel (hero panel, StoryPanel, surface-panel): light outline,
32
35
  // surface text (cream on petrol 9.41:1 on ttcab)
@@ -0,0 +1,59 @@
1
+ import type { ComponentType, ReactNode } from "react";
2
+
3
+ type IconType = ComponentType<{ size?: number; strokeWidth?: number; "aria-hidden"?: boolean | "true" }>;
4
+
5
+ type Props = {
6
+ title: ReactNode;
7
+ /** Small line above the title in action-text ("Perto de você"). */
8
+ kicker?: ReactNode;
9
+ /** One short sentence under the title. */
10
+ body?: ReactNode;
11
+ /** Small icon in a bubble on the left (lucide). */
12
+ Icon?: IconType;
13
+ /** The actions on the right: one button (variant="tonal" on the wash; "solid"
14
+ * or "outline" on the other tones) and, if needed, a text link ("Agora não"). Never the accent button:
15
+ * the callout sits under the page's main control and must not compete with it. */
16
+ actions?: ReactNode;
17
+ /** `wash` (default): the brand's callout wash (TTCAB: #d2e6e9, the split complement of
18
+ * the cream), no border. Pair it with <Button variant="tonal">.
19
+ * `accent`: the action-colour wash with a hairline border in the action colour.
20
+ * `neutral`: white, no border. */
21
+ tone?: "wash" | "accent" | "neutral";
22
+ /** Accessible name of the region (defaults to nothing: pass the title as text). */
23
+ label?: string;
24
+ className?: string;
25
+ };
26
+
27
+ /**
28
+ * Callout — a secondary invitation under a page's main control (e.g. "Quer ver
29
+ * primeiro quem atende perto de você?" under the professionals search, a tip
30
+ * under the articles search). The main control keeps the emphasis (white,
31
+ * shadow); the callout is a quiet wash a step off the page, without a border.
32
+ * Optical padding: the right side gets 8px more than the left (36 vs 28) — the round
33
+ * icon bubble reads as more inset than the straight edge of a button or underlined link.
34
+ * TTCAB (wash #d2e6e9): ink 7.90:1, ink-body 6.98:1; the kicker goes in ink-body
35
+ * because action-text would drop to 4.18:1 there.
36
+ */
37
+ export function Callout({ title, kicker, body, Icon, actions, tone = "wash", label, className = "" }: Props) {
38
+ const accent = tone === "accent";
39
+ const box = { wash: "bg-callout", accent: "border border-action-line bg-action-tint", neutral: "bg-card" }[tone];
40
+ return (
41
+ <div
42
+ role={label ? "region" : undefined}
43
+ aria-label={label}
44
+ className={`flex flex-col gap-4 rounded-[24px] p-5 sm:flex-row sm:items-center sm:gap-5 sm:pr-6 md:py-6 md:pr-9 md:pl-7 ${box} ${className}`}
45
+ >
46
+ {Icon && (
47
+ <span aria-hidden="true" className={`hidden h-12 w-12 shrink-0 items-center justify-center rounded-full sm:flex ${accent ? "bg-card text-action-text" : tone === "wash" ? "bg-callout-deep text-ink" : "bg-tint text-ink"}`}>
48
+ <Icon size={22} strokeWidth={1.8} />
49
+ </span>
50
+ )}
51
+ <div className="min-w-0 flex-1">
52
+ {kicker && <p className={`m-0 mb-1 text-xs font-bold tracking-[0.12em] uppercase ${accent ? "text-action-text" : "text-ink-body"}`}>{kicker}</p>}
53
+ <p className="m-0 font-heading text-lg leading-snug font-semibold text-ink md:text-xl">{title}</p>
54
+ {body && <p className="m-0 mt-1 text-sm leading-relaxed text-ink-body">{body}</p>}
55
+ </div>
56
+ {actions && <div className="flex shrink-0 flex-wrap items-center gap-x-4 gap-y-2">{actions}</div>}
57
+ </div>
58
+ );
59
+ }
@@ -2,6 +2,7 @@ import type { ElementType, ReactNode } from "react";
2
2
  import { Clock } from "lucide-react";
3
3
  import { Byline, type Person } from "./Byline";
4
4
  import { EventFacts, DateBadge, type EventFact } from "./EventFacts";
5
+ import { KenBurnsImage } from "./KenBurnsImage";
5
6
 
6
7
  type Labels = {
7
8
  /** Prefix before the instructors' names ("With"). */
@@ -13,8 +14,13 @@ const EN: Labels = { with: "With", soldOut: "Sold out" };
13
14
 
14
15
  type Props = {
15
16
  /** 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 };
17
+ * focused near the top for the portrait photos the school uses.
18
+ * `treatment: "duotone"` for an untreated photo (straight from a phone): it is
19
+ * shown in the brand's two colours (ink shadows, page-colour lights).
20
+ * While the card is hovered/focused the photo does the family's slow Ken Burns
21
+ * boomerang (KenBurnsImage: in and out, 9s each way, eases back on leave);
22
+ * off with reduced motion. */
23
+ cover?: { src: string; alt?: string; position?: string; treatment?: "duotone" };
18
24
  /** The course ("Basic RTA course 2026"): the heading of the card. */
19
25
  course?: ReactNode;
20
26
  /** The class ("City · ST"): the line under the heading, with the seats pill.
@@ -83,11 +89,11 @@ export function CourseCard({ cover, course, title, start, when, where, workload,
83
89
  );
84
90
 
85
91
  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}`}>
92
+ <article className={`group 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
93
  {cover && (
88
- <div className="relative aspect-[4/3] overflow-hidden">
94
+ <div className={`relative aspect-[4/3] overflow-hidden ${cover.treatment === "duotone" ? "cg-duotone" : ""}`}>
89
95
  {/* 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%" }} />
96
+ <KenBurnsImage 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
97
  </div>
92
98
  )}
93
99
  <div className="flex flex-1 flex-col p-6 md:p-7">
@@ -0,0 +1,71 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, type ImgHTMLAttributes } from "react";
4
+
5
+ type Props = ImgHTMLAttributes<HTMLImageElement> & {
6
+ /** How far it zooms in (1.1 = 10%). */
7
+ zoom?: number;
8
+ /** One way of the boomerang, in ms (in, then the same back out). */
9
+ duration?: number;
10
+ /** How long it takes to settle back when the pointer leaves, in ms. */
11
+ returnDuration?: number;
12
+ };
13
+
14
+ const EASE = "cubic-bezier(0.45, 0, 0.55, 1)";
15
+
16
+ /**
17
+ * Ken Burns (boomerang) — the family's slow documentary zoom on covers. While
18
+ * the pointer is on the card (the closest `.group`, or the image itself) or focus
19
+ * is inside it, the photo zooms in and back out, over and over; when it leaves,
20
+ * the photo eases back to rest from wherever it was (no snap). Web Animations
21
+ * API, so it can start from the current frame. Off with prefers-reduced-motion.
22
+ * Use it in place of the <img> of a cover (`position: absolute; inset: 0` etc. via
23
+ * className, as usual).
24
+ */
25
+ export function KenBurnsImage({ zoom = 1.1, duration = 9000, returnDuration = 1600, style, ...img }: Props) {
26
+ const ref = useRef<HTMLImageElement>(null);
27
+
28
+ useEffect(() => {
29
+ const el = ref.current;
30
+ if (!el || typeof el.animate !== "function") return;
31
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
32
+ const host = (el.closest(".group") as HTMLElement | null) ?? el;
33
+ const end = { scale: String(zoom), translate: "-1.5% -1%" };
34
+ let anim: Animation | null = null;
35
+
36
+ const now = () => {
37
+ const cs = getComputedStyle(el);
38
+ return { scale: cs.scale === "none" ? "1" : cs.scale, translate: cs.translate === "none" ? "0px 0px" : cs.translate };
39
+ };
40
+ const start = () => {
41
+ const from = now();
42
+ anim?.cancel();
43
+ // first leg from wherever it is, then the endless in-and-out
44
+ anim = el.animate([from, end], { duration, easing: EASE, fill: "forwards" });
45
+ anim.onfinish = () => {
46
+ anim = el.animate([end, { scale: "1", translate: "0px 0px" }], { duration, easing: EASE, iterations: Infinity, direction: "alternate" });
47
+ };
48
+ };
49
+ const stop = () => {
50
+ if (host.matches(":hover") || host.matches(":focus-within")) return;
51
+ const from = now();
52
+ anim?.cancel();
53
+ anim = el.animate([from, { scale: "1", translate: "0px 0px" }], { duration: returnDuration, easing: EASE });
54
+ };
55
+
56
+ host.addEventListener("pointerenter", start);
57
+ host.addEventListener("pointerleave", stop);
58
+ host.addEventListener("focusin", start);
59
+ host.addEventListener("focusout", stop);
60
+ return () => {
61
+ host.removeEventListener("pointerenter", start);
62
+ host.removeEventListener("pointerleave", stop);
63
+ host.removeEventListener("focusin", start);
64
+ host.removeEventListener("focusout", stop);
65
+ anim?.cancel();
66
+ };
67
+ }, [zoom, duration, returnDuration]);
68
+
69
+ // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text
70
+ return <img ref={ref} style={{ transformOrigin: style?.objectPosition as string | undefined, ...style }} {...img} />;
71
+ }
@@ -208,10 +208,13 @@ export function TestimonialCard({ quote, author, authorPhoto, authorAvatar, auth
208
208
  joiners={joiners}
209
209
  />
210
210
  );
211
+ // the whole card opens the panel (stretched link: the button's ::after covers the card);
212
+ // the video and the source link sit above it (relative z-[1]) and keep their own click
213
+ const opens = overflows || !!panel;
211
214
  return (
212
- <Root className={`${cardVideo || cardPhoto ? "grid grid-cols-1 sm:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]" : "flex"} overflow-hidden rounded-[28px] bg-card ${className}`}>
215
+ <Root className={`${opens ? "group/card relative" : ""} ${cardVideo || cardPhoto ? "grid grid-cols-1 sm:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]" : "flex"} overflow-hidden rounded-[28px] bg-card ${className}`}>
213
216
  {cardVideo ? (
214
- <TapVideo {...cardVideo} label={t.watch(cardVideo.description)} />
217
+ <TapVideo {...cardVideo} label={t.watch(cardVideo.description)} frameClass="relative z-[1] aspect-video overflow-hidden bg-ink sm:aspect-auto sm:min-h-[16rem]" />
215
218
  ) : cardPhoto ? (
216
219
  <div className="relative aspect-[4/3] overflow-hidden bg-tint sm:aspect-auto sm:min-h-[16rem]">
217
220
  {cardPhoto.media ?? (
@@ -240,17 +243,17 @@ export function TestimonialCard({ quote, author, authorPhoto, authorAvatar, auth
240
243
  {quote}
241
244
  </blockquote>
242
245
  )}
243
- {(overflows || panel) && (
246
+ {opens && (
244
247
  <button
245
248
  type="button"
246
249
  onClick={() => setOpen(true)}
247
- className="mt-3 inline-flex min-h-11 items-center gap-1 text-left font-semibold text-ink underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink"
250
+ className="mt-3 inline-flex min-h-11 items-center gap-1 text-left font-semibold text-ink underline decoration-ink/40 underline-offset-4 transition-[text-decoration-color] duration-150 after:absolute after:inset-0 after:rounded-[28px] after:content-[''] group-hover/card:decoration-ink focus-visible:outline-none focus-visible:after:outline-2 focus-visible:after:outline-offset-2 focus-visible:after:outline-ink motion-reduce:transition-none"
248
251
  >
249
252
  {video && !cardVideo ? t.watchFull : t.readMore}
250
253
  </button>
251
254
  )}
252
255
  </div>
253
- <figcaption>{byline}</figcaption>
256
+ <figcaption className={link ? "relative z-[1]" : undefined}>{byline}</figcaption>
254
257
  </figure>
255
258
  {(clampLines || panel) && (
256
259
  <SidePanel open={open} onClose={() => setOpen(false)} title={names} labels={{ close: t.close }}>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@breathe-ds/ui",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
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",
@@ -99,7 +99,9 @@
99
99
  "./components/SidePanel": "./components/SidePanel.tsx",
100
100
  "./components/PullQuote": "./components/PullQuote.tsx",
101
101
  "./components/ProfileSpotlight": "./components/ProfileSpotlight.tsx",
102
- "./components/FilterBar": "./components/FilterBar.tsx"
102
+ "./components/FilterBar": "./components/FilterBar.tsx",
103
+ "./components/Callout": "./components/Callout.tsx",
104
+ "./components/KenBurnsImage": "./components/KenBurnsImage.tsx"
103
105
  },
104
106
  "publishConfig": {
105
107
  "access": "public"
package/theme.css CHANGED
@@ -29,6 +29,9 @@
29
29
  --color-action-line: var(--cg-action-line, var(--cg-action)); /* border of a highlighted card (3:1 on surface) */
30
30
  --color-line: var(--cg-line); /* hairlines/borders */
31
31
  --color-surface-alt: var(--cg-surface-alt, var(--cg-surface)); /* alternate section band (rhythm), a step deeper than surface */
32
+ --color-callout: var(--cg-callout, var(--cg-surface-alt, var(--cg-surface))); /* secondary invitation under a main control: a cool wash, no border */
33
+ --color-callout-deep: var(--cg-callout-deep, var(--cg-tint, var(--cg-surface))); /* tonal controls on the callout (button, icon bubble) */
34
+ --color-callout-deeper: var(--cg-callout-deeper, var(--cg-callout-deep, var(--cg-surface))); /* their hover */
32
35
  --color-tint: var(--cg-tint, color-mix(in srgb, var(--cg-ink) 8%, transparent)); /* neutral fill: icon bubbles, pills, image placeholders */
33
36
  --color-shadow: var(--cg-shadow, rgb(20 71 83)); /* hue of soft shadows (used at low alpha via color-mix) */
34
37
 
@@ -294,3 +297,27 @@
294
297
  -webkit-mask-image: linear-gradient(to right, #000 0, #000 7%, transparent 17%, transparent 83%, #000 93%, #000 100%);
295
298
  mask-image: linear-gradient(to right, #000 0, #000 7%, transparent 17%, transparent 83%, #000 93%, #000 100%);
296
299
  }
300
+
301
+ /* Duotone: for photos that were not treated (straight from a phone camera) — the
302
+ picture turns to two brand colours, ink in the shadows and the page colour in
303
+ the lights, so a busy, off-palette photo sits in the family. Put it on the
304
+ element that wraps the <img> (it needs `relative` + `overflow-hidden`, which
305
+ covers already have). Brands can tune it: --cg-duotone-dark / --cg-duotone-light.
306
+ Pure CSS: the file is untouched, the alt text stays. */
307
+ .cg-duotone {
308
+ isolation: isolate;
309
+ background: var(--cg-duotone-light, var(--color-surface));
310
+ }
311
+ .cg-duotone img {
312
+ filter: grayscale(1) contrast(1.08) brightness(1.04);
313
+ mix-blend-mode: multiply;
314
+ }
315
+ .cg-duotone::after {
316
+ content: "";
317
+ position: absolute;
318
+ inset: 0;
319
+ pointer-events: none;
320
+ background: var(--cg-duotone-dark, var(--color-ink));
321
+ mix-blend-mode: lighten;
322
+ }
323
+
package/tokens/creeta.css CHANGED
@@ -20,6 +20,10 @@
20
20
  --cg-action-line: #ab2781; /* magenta itself: 5.62:1 on mist */
21
21
  --cg-on-action: #ffffff; /* white on magenta */
22
22
  --cg-surface-alt: #e4ecee; /* section band: ink 8.53:1 · ink-soft 5.41:1 · action-text 6.79:1 */
23
+ /* Callout: split complement of the mist (190° → 40°, the one farther from the magenta) */
24
+ --cg-callout: #eddec1; /* sand · vs page 1.19:1 · ink 7.69:1 · ink-body 6.81:1 */
25
+ --cg-callout-deep: #e8cd98; /* tonal button + icon bubble · ink 6.62:1 */
26
+ --cg-callout-deeper: #e5be72; /* tonal button hover · ink 5.81:1 */
23
27
  --cg-line: rgb(20 71 83 / 0.16);
24
28
  --cg-font-heading: var(--font-quicksand), system-ui, sans-serif; /* Quicksand */
25
29
  --cg-font-body: var(--font-figtree), system-ui, sans-serif; /* Figtree */
@@ -23,6 +23,10 @@
23
23
  --cg-action-tint: #e9e9ea;
24
24
  --cg-action-line: #18181b;
25
25
  --cg-on-action: #ffffff;
26
+ /* Callout: no hue to complement — one gray step down (vs page 1.19:1) */
27
+ --cg-callout: #e1e1e4;
28
+ --cg-callout-deep: #d4d4d8;
29
+ --cg-callout-deeper: #c4c4c9;
26
30
  --cg-line: rgb(0 0 0 / 0.12);
27
31
  --cg-font-heading: var(--font-quicksand), system-ui, sans-serif; /* Quicksand (fixed across the family) */
28
32
  --cg-font-body: var(--font-figtree), system-ui, sans-serif; /* Figtree (fixed across the family) */
package/tokens/ttcab.css CHANGED
@@ -29,6 +29,10 @@
29
29
  /* Warm neutrals (30/10/2026): lines, fills and shadows come from the logo's
30
30
  orange family (hue ~36°), never grey — the cream and the brown stay one family. */
31
31
  --cg-line: rgb(150 108 62 / 0.22); /* warm hairline */
32
+ /* Callout: split complement of the cream (40° → 190°), the petrol's own hue lightened */
33
+ --cg-callout: #d2e6e9; /* vs page 1.19:1 · ink 7.90:1 · ink-body 6.98:1 */
34
+ --cg-callout-deep: #afd8de; /* tonal button + icon bubble · ink 6.66:1 */
35
+ --cg-callout-deeper: #8ecdd6; /* tonal button hover · ink 5.77:1 */
32
36
  --cg-tint: #f7ecd8; /* icon bubbles, pills, placeholders: ink 8.73:1 · ink-soft 4.51:1 */
33
37
  --cg-shadow: rgb(107 74 55); /* mocha: soft shadows, used at low alpha */
34
38
  --cg-font-heading: var(--font-quicksand), system-ui, sans-serif; /* Quicksand */