@breathe-ds/ui 0.6.0 → 0.6.2
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 +18 -0
- package/components/Button.tsx +4 -1
- package/components/Callout.tsx +59 -0
- package/components/ContactRow.tsx +7 -1
- package/components/CourseCard.tsx +11 -5
- package/components/KenBurnsImage.tsx +71 -0
- package/components/PublicationCard.tsx +94 -0
- package/package.json +4 -2
- package/theme.css +27 -0
- package/tokens/creeta.css +4 -0
- package/tokens/neutral.css +4 -0
- package/tokens/ttcab.css +4 -0
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.
|
package/components/Button.tsx
CHANGED
|
@@ -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
|
+
}
|
|
@@ -101,7 +101,13 @@ export function ContactRow({ name, nameDisplay, meta, actions, defaultOpen = fal
|
|
|
101
101
|
* the group, rows separated by a hairline that starts at the name (after the
|
|
102
102
|
* 40px avatar + its padding: 70px, 74px from md), drawn as a ::before so the
|
|
103
103
|
* row itself never moves. */
|
|
104
|
-
export function ContactList({ children, className = "" }: { children: ReactNode; className?: string }) {
|
|
104
|
+
export function ContactList({ children, inset = true, className = "" }: { children: ReactNode; /** false: hairline from the edge (rows without an avatar, e.g. PublicationRow). */ inset?: boolean; className?: string }) {
|
|
105
|
+
if (!inset)
|
|
106
|
+
return (
|
|
107
|
+
<ul className={`m-0 list-none overflow-hidden rounded-[22px] bg-card p-0 [&>li+li]:border-t [&>li+li]:border-line ${className}`}>
|
|
108
|
+
{children}
|
|
109
|
+
</ul>
|
|
110
|
+
);
|
|
105
111
|
return (
|
|
106
112
|
<ul
|
|
107
113
|
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}`}
|
|
@@ -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
|
-
|
|
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=
|
|
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
|
-
<
|
|
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
|
+
}
|
|
@@ -213,3 +213,97 @@ export function PublicationCard({
|
|
|
213
213
|
</article>
|
|
214
214
|
);
|
|
215
215
|
}
|
|
216
|
+
|
|
217
|
+
type RowProps = {
|
|
218
|
+
title: string;
|
|
219
|
+
/** Title as rendered (e.g. with the search match marked). */
|
|
220
|
+
titleDisplay?: ReactNode;
|
|
221
|
+
lang?: string;
|
|
222
|
+
/** One quiet line under the title: "Ensaio clínico randomizado · Pastor-Pons e mais 6". */
|
|
223
|
+
meta?: ReactNode;
|
|
224
|
+
abstract?: string[];
|
|
225
|
+
abstractLang?: string;
|
|
226
|
+
/** The publication's page. */
|
|
227
|
+
href?: string;
|
|
228
|
+
pdf?: string;
|
|
229
|
+
defaultOpen?: boolean;
|
|
230
|
+
headingLevel?: "h2" | "h3";
|
|
231
|
+
linkComponent?: ElementType;
|
|
232
|
+
labels?: Partial<PublicationCardLabels>;
|
|
233
|
+
className?: string;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* PublicationRow — a study in a long library, as a row of a ContactList
|
|
238
|
+
* (the same white inset-grouped list as the professionals directory): the
|
|
239
|
+
* list shows only the title and one quiet line (type · first author); tapping
|
|
240
|
+
* opens the abstract and two plain-text links, "read the article" and the PDF.
|
|
241
|
+
* The list of 16 big cards was a wall; this is a list of titles.
|
|
242
|
+
*/
|
|
243
|
+
export function PublicationRow({
|
|
244
|
+
title,
|
|
245
|
+
titleDisplay,
|
|
246
|
+
lang,
|
|
247
|
+
meta,
|
|
248
|
+
abstract,
|
|
249
|
+
abstractLang,
|
|
250
|
+
href,
|
|
251
|
+
pdf,
|
|
252
|
+
defaultOpen = false,
|
|
253
|
+
headingLevel: H = "h2",
|
|
254
|
+
linkComponent,
|
|
255
|
+
labels,
|
|
256
|
+
className = "",
|
|
257
|
+
}: RowProps) {
|
|
258
|
+
const t = { ...EN, ...labels };
|
|
259
|
+
const L: ElementType = linkComponent ?? "a";
|
|
260
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
261
|
+
const panel = useId();
|
|
262
|
+
const LINK =
|
|
263
|
+
"group/act inline-flex min-h-11 items-center gap-2 rounded-[2px] text-[0.9375rem] font-semibold 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";
|
|
264
|
+
return (
|
|
265
|
+
<li className={`relative list-none ${className}`}>
|
|
266
|
+
<H className="m-0 font-normal">
|
|
267
|
+
<button
|
|
268
|
+
type="button"
|
|
269
|
+
aria-expanded={open}
|
|
270
|
+
aria-controls={panel}
|
|
271
|
+
onClick={() => setOpen((v) => !v)}
|
|
272
|
+
className="group/row flex min-h-16 w-full items-center gap-4 px-5 py-3.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-6"
|
|
273
|
+
>
|
|
274
|
+
<span className="min-w-0 flex-1">
|
|
275
|
+
<span lang={lang} className="line-clamp-3 block font-heading text-[1.0625rem] leading-snug font-medium text-pretty text-ink">
|
|
276
|
+
{titleDisplay ?? title}
|
|
277
|
+
</span>
|
|
278
|
+
{meta && <span className="mt-1 block text-sm text-ink-soft">{meta}</span>}
|
|
279
|
+
</span>
|
|
280
|
+
<ChevronDown
|
|
281
|
+
aria-hidden="true"
|
|
282
|
+
size={18}
|
|
283
|
+
className={`shrink-0 text-ink-soft transition-transform duration-200 motion-reduce:transition-none ${open ? "rotate-180" : "group-hover/row:translate-y-0.5"}`}
|
|
284
|
+
/>
|
|
285
|
+
</button>
|
|
286
|
+
</H>
|
|
287
|
+
<div id={panel} hidden={!open} className={`${FADE_IN} px-5 pb-5 md:px-6`}>
|
|
288
|
+
{abstract && abstract.length > 0 && <AbstractText paragraphs={abstract} lang={abstractLang ?? lang} className="text-[0.9375rem]" />}
|
|
289
|
+
{(href || pdf) && (
|
|
290
|
+
<div className="mt-3 flex flex-wrap gap-x-6">
|
|
291
|
+
{href && (
|
|
292
|
+
<L href={href} className={LINK}>
|
|
293
|
+
{t.read}
|
|
294
|
+
<ArrowRight aria-hidden="true" size={16} className="text-ink-soft transition-transform duration-200 group-hover/act:translate-x-0.5 motion-reduce:transform-none" />
|
|
295
|
+
</L>
|
|
296
|
+
)}
|
|
297
|
+
{pdf && (
|
|
298
|
+
<a href={pdf} target="_blank" rel="noopener noreferrer" className={LINK}>
|
|
299
|
+
<FileText aria-hidden="true" size={16} className="text-ink-soft" />
|
|
300
|
+
{t.pdf}
|
|
301
|
+
<span className="sr-only">{` ${t.of} ${title} ${t.newTab}`}</span>
|
|
302
|
+
</a>
|
|
303
|
+
)}
|
|
304
|
+
</div>
|
|
305
|
+
)}
|
|
306
|
+
</div>
|
|
307
|
+
</li>
|
|
308
|
+
);
|
|
309
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@breathe-ds/ui",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
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 */
|
package/tokens/neutral.css
CHANGED
|
@@ -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 */
|